├── .flake8 ├── .github └── dependabot.yml ├── .gitignore ├── LICENSE ├── README.md ├── core_modules ├── bot_manage.py ├── bot_status.py ├── error_handler.py ├── module_manage.py └── recall.py ├── libs ├── __init__.py ├── better_pydantic.py ├── config.py ├── control │ ├── __init__.py │ ├── interval.py │ └── permission.py ├── database │ ├── __init__.py │ ├── models.py │ └── types.py ├── database_services.py ├── fonts_provider.py ├── send_action.py └── text2img.py ├── main.py ├── modules ├── bili_share_resolver.py ├── dont_nudge_me.py ├── eat_what │ ├── __init__.py │ └── foods.txt ├── mc_wiki_searcher.py ├── minecraft_ping │ ├── __init__.py │ ├── aiodns_resolver.py │ ├── ping_client.py │ └── utils.py ├── msg2img.py ├── read_and_send_msg.py ├── renpin_checker.py ├── roll.py └── the_wondering_earth_counting_down.py ├── pdm.lock ├── pyproject.toml └── static ├── __init__.py ├── fonts └── .gitkeep ├── jinja2_templates └── bili_video.html └── path.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | extend-ignore = 4 | # See https://github.com/PyCQA/pycodestyle/issues/373 5 | E203, 6 | 7 | exclude = .git, 8 | __pycache__, 9 | .github/*, 10 | .idea/*, 11 | .vscode/*, 12 | data/*, 13 | logs/*, 14 | config/* 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 20 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | data/ 2 | config/ 3 | logs/ 4 | test/ 5 | test.py 6 | alembic_data/ 7 | alembic.ini 8 | static/fonts/* 9 | !static/fonts/.gitkeep 10 | 11 | # VSCode 12 | .vscode 13 | 14 | # Private modules 15 | *modules/fastapi/ 16 | !removed_modules/fastapi/ 17 | 18 | # Byte-compiled / optimized / DLL files 19 | __pycache__/ 20 | *.py[cod] 21 | *$py.class 22 | 23 | # C extensions 24 | *.so 25 | 26 | # Distribution / packaging 27 | .Python 28 | build/ 29 | develop-eggs/ 30 | dist/ 31 | downloads/ 32 | eggs/ 33 | .eggs/ 34 | lib/ 35 | lib64/ 36 | parts/ 37 | sdist/ 38 | var/ 39 | wheels/ 40 | share/python-wheels/ 41 | *.egg-info/ 42 | .installed.cfg 43 | *.egg 44 | MANIFEST 45 | 46 | # PyInstaller 47 | # Usually these files are written by a python script from a template 48 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 49 | *.manifest 50 | *.spec 51 | 52 | # Installer logs 53 | pip-log.txt 54 | pip-delete-this-directory.txt 55 | 56 | # Unit test / coverage reports 57 | htmlcov/ 58 | .tox/ 59 | .nox/ 60 | .coverage 61 | .coverage.* 62 | .cache 63 | nosetests.xml 64 | coverage.xml 65 | *.cover 66 | *.py,cover 67 | .hypothesis/ 68 | .pytest_cache/ 69 | cover/ 70 | 71 | # Translations 72 | *.mo 73 | *.pot 74 | 75 | # Django stuff: 76 | *.log 77 | local_settings.py 78 | db.sqlite3 79 | db.sqlite3-journal 80 | 81 | # Flask stuff: 82 | instance/ 83 | .webassets-cache 84 | 85 | # Scrapy stuff: 86 | .scrapy 87 | 88 | # Sphinx documentation 89 | docs/_build/ 90 | 91 | # PyBuilder 92 | .pybuilder/ 93 | target/ 94 | 95 | # Jupyter Notebook 96 | .ipynb_checkpoints 97 | 98 | # IPython 99 | profile_default/ 100 | ipython_config.py 101 | 102 | # pyenv 103 | # For a library or package, you might want to ignore these files since the code is 104 | # intended to run in multiple environments; otherwise, check them in: 105 | # .python-version 106 | 107 | # pipenv 108 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 109 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 110 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 111 | # install all needed dependencies. 112 | #Pipfile.lock 113 | 114 | # poetry 115 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 116 | # This is especially recommended for binary packages to ensure reproducibility, and is more 117 | # commonly ignored for libraries. 118 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 119 | #poetry.lock 120 | 121 | # pdm 122 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 123 | #pdm.lock 124 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 125 | # in version control. 126 | # https://pdm.fming.dev/#use-with-ide 127 | .pdm-python 128 | 129 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 130 | __pypackages__/ 131 | 132 | # Celery stuff 133 | celerybeat-schedule 134 | celerybeat.pid 135 | 136 | # SageMath parsed files 137 | *.sage.py 138 | 139 | # Environments 140 | .env 141 | .venv 142 | env/ 143 | venv/ 144 | ENV/ 145 | env.bak/ 146 | venv.bak/ 147 | 148 | # Spyder project settings 149 | .spyderproject 150 | .spyproject 151 | 152 | # Rope project settings 153 | .ropeproject 154 | 155 | # mkdocs documentation 156 | /site 157 | 158 | # mypy 159 | .mypy_cache/ 160 | .dmypy.json 161 | dmypy.json 162 | 163 | # Pyre type checker 164 | .pyre/ 165 | 166 | # pytype static type analyzer 167 | .pytype/ 168 | 169 | # Cython debug symbols 170 | cython_debug/ 171 | 172 | # PyCharm 173 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 174 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 175 | # and can be added to the global gitignore or merged into this file. For a more nuclear 176 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 177 | .idea/ 178 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # redbot 4 | 5 | 一个以 [Graia Ariadne](https://github.com/GraiaProject/Ariadne) 框架为基础的 QQ 机器人 6 | 7 |
8 | 9 |

10 | works for me 11 | works on my machine 12 | 13 | Licence 14 | 15 | 16 | Code style: black 17 | 18 | 19 | Imports: isort 20 | 21 |

22 | 23 |

24 | 访问次数 25 |

26 | 27 | ## 注意事项 28 | 29 | 1. **本仓库经常 Force Push,不建议 Fork 和 Clone** 30 | 2. 本 Bot 的设计只考虑本人需求随时可能大改,推荐使用 [`BBot`](https://github.com/djkcyl/BBot-Graia/) 和 [`sagiri-bot`](https://github.com/SAGIRI-kawaii/sagiri-bot) 31 | 32 | ## 功能列表 33 | 34 | - Bot 菜单、模块管理及查询 35 | - Bot 管理 36 | - Bot 版本与系统运行情况查询 37 | - 撤回 Bot 自己最近发的消息 38 | - 吃啥? 39 | - 我的世界服务器 Ping 40 | - 我的世界中文 Wiki 搜索 41 | - BiliBili 视频分享解析:支持小程序、卡片分享、av号、BV号、B站链接 42 | - 别戳我 43 | - 文本转图片 44 | - 历史聊天数据记录 45 | - 每日抽签(人品检测):~~狗屁不通的签文生成~~ 46 | - 随机数抽取 47 | 48 | ## 部署 49 | 50 | 请参阅 [如何部署 redbot](https://github.com/Redlnn/redbot/wiki/%E5%A6%82%E4%BD%95%E9%83%A8%E7%BD%B2-redbot) 51 | 52 | ## 鸣谢 & 相关项目 53 | 54 | > 这些项目也很棒, 可以去他们的项目主页看看, 点个 Star 鼓励他们的开发工作吧 55 | 56 | 特别感谢 [`Mamoe Technologies`](https://github.com/mamoe) 给我们带来这些精彩的项目: 57 | 58 | - [`mirai`](https://github.com/mamoe/mirai) & [`mirai-console`](https://github.com/mamoe/mirai-console): 一个在全平台下运行,提供 QQ Android 和 TIM PC 协议支持的高效率机器人框架 59 | - [`mirai-api-http`](https://github.com/project-mirai/mirai-api-http): 为本项目提供与 mirai 交互方式的 mirai-console 插件 60 | 61 | 感谢 [`GraiaProject`](https://github.com/GraiaProject) 给我们带来这些项目: 62 | 63 | - [`Broadcast Control`](https://github.com/GraiaProject/BroadcastControl): 高性能, 高可扩展性,设计简洁,基于 asyncio 的事件系统 64 | - [`Ariadne`](https://github.com/GraiaProject/Ariadne): 一个设计精巧, 协议实现完备的, 基于 mirai-api-http v2 的即时聊天软件自动化框架 65 | - [`Saya`](https://github.com/GraiaProject/Saya) 简洁的模块管理系统 66 | - [`Scheduler`](https://github.com/GraiaProject/Scheduler): 简洁的基于 `asyncio` 的定时任务实现 67 | - [`Application`](https://github.com/GraiaProject/Application): Ariadne 的前身,一个设计精巧, 协议实现完备的, 基于 mirai-api-http 的即时聊天软件自动化框架 68 | 69 | 本 QQ 机器人在开发中还参考了如下项目: 70 | 71 | - [`ABot`](https://github.com/djkcyl/ABot-Graia/): 一个使用 [Graia-Ariadne](https://github.com/GraiaProject/Ariadne) 搭建的 QQ 功能性~~究极缝合怪~~机器人 72 | - [`Xenon`](https://github.com/McZoo/Xenon): 一个优雅,用户友好的,基于 [mirai](https://github.com/mamoe/mirai) 与 [Graia Project](https://github.com/GraiaProject/) 的 QQ 机器人应用 73 | - [`SereinFish Bot`](https://github.com/coide-SaltedFish/SereinFish) 74 | -------------------------------------------------------------------------------- /core_modules/bot_manage.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import contextlib 3 | from random import uniform 4 | 5 | import kayaku 6 | from graia.ariadne.app import Ariadne 7 | from graia.ariadne.event.lifecycle import ApplicationLaunched 8 | from graia.ariadne.event.message import FriendMessage 9 | from graia.ariadne.event.mirai import ( 10 | BotGroupPermissionChangeEvent, 11 | BotInvitedJoinGroupRequestEvent, 12 | BotJoinGroupEvent, 13 | BotLeaveEventActive, 14 | BotLeaveEventKick, 15 | NewFriendRequestEvent, 16 | ) 17 | from graia.ariadne.exception import UnknownTarget 18 | from graia.ariadne.message.chain import MessageChain 19 | from graia.ariadne.message.element import Plain 20 | from graia.ariadne.message.parser.twilight import ( 21 | RegexMatch, 22 | RegexResult, 23 | SpacePolicy, 24 | Twilight, 25 | ) 26 | from graia.ariadne.model import Friend 27 | from graia.saya import Channel 28 | from graiax.shortcut.interrupt import FunctionWaiter 29 | from graiax.shortcut.saya import decorate, dispatch, listen 30 | from loguru import logger 31 | 32 | from libs.config import basic_cfg 33 | from libs.control import require_disable 34 | from libs.control.permission import perm_cfg 35 | 36 | channel = Channel.current() 37 | 38 | channel.meta['author'] = ['Red_lnn', 'A60(djkcyl)'] 39 | channel.meta['name'] = 'Bot管理' 40 | channel.meta['description'] = '[.!!]添加群白名单 [群号]\n[.!!]添加群黑名单 [群号]\n[.!!]添加用户黑名单 [QQ号]' 41 | channel.meta['can_disable'] = False 42 | 43 | ASCII_LOGO = r'''_____ _____ _____ _____ _____ _____ 44 | | _ \ | ____|| _ \| _ \/ _ \|_ _| 45 | | |_| | | |__ | | | || |_| || | | | | | 46 | | _ / | __| | | | || _ <| | | | | | 47 | | | \ \ | |___ | |_| || |_| || |_| | | | 48 | |_| \_\|_____||_____/|_____/\_____/ |_| 49 | ''' 50 | 51 | 52 | async def send_to_admin(message: MessageChain): 53 | app = Ariadne.current() 54 | for admin in basic_cfg.admin.admins: 55 | with contextlib.suppress(UnknownTarget, ValueError): 56 | await app.send_friend_message(admin, message) 57 | await asyncio.sleep(uniform(0.5, 1.5)) 58 | 59 | 60 | @listen(ApplicationLaunched) 61 | async def launch_handler(): 62 | logger.opt(colors=True, raw=True).info( 63 | f'{ASCII_LOGO}', 64 | alt=f'[cyan]{ASCII_LOGO}[/]', 65 | ) 66 | logger.success('launched!') 67 | 68 | 69 | # @listen(ApplicationLaunched) 70 | # @decorate(require_disable(channel.module)) 71 | # async def launched(app: Ariadne): 72 | # group_list = await app.get_group_list() 73 | # quit_groups = 0 74 | # # for group in groupList: 75 | # # if group.id not in perm_cfg.group_whitelist: 76 | # # await app.quit_group(group) 77 | # # quit_groups += 1 78 | # msg = f'{basic_cfg.botName} 成功启动,当前共加入了 {len(group_list) - quit_groups} 个群' 79 | # # if quit_groups > 0: 80 | # # msg += f',本次已自动退出 {quit_groups} 个群' 81 | # try: 82 | # await app.send_friend_message(basic_cfg.admin.masterId, MessageChain(Plain(msg))) 83 | # except UnknownTarget: 84 | # logger.warning('无法向 Bot 主人发送消息,请添加 Bot 为好友') 85 | 86 | 87 | # @listen(ApplicationShutdowned) 88 | # @decorate(require_disable(channel.module)) 89 | # async def shutdowned(app: Ariadne): 90 | # try: 91 | # await app.send_friend_message(basic_cfg.admin.masterId, MessageChain(Plain(f'{basic_cfg.botName} 正在关闭'))) 92 | # except UnknownTarget: 93 | # logger.warning('无法向 Bot 主人发送消息,请添加 Bot 为好友') 94 | 95 | 96 | @listen(NewFriendRequestEvent) 97 | async def new_friend(app: Ariadne, event: NewFriendRequestEvent): 98 | """ 99 | 收到好友申请 100 | """ 101 | if event.supplicant in basic_cfg.admin.admins: 102 | await event.accept() 103 | await app.send_friend_message(event.supplicant, MessageChain(Plain('已通过你的好友申请'))) 104 | return 105 | 106 | source_group: int | None = event.source_group 107 | group_name = '未知' 108 | if source_group: 109 | group = await app.get_group(source_group) 110 | group_name = group.name if group else '未知' 111 | 112 | await send_to_admin( 113 | MessageChain( 114 | Plain(f'收到添加好友事件\nQQ:{event.supplicant}\n昵称:{event.nickname}\n'), 115 | Plain(f'来自群:{group_name}({source_group})\n') if source_group else Plain('\n来自好友搜索\n'), 116 | Plain(event.message) if event.message else Plain('无附加信息'), 117 | Plain('\n\n是否同意申请?请在10分钟内发送“同意”或“拒绝”,否则自动同意'), 118 | ) 119 | ) 120 | 121 | async def waiter(waiter_friend: Friend, waiter_message: MessageChain) -> tuple[bool, int] | None: 122 | if waiter_friend.id in basic_cfg.admin.admins: 123 | saying = str(waiter_message) 124 | if saying == '同意': 125 | return True, waiter_friend.id 126 | elif saying == '拒绝': 127 | return False, waiter_friend.id 128 | else: 129 | await app.send_friend_message(waiter_friend, MessageChain(Plain('请发送同意或拒绝'))) 130 | 131 | result = await FunctionWaiter(waiter, [FriendMessage]).wait(timeout=600) 132 | if result is None: 133 | await event.accept() 134 | await send_to_admin(MessageChain(Plain(f'由于超时未审核,已自动同意 {event.nickname}({event.supplicant}) 的好友请求'))) 135 | return 136 | 137 | if result[0]: 138 | await event.accept() 139 | await send_to_admin(MessageChain(Plain(f'Bot 管理员 {result[1]} 已同意 {event.nickname}({event.supplicant}) 的好友请求'))) 140 | else: 141 | await event.reject('Bot 管理员拒绝了你的好友请求') 142 | await send_to_admin(MessageChain(Plain(f'Bot 管理员 {result[1]} 已拒绝 {event.nickname}({event.supplicant}) 的好友请求'))) 143 | 144 | 145 | @listen(BotInvitedJoinGroupRequestEvent) 146 | @decorate(require_disable(channel.module)) 147 | async def invited_join_group(app: Ariadne, event: BotInvitedJoinGroupRequestEvent): 148 | """ 149 | 被邀请入群 150 | """ 151 | if event.source_group in perm_cfg.group_whitelist: 152 | await event.accept() 153 | await send_to_admin( 154 | MessageChain( 155 | Plain( 156 | '收到邀请入群事件\n' 157 | f'邀请者:{event.nickname}({event.supplicant})\n' 158 | f'群号:{event.source_group}\n' 159 | f'群名:{event.group_name}\n' 160 | '\n该群位于白名单中,已同意加入' 161 | ), 162 | ), 163 | ) 164 | return 165 | 166 | await send_to_admin( 167 | MessageChain( 168 | Plain( 169 | '收到邀请入群事件\n' 170 | f'邀请者:{event.nickname}({event.supplicant})\n' 171 | f'群号:{event.source_group}\n' 172 | f'群名:{event.group_name}\n' 173 | '\n是否同意申请?请在10分钟内发送“同意”或“拒绝”,否则自动拒绝' 174 | ), 175 | ), 176 | ) 177 | 178 | async def waiter(waiter_friend: Friend, waiter_message: MessageChain) -> tuple[bool, int] | None: 179 | if waiter_friend.id in basic_cfg.admin.admins: 180 | saying = str(waiter_message) 181 | if saying == '同意': 182 | return True, waiter_friend.id 183 | elif saying == '拒绝': 184 | return False, waiter_friend.id 185 | else: 186 | await app.send_friend_message(waiter_friend, MessageChain(Plain('请发送同意或拒绝'))) 187 | 188 | result = await FunctionWaiter(waiter, [FriendMessage]).wait(timeout=600) 189 | if result is None: 190 | await event.reject('由于 Bot 管理员长时间未审核,已自动拒绝') 191 | await send_to_admin( 192 | MessageChain( 193 | Plain( 194 | f'由于长时间未审核,已自动拒绝 {event.nickname}({event.supplicant}) ' 195 | f'邀请进入群 {event.group_name}({event.source_group}) 请求' 196 | ) 197 | ), 198 | ) 199 | return 200 | if result[0]: 201 | await event.accept() 202 | if event.source_group: 203 | perm_cfg.group_whitelist.append(event.source_group) 204 | kayaku.save(perm_cfg) 205 | await send_to_admin( 206 | MessageChain( 207 | Plain( 208 | f'Bot 管理员 {result[1]} 已同意 {event.nickname}({event.supplicant}) ' 209 | f'邀请进入群 {event.group_name}({event.source_group}) 请求' 210 | ) 211 | ), 212 | ) 213 | else: 214 | await event.reject('Bot 管理员拒绝加入该群') 215 | await send_to_admin( 216 | MessageChain( 217 | Plain( 218 | f'Bot 管理员 {result[1]} 已拒绝 {event.nickname}({event.supplicant}) ' 219 | f'邀请进入群 {event.group_name}({event.source_group}) 请求' 220 | ) 221 | ), 222 | ) 223 | 224 | 225 | @listen(BotJoinGroupEvent) 226 | @decorate(require_disable(channel.module)) 227 | async def join_group(app: Ariadne, event: BotJoinGroupEvent): 228 | """ 229 | 收到入群事件 230 | """ 231 | member_num = len(await app.get_member_list(event.group)) 232 | await send_to_admin( 233 | MessageChain( 234 | Plain(f'收到 Bot 入群事件\n群号:{event.group.id}\n群名:{event.group.name}\n群人数:{member_num}'), 235 | ), 236 | ) 237 | 238 | if event.group.id not in perm_cfg.group_whitelist: 239 | await app.send_group_message( 240 | event.group, 241 | MessageChain(f'该群未在白名单中,将不会启用本 Bot 的绝大部分功能,如有需要请联系 {basic_cfg.admin.masterId} 申请白名单'), 242 | ) 243 | # return await app.quit_group(event.group.id) 244 | else: 245 | await app.send_group_message( 246 | event.group, 247 | MessageChain( 248 | f'我是 {basic_cfg.admin.masterName} 的机器人 {basic_cfg.botName}\n' 249 | f'如果有需要可以联系主人QQ『{basic_cfg.admin.masterId}』\n' 250 | '拉进其他群前请先添加主人为好友并说明用途,主人添加白名单后即可邀请\n' 251 | '直接拉进其他群也需要经过主人同意才会入群噢\n' 252 | '发送 .menu 可以查看功能列表,群管理员可以开启或禁用功能\n' 253 | '注:@我 不会触发任何功能(不过也存在部分特例)' 254 | ), 255 | ) 256 | 257 | 258 | @listen(BotLeaveEventKick) 259 | @decorate(require_disable(channel.module)) 260 | async def kick_group(event: BotLeaveEventKick): 261 | """ 262 | 被踢出群 263 | """ 264 | perm_cfg.group_whitelist.remove(event.group.id) 265 | kayaku.save(perm_cfg) 266 | 267 | await send_to_admin( 268 | MessageChain(f'收到被踢出群聊事件\n群号:{event.group.id}\n群名:{event.group.name}\n已移出白名单'), 269 | ) 270 | 271 | 272 | @listen(BotLeaveEventActive) 273 | @decorate(require_disable(channel.module)) 274 | async def leave_group(event: BotLeaveEventActive): 275 | """ 276 | 主动退群 277 | """ 278 | perm_cfg.group_whitelist.remove(event.group.id) 279 | kayaku.save(perm_cfg) 280 | 281 | await send_to_admin( 282 | MessageChain(f'收到主动退出群聊事件\n群号:{event.group.id}\n群名:{event.group.name}\n已移出白名单'), 283 | ) 284 | 285 | 286 | @listen(BotGroupPermissionChangeEvent) 287 | @decorate(require_disable(channel.module)) 288 | async def permission_change(event: BotGroupPermissionChangeEvent): 289 | """ 290 | 群内权限变动 291 | """ 292 | await send_to_admin( 293 | MessageChain(f'收到权限变动事件\n群号:{event.group.id}\n群名:{event.group.name}\n权限变更为:{event.current}'), 294 | ) 295 | 296 | 297 | @listen(FriendMessage) 298 | @dispatch(Twilight(RegexMatch(r'[.!!]添加群白名单').space(SpacePolicy.FORCE), 'group' @ RegexMatch(r'\d+'))) 299 | @decorate(require_disable(channel.module)) 300 | async def add_group_whitelist(app: Ariadne, friend: Friend, group: RegexResult): 301 | """ 302 | 添加群白名单 303 | """ 304 | if friend.id not in basic_cfg.admin.admins or group.result is None: 305 | return 306 | perm_cfg.group_whitelist.append(int(str(group.result))) 307 | kayaku.save(perm_cfg) 308 | 309 | await app.send_friend_message( 310 | friend, 311 | MessageChain( 312 | Plain(f'已添加群 {group.result} 至白名单'), 313 | ), 314 | ) 315 | 316 | 317 | @listen(FriendMessage) 318 | @dispatch(Twilight(RegexMatch(r'[.!!]添加用户黑名单').space(SpacePolicy.FORCE), 'qq' @ RegexMatch(r'\d+'))) 319 | @decorate(require_disable(channel.module)) 320 | async def add_qq_blacklist(app: Ariadne, friend: Friend, qq: RegexResult): 321 | """ 322 | 添加用户黑名单 323 | """ 324 | if friend.id not in basic_cfg.admin.admins or qq.result is None: 325 | return 326 | perm_cfg.user_blacklist.append(int(str(qq.result))) 327 | kayaku.save(perm_cfg) 328 | 329 | await app.send_friend_message( 330 | friend, 331 | MessageChain( 332 | Plain(f'已添加用户 {qq.result} 至黑名单'), 333 | ), 334 | ) 335 | -------------------------------------------------------------------------------- /core_modules/bot_status.py: -------------------------------------------------------------------------------- 1 | import os 2 | import platform 3 | import time 4 | 5 | import psutil 6 | from graia.ariadne.app import Ariadne 7 | from graia.ariadne.event.message import GroupMessage 8 | from graia.ariadne.message.chain import MessageChain 9 | from graia.ariadne.message.element import Image 10 | from graia.ariadne.message.parser.twilight import RegexMatch, Twilight 11 | from graia.ariadne.model import Group 12 | from graia.saya import Channel 13 | from graiax.shortcut.saya import decorate, dispatch, listen 14 | 15 | from libs import get_graia_version 16 | from libs.control import require_disable 17 | from libs.control.permission import GroupPermission 18 | from libs.text2img import md2img 19 | 20 | channel = Channel.current() 21 | 22 | channel.meta['author'] = ['Red_lnn'] 23 | channel.meta['name'] = 'Bot版本与系统运行情况查询' 24 | channel.meta['description'] = '[!!.](status|version)' 25 | channel.meta['can_disable'] = False 26 | 27 | extra, official, community = get_graia_version() 28 | 29 | python_version = platform.python_version() 30 | if platform.uname().system == 'Windows': 31 | system_version = platform.platform() 32 | else: 33 | system_version = f'{platform.platform()} {platform.version()}' 34 | total_memory = '%.1f' % (psutil.virtual_memory().total / 1073741824) 35 | pid = os.getpid() 36 | 37 | 38 | @listen(GroupMessage) 39 | @dispatch(Twilight(RegexMatch(r'[!!.](status|version)'))) 40 | @decorate(GroupPermission.require(), require_disable(channel.module)) 41 | async def main(app: Ariadne, group: Group): 42 | p = psutil.Process(pid) 43 | started_time = time.localtime(p.create_time()) 44 | running_time = time.time() - p.create_time() 45 | day = int(running_time / 86400) 46 | hour = int(running_time % 86400 / 3600) 47 | minute = int(running_time % 86400 % 3600 / 60) 48 | second = int(running_time % 86400 % 3600 % 60) 49 | running_time = f'{f"{day}d " if day else ""}{f"{hour}h " if hour else ""}{f"{minute}m " if minute else ""}{second}s' 50 | 51 | md = f'''\ 52 |
53 | 54 | # RedBot 状态 55 | 56 |
57 | 58 | ## 基本信息 59 | **PID**: {pid} 60 | **启动时间**:{time.strftime("%Y-%m-%d %p %I:%M:%S", started_time)} 61 | **已运行时长**:{running_time} 62 | 63 | ## 运行环境 64 | **Python 版本**:{python_version} 65 | **系统版本**:{system_version} 66 | **CPU 核心数**:{psutil.cpu_count()} 67 | **CPU 占用率**:{psutil.cpu_percent()}% 68 | **系统内存占用**:{"%.1f" % (psutil.virtual_memory().available / 1073741824)}G / {total_memory}G 69 | 70 | ## 依赖版本 71 | **Mirai Api Http**:{await app.get_version()} 72 | **Graia 相关**: 73 | ''' 74 | if extra: 75 | md += ''.join(f' - {name}:{version}\n' for name, version in extra) 76 | md += ''.join(f' - {name}:{version}\n' for name, version in official) 77 | if community: 78 | md += ''.join(f' - {name}:{version}\n' for name, version in community) 79 | 80 | await app.send_message(group, MessageChain(Image(data_bytes=await md2img(md.rstrip())))) 81 | -------------------------------------------------------------------------------- /core_modules/error_handler.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | from io import StringIO 3 | 4 | from graia.ariadne.app import Ariadne 5 | from graia.ariadne.message.chain import MessageChain 6 | from graia.ariadne.message.element import Image, Plain 7 | from graia.broadcast.builtin.event import ExceptionThrowed 8 | from graia.saya import Channel 9 | from graiax.shortcut.saya import listen 10 | 11 | from libs.config import basic_cfg 12 | from libs.text2img import md2img 13 | 14 | channel = Channel.current() 15 | channel.meta['can_disable'] = False 16 | 17 | 18 | @listen(ExceptionThrowed) 19 | async def except_handle(event: ExceptionThrowed): 20 | if isinstance(event.event, ExceptionThrowed): 21 | return 22 | app = Ariadne.current() 23 | with StringIO() as fp: 24 | traceback.print_tb(event.exception.__traceback__, file=fp) 25 | tb = fp.getvalue() 26 | msg = f'''\ 27 | ## 异常事件: 28 | 29 | {str(event.event.__repr__())} 30 | 31 | ## 异常类型: 32 | 33 | `{type(event.exception)}` 34 | 35 | ## 异常内容: 36 | 37 | {str(event.exception)} 38 | 39 | ## 异常追踪: 40 | 41 | ```py 42 | {tb} 43 | ``` 44 | ''' 45 | img_bytes = await md2img(msg, 1500) 46 | await app.send_friend_message(basic_cfg.admin.masterId, MessageChain(Plain('发生异常\n'), Image(data_bytes=img_bytes))) 47 | 48 | 49 | # from graia.ariadne.event.message import GroupMessage 50 | 51 | 52 | # @listen(GroupMessage) 53 | # async def error_handler_test(msg: MessageChain): 54 | # if str(msg) == '.错误捕捉测试': 55 | # raise ValueError('错误捕捉测试') 56 | -------------------------------------------------------------------------------- /core_modules/module_manage.py: -------------------------------------------------------------------------------- 1 | """ 2 | Bot 管理 3 | 包含 分群配置 插件配置更改 菜单 4 | """ 5 | 6 | import os 7 | from pathlib import Path 8 | 9 | import kayaku 10 | from graia.ariadne.app import Ariadne 11 | from graia.ariadne.event.message import GroupMessage 12 | from graia.ariadne.message.chain import MessageChain 13 | from graia.ariadne.message.element import At, Image, Plain 14 | from graia.ariadne.message.parser.twilight import RegexMatch, RegexResult, Twilight 15 | from graia.ariadne.model import Group, Member, MemberPerm 16 | from graia.saya import Channel, Saya 17 | from graiax.shortcut.interrupt import FunctionWaiter 18 | from graiax.shortcut.saya import decorate, dispatch, listen 19 | from loguru import logger 20 | 21 | from libs.config import basic_cfg, modules_cfg 22 | from libs.control import require_disable 23 | from libs.control.permission import GroupPermission 24 | from libs.text2img import md2img 25 | 26 | saya = Saya.current() 27 | channel = Channel.current() 28 | 29 | channel.meta['name'] = 'Bot模块管理' 30 | channel.meta['author'] = ['Red_lnn'] 31 | channel.meta['description'] = ( 32 | '管理Bot已加载的模块\n' 33 | '[!!.](菜单|menu) —— 获取模块列表\n' 34 | '[!!.]启用 <模块ID> —— 【群管理员】启用某个已禁用的模块(全局禁用除外)\n' 35 | '[!!.]禁用 <模块ID> —— 【群管理员】禁用某个已启用的模块(禁止禁用的模块除外)\n' 36 | '[!!.]全局启用 <模块ID> —— 【Bot管理员】全局启用某个已禁用的模块\n' 37 | '[!!.]全局禁用 <模块ID> —— 【Bot管理员】全局禁用某个已启用的模块(禁止禁用的模块除外)\n' 38 | '[!!.]重载 <模块ID> —— 【Bot管理员,强烈不推荐】重载指定模块\n' 39 | '[!!.]加载 <模块文件名> —— 【Bot管理员,强烈不推荐】加载新模块(xxx.xxx 无需后缀)\n' 40 | '[!!.]卸载 <模块ID> —— 【Bot管理员】卸载指定模块' 41 | ) 42 | channel.meta['can_disable'] = False 43 | 44 | # ensp = ' ' # 半角空格 45 | # emsp = ' ' # 全角空格 46 | 47 | 48 | async def get_channel(app: Ariadne, module_id: str, group: Group): 49 | if module_id.isdigit(): 50 | target_id = int(module_id) - 1 51 | if target_id >= len(saya.channels): 52 | await app.send_message(group, MessageChain(Plain('你指定的模块不存在'))) 53 | return 54 | return saya.channels[str(list(saya.channels.keys())[target_id])] 55 | else: 56 | if module_id not in saya.channels: 57 | await app.send_message(group, MessageChain(Plain('你指定的模块不存在'))) 58 | return 59 | return saya.channels[module_id] 60 | 61 | 62 | @listen(GroupMessage) 63 | @dispatch(Twilight(RegexMatch(r'[!!.](菜单|menu)'))) 64 | @decorate(GroupPermission.require(), require_disable(channel.module)) 65 | async def menu(app: Ariadne, group: Group): 66 | md = f'''\ 67 |
68 | 69 | # {basic_cfg.botName} 功能菜单 70 | 71 | for {group.name}({group.id}) 72 | 73 | | ID | 模块状态 | 模块名 | 74 | | --- | --- | --- | 75 | ''' 76 | 77 | for index, module in enumerate(saya.channels, start=1): 78 | global_disabled = module in modules_cfg.globalDisabledModules 79 | disabled_groups = modules_cfg.disabledGroups[module] if module in modules_cfg.disabledGroups else [] 80 | num = f' {index}' if index < 10 else str(index) 81 | status = '❌' if global_disabled or group.id in disabled_groups else '✔️' 82 | md += f'| {num} | {status} | {saya.channels[module]._name if saya.channels[module]._name is not None else saya.channels[module].module} |\n' 83 | md += f''' 84 | --- 85 | 86 | 私は {basic_cfg.admin.masterName} の {basic_cfg.botName} です www 87 | 群管理员要想配置模块开关请发送【.启用/禁用 】 88 | 要想查询某模块的用法和介绍请发送【.用法 】 89 | 若无法触发,请检查前缀符号是否正确如!与! 90 | 或是命令中有无多余空格,除特别说明外均不需要 @bot 91 | 92 |
''' 93 | await app.send_message(group, MessageChain(Image(data_bytes=await md2img(md)))) 94 | 95 | 96 | @listen(GroupMessage) 97 | @dispatch(Twilight(RegexMatch(r'[!!.]启用'), 'module_id' @ RegexMatch(r'\d+'))) 98 | @decorate(GroupPermission.require(MemberPerm.Administrator), require_disable(channel.module)) 99 | async def enable_module(app: Ariadne, group: Group, module_id: RegexResult): 100 | if module_id.result is None: 101 | return 102 | target_id = str(module_id.result) 103 | target_module = await get_channel(app, target_id, group) 104 | if target_module is None: 105 | await app.send_message(group, MessageChain(Plain('你指定的模块不存在'))) 106 | return 107 | disabled_groups = ( 108 | modules_cfg.disabledGroups[target_module.module] if target_module.module in modules_cfg.disabledGroups else [] 109 | ) 110 | if target_module.module in modules_cfg.globalDisabledModules: 111 | await app.send_message(group, MessageChain(Plain('模块已全局禁用无法开启'))) 112 | elif group.id in disabled_groups: 113 | disabled_groups.remove(group.id) 114 | modules_cfg.disabledGroups[target_module.module] = disabled_groups 115 | kayaku.save(modules_cfg) 116 | await app.send_message(group, MessageChain(Plain('模块已启用'))) 117 | else: 118 | await app.send_message(group, MessageChain(Plain('无变化,模块已处于开启状态'))) 119 | 120 | 121 | @listen(GroupMessage) 122 | @dispatch(Twilight(RegexMatch(r'[!!.]禁用'), 'module_id' @ RegexMatch(r'\d+'))) 123 | @decorate(GroupPermission.require(MemberPerm.Administrator), require_disable(channel.module)) 124 | async def disable_module(app: Ariadne, group: Group, module_id: RegexResult): 125 | if module_id.result is None: 126 | return 127 | target_module = await get_channel(app, str(module_id.result), group) 128 | if target_module is None: 129 | await app.send_message(group, MessageChain(Plain('你指定的模块不存在'))) 130 | return 131 | disabled_groups = ( 132 | modules_cfg.disabledGroups[target_module.module] if target_module.module in modules_cfg.disabledGroups else [] 133 | ) 134 | if not target_module.meta.get('can_disable', True): 135 | await app.send_message(group, MessageChain(Plain('你指定的模块不允许禁用'))) 136 | elif group.id not in disabled_groups: 137 | disabled_groups.append(group.id) 138 | modules_cfg.disabledGroups[target_module.module] = disabled_groups 139 | kayaku.save(modules_cfg) 140 | await app.send_message(group, MessageChain(Plain('模块已禁用'))) 141 | else: 142 | await app.send_message(group, MessageChain(Plain('无变化,模块已处于禁用状态'))) 143 | 144 | 145 | @listen(GroupMessage) 146 | @dispatch(Twilight(RegexMatch(r'[!!.]全局启用'), 'module_id' @ RegexMatch(r'\d+'))) 147 | @decorate(GroupPermission.require(MemberPerm.Administrator), require_disable(channel.module)) 148 | async def global_enable_module(app: Ariadne, group: Group, module_id: RegexResult): 149 | if module_id.result is None: 150 | return 151 | target_module = await get_channel(app, str(module_id.result), group) 152 | if target_module is None: 153 | await app.send_message(group, MessageChain(Plain('你指定的模块不存在'))) 154 | return 155 | if target_module.module not in modules_cfg.globalDisabledModules: 156 | await app.send_message(group, MessageChain(Plain('无变化,模块已处于全局开启状态'))) 157 | else: 158 | modules_cfg.globalDisabledModules.remove(target_module.module) 159 | kayaku.save(modules_cfg) 160 | await app.send_message(group, MessageChain(Plain('模块已全局启用'))) 161 | 162 | 163 | @listen(GroupMessage) 164 | @dispatch(Twilight(RegexMatch(r'[!!.]全局禁用'), 'module_id' @ RegexMatch(r'\d+'))) 165 | @decorate(GroupPermission.require(MemberPerm.Administrator), require_disable(channel.module)) 166 | async def global_disable_module(app: Ariadne, group: Group, module_id: RegexResult): 167 | if module_id.result is None: 168 | return 169 | target_module = await get_channel(app, str(module_id.result), group) 170 | if target_module is None: 171 | await app.send_message(group, MessageChain(Plain('你指定的模块不存在'))) 172 | return 173 | if not target_module.meta.get('can_disable', True): 174 | await app.send_message(group, MessageChain(Plain('你指定的模块不允许禁用'))) 175 | elif target_module.module not in modules_cfg.globalDisabledModules: 176 | modules_cfg.globalDisabledModules.append(target_module.module) 177 | kayaku.save(modules_cfg) 178 | await app.send_message(group, MessageChain(Plain('模块已全局禁用'))) 179 | else: 180 | await app.send_message(group, MessageChain(Plain('无变化,模块已处于全局禁用状态'))) 181 | 182 | 183 | @listen(GroupMessage) 184 | @dispatch(Twilight(RegexMatch(r'[!!.]用法'), 'module_id' @ RegexMatch(r'\d+'))) 185 | @decorate(GroupPermission.require(), require_disable(channel.module)) 186 | async def get_usage(app: Ariadne, group: Group, module_id: RegexResult): 187 | if module_id.result is None: 188 | return 189 | target_module = await get_channel(app, str(module_id.result), group) 190 | if target_module is None: 191 | return 192 | disabled_groups = ( 193 | modules_cfg.disabledGroups[target_module.module] if target_module.module in modules_cfg.disabledGroups else [] 194 | ) 195 | if group.id in disabled_groups: 196 | await app.send_message(group, MessageChain(Plain('该模块已在本群禁用'))) 197 | return 198 | authors = '' 199 | if target_module._author: 200 | for author in target_module._author: 201 | authors += f'{author}, ' 202 | authors = authors.rstrip(', ') 203 | md = f'## {target_module._name}{f" By {authors}" if authors else ""}\n\n' 204 | if target_module._description: 205 | md += '### 描述\n' + target_module._description 206 | img_bytes = await md2img(md) 207 | await app.send_message(group, MessageChain(Image(data_bytes=img_bytes))) 208 | 209 | 210 | @listen(GroupMessage) 211 | @dispatch(Twilight(RegexMatch(r'[!!.]重载模块'), 'module_id' @ RegexMatch(r'\d+'))) 212 | @decorate(GroupPermission.require(GroupPermission.BOT_ADMIN), require_disable(channel.module)) 213 | async def reload_module(app: Ariadne, group: Group, member: Member, module_id: RegexResult): 214 | await app.send_message( 215 | group, 216 | MessageChain(At(member.id), Plain(' 重载模块有极大可能会出错,请问你确实要重载吗?\n强制重载请在10s内发送 .force ,取消请发送 .cancel')), 217 | ) 218 | 219 | async def waiter(waiter_group: Group, waiter_member: Member, waiter_message: MessageChain) -> bool | None: 220 | if waiter_group.id == group.id and waiter_member.id == member.id: 221 | saying = str(waiter_message) 222 | if saying == '.force': 223 | return True 224 | elif saying == '.cancel': 225 | return False 226 | else: 227 | await app.send_message(group, MessageChain(At(member.id), Plain('请发送 .force 或 .cancel'))) 228 | 229 | answer = await FunctionWaiter(waiter, [GroupMessage]).wait(timeout=10) 230 | if answer is None: 231 | await app.send_message(group, MessageChain(Plain('已超时取消'))) 232 | return 233 | if not answer: 234 | await app.send_message(group, MessageChain(Plain('已取消操作'))) 235 | return 236 | 237 | if module_id.result is None: 238 | return 239 | target_module = await get_channel(app, str(module_id.result), group) 240 | if target_module is None: 241 | return 242 | logger.info(f'重载模块: {target_module._name} —— {target_module.module}') 243 | try: 244 | with saya.module_context(): 245 | saya.reload_channel(saya.channels[target_module.module]) 246 | except Exception as e: 247 | await app.send_message(group, MessageChain(Plain(f'重载模块 {target_module.module} 时出错'))) 248 | logger.error(f'重载模块 {target_module.module} 时出错') 249 | logger.exception(e) 250 | else: 251 | await app.send_message(group, MessageChain(Plain(f'重载模块 {target_module.module} 成功'))) 252 | 253 | 254 | @listen(GroupMessage) 255 | @dispatch(Twilight(RegexMatch('[!!.]加载'), 'module_id' @ RegexMatch(r'\d+'))) 256 | @decorate(GroupPermission.require(GroupPermission.BOT_ADMIN), require_disable(channel.module)) 257 | async def load_module(app: Ariadne, group: Group, member: Member, module_id: RegexResult): 258 | if module_id.result is None: 259 | return 260 | await app.send_message( 261 | group, 262 | MessageChain(At(member.id), Plain(' 加载新模块有极大可能会出错,请问你确实吗?\n强制加载请在10s内发送 .force ,取消请发送 .cancel')), 263 | ) 264 | 265 | async def waiter(waiter_group: Group, waiter_member: Member, waiter_message: MessageChain) -> bool | None: 266 | if waiter_group.id == group.id and waiter_member.id == member.id: 267 | saying = str(waiter_message) 268 | if saying == '.force': 269 | return True 270 | elif saying == '.cancel': 271 | return False 272 | else: 273 | await app.send_message(group, MessageChain(At(member.id), Plain('请发送 .force 或 .cancel'))) 274 | 275 | answer = await FunctionWaiter(waiter, [GroupMessage]).wait(timeout=600) 276 | if answer is None: 277 | await app.send_message(group, MessageChain(Plain('已超时取消'))) 278 | return 279 | if not answer: 280 | await app.send_message(group, MessageChain(Plain('已取消操作'))) 281 | return 282 | match_result = str(module_id.result) 283 | target_filename = match_result if match_result[-3:] != '.py' else match_result[:-3] 284 | 285 | modules_dir_list = os.listdir(Path(Path.cwd(), 'modules')) 286 | if f'{target_filename}.py' in modules_dir_list or target_filename in modules_dir_list: 287 | try: 288 | with saya.module_context(): 289 | saya.require(f'modules.{target_filename}') 290 | except Exception as e: 291 | await app.send_message(group, MessageChain(Plain(f'加载模块 modules.{target_filename} 时出错'))) 292 | logger.error(f'加载模块 modules.{target_filename} 时出错') 293 | logger.exception(e) 294 | return 295 | else: 296 | await app.send_message(group, MessageChain(Plain(f'加载模块 modules.{target_filename} 成功'))) 297 | return 298 | else: 299 | await app.send_message(group, MessageChain(Plain(f'模块 modules.{target_filename} 不存在'))) 300 | 301 | 302 | @listen(GroupMessage) 303 | @dispatch(Twilight(RegexMatch(r'[!!.]卸载'), 'module_id' @ RegexMatch(r'\d+'))) 304 | @decorate(GroupPermission.require(GroupPermission.BOT_ADMIN), require_disable(channel.module)) 305 | async def unload_module(app: Ariadne, group: Group, module_id: RegexResult): 306 | if module_id.result is None: 307 | return 308 | target_module = await get_channel(app, str(module_id.result), group) 309 | if target_module is None: 310 | return 311 | if not target_module.meta.get('can_disable', True): 312 | await app.send_message(group, MessageChain(Plain('你指定的模块不允许禁用或卸载'))) 313 | return 314 | logger.debug(f'原channels: {saya.channels}') 315 | logger.debug(f'要卸载的channel: {saya.channels["modules.BiliVideoInfo"]}') 316 | logger.info(f'卸载模块: {target_module._name} —— {target_module.module}') 317 | try: 318 | saya.uninstall_channel(saya.channels[target_module.module]) 319 | except Exception as e: 320 | await app.send_message(group, MessageChain(Plain(f'卸载模块 {target_module.module} 时出错'))) 321 | logger.error(f'卸载模块 {target_module.module} 时出错') 322 | logger.exception(e) 323 | else: 324 | logger.debug(f'卸载后的channels: {saya.channels}') 325 | await app.send_message(group, MessageChain(Plain(f'卸载模块 {target_module.module} 成功'))) 326 | -------------------------------------------------------------------------------- /core_modules/recall.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import time 3 | from datetime import datetime 4 | 5 | from graia.amnesia.builtins.memcache import Memcache 6 | from graia.ariadne.app import Ariadne 7 | from graia.ariadne.event.message import ActiveGroupMessage, GroupMessage 8 | from graia.ariadne.exception import ( 9 | InvalidArgument, 10 | RemoteException, 11 | UnknownError, 12 | UnknownTarget, 13 | ) 14 | from graia.ariadne.message.chain import MessageChain 15 | from graia.ariadne.message.element import Plain, Source 16 | from graia.ariadne.model import Group 17 | from graia.saya import Channel 18 | from graia.scheduler.saya import SchedulerSchema 19 | from graia.scheduler.timers import crontabify 20 | from graiax.shortcut.saya import decorate, listen 21 | 22 | from libs.config import basic_cfg 23 | from libs.control import require_disable 24 | 25 | channel = Channel.current() 26 | 27 | channel.meta['name'] = '撤回自己的消息' 28 | channel.meta['author'] = ['Red_lnn'] 29 | channel.meta['description'] = '撤回bot发的消息\n用法:\n 回复bot的某条消息【撤回】,或发送【撤回最近】以撤回最近发送的消息' 30 | channel.meta['can_disable'] = False 31 | 32 | 33 | @listen(GroupMessage) 34 | @decorate(require_disable(channel.module)) 35 | async def recall_message(app: Ariadne, group: Group, message: MessageChain, event: GroupMessage, memcache: Memcache): 36 | if event.sender.id not in basic_cfg.admin.admins: 37 | return 38 | if str(message) == '.撤回最近': 39 | recent_msg: list[dict] = await memcache.get('recent_msg', []) 40 | for item in recent_msg: 41 | with contextlib.suppress(UnknownTarget, InvalidArgument, RemoteException, UnknownError): 42 | await app.recall_message(item['id']) 43 | with contextlib.suppress(ValueError): 44 | recent_msg.remove(item) 45 | await memcache.set('recent_msg', recent_msg) 46 | elif event.quote is not None: 47 | if event.quote.sender_id != basic_cfg.miraiApiHttp.account: 48 | return 49 | if str(message.include(Plain).merge()).strip() == '.撤回': 50 | target_id = event.quote.id 51 | recent_msg: list[dict] = await memcache.get('recent_msg', []) 52 | for item in recent_msg: 53 | if item['id'] == target_id: 54 | if item['time'] - time.time() > 115: 55 | await app.send_message(group, MessageChain(Plain('该消息已超过撤回时间。')), quote=event.source) 56 | 57 | return 58 | with contextlib.suppress(UnknownTarget, InvalidArgument, RemoteException, UnknownError): 59 | await app.recall_message(item['id']) 60 | with contextlib.suppress(ValueError): 61 | recent_msg.remove(item) 62 | await memcache.set('recent_msg', recent_msg) 63 | break 64 | 65 | 66 | @listen(ActiveGroupMessage) 67 | @decorate(require_disable(channel.module)) 68 | async def listener(source: Source, memcache: Memcache): 69 | recent_msg: list[dict] = await memcache.get('recent_msg', []) 70 | recent_msg.append( 71 | { 72 | 'time': datetime.timestamp(source.time), 73 | 'id': source.id, 74 | } 75 | ) 76 | await memcache.set('recent_msg', recent_msg) 77 | 78 | 79 | @channel.use(SchedulerSchema(crontabify('0/2 * * * *'))) 80 | async def clear_outdated(app: Ariadne): 81 | time_now = time.time() 82 | memcache = app.launch_manager.get_interface(Memcache) 83 | recent_msg: list[dict] = await memcache.get('recent_msg', []) 84 | for item in recent_msg: 85 | if (time_now - item['time']) > 120: 86 | recent_msg.remove(item) 87 | await memcache.set('recent_msg', recent_msg) 88 | -------------------------------------------------------------------------------- /libs/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from importlib import metadata 4 | from pathlib import Path 5 | from typing import TYPE_CHECKING 6 | 7 | from loguru import logger 8 | from richuru import LoguruHandler, install 9 | 10 | from libs.config import basic_cfg 11 | from libs.database import DatabaseManager 12 | from static.path import logs_path 13 | 14 | db = DatabaseManager(basic_cfg.databaseUrl) 15 | 16 | if TYPE_CHECKING: 17 | from graia.ariadne.event import MiraiEvent 18 | 19 | 20 | def get_graia_version(): 21 | extra: list[tuple[str, str]] = [] 22 | official: list[tuple[str, str]] = [] 23 | community: list[tuple[str, str]] = [] 24 | 25 | for dist in metadata.distributions(): 26 | name: str = dist.metadata['Name'] 27 | version: str = dist.version 28 | if name in {'launart', 'creart', 'creart-graia', 'statv', 'richuru'}: 29 | extra.append((name, version)) 30 | elif name.startswith('graia-'): 31 | official.append((name, version)) 32 | elif name.startswith('graiax-'): 33 | community.append((name, version)) 34 | 35 | return extra, official, community 36 | 37 | 38 | def replace_logger(level: str | int = 'INFO', richuru: bool = False): 39 | if richuru: 40 | install(level=level) 41 | else: 42 | logging.basicConfig(handlers=[LoguruHandler()], level=0) 43 | logger.remove() 44 | logger.add(sys.stderr, level=level, enqueue=True) 45 | logger.add( 46 | Path(logs_path, 'latest.log'), 47 | level=level, 48 | rotation='00:00', 49 | retention='30 days', 50 | compression='zip', 51 | encoding='utf-8', 52 | enqueue=True, 53 | ) 54 | 55 | 56 | def log_level_handler(event: 'MiraiEvent'): 57 | if basic_cfg.debug: 58 | return 'DEBUG' 59 | from graia.ariadne.event.message import ( 60 | ActiveMessage, 61 | FriendMessage, 62 | GroupMessage, 63 | OtherClientMessage, 64 | StrangerMessage, 65 | TempMessage, 66 | ) 67 | 68 | if type(event) in { 69 | ActiveMessage, 70 | FriendMessage, 71 | GroupMessage, 72 | OtherClientMessage, 73 | StrangerMessage, 74 | TempMessage, 75 | }: 76 | return 'INFO' if basic_cfg.logChat else 'DEBUG' 77 | return 'INFO' 78 | -------------------------------------------------------------------------------- /libs/better_pydantic.py: -------------------------------------------------------------------------------- 1 | import orjson 2 | from pydantic import BaseModel as PyDanticBaseModel 3 | 4 | 5 | def orjson_dumps(v, *, default, **dumps_kwargs): 6 | # orjson.dumps returns bytes, to match standard json.dumps we need to decode 7 | return orjson.dumps(v, default=default, **dumps_kwargs).decode() 8 | 9 | 10 | class BaseModel(PyDanticBaseModel): 11 | class Config: 12 | json_loads = orjson.loads 13 | json_dumps = orjson_dumps 14 | -------------------------------------------------------------------------------- /libs/config.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | from kayaku import config, create 4 | 5 | 6 | @dataclass 7 | class MAHConfig: 8 | account: int = 123456789 9 | """Mirai Api Http 已登录的账号""" 10 | host: str = 'http://localhost:8080' 11 | """Mirai Api Http 地址""" 12 | verifyKey: str = 'VerifyKey' 13 | """Mirai Api Http 的 verifyKey""" 14 | 15 | 16 | @dataclass 17 | class AdminConfig: 18 | masterId: int = 731347477 19 | """机器人主人的QQ号""" 20 | masterName: str = 'Red_lnn' 21 | """机器人主人的称呼""" 22 | admins: list[int] = field(default_factory=lambda: [731347477]) 23 | """机器人管理员列表""" 24 | 25 | 26 | @config('redbot') 27 | class BasicConfig: 28 | botName: str = 'RedBot' 29 | """机器人的QQ号""" 30 | logChat: bool = True 31 | """是否将聊天信息打印在日志中""" 32 | debug: bool = False 33 | """是否启用调试模式""" 34 | databaseUrl: str = 'sqlite+aiosqlite:///data/database.db' 35 | """数据库地址 36 | 37 | MySQL示例:mysql+asyncmy://user:pass@hostname/dbname?charset=utf8mb4 38 | """ 39 | miraiApiHttp: MAHConfig = field(default_factory=lambda: MAHConfig(account=123456789, verifyKey='VerifyKey')) 40 | """Mirai Api Http 配置""" 41 | admin: AdminConfig = field(default_factory=lambda: AdminConfig()) 42 | """机器人管理相关配置""" 43 | 44 | 45 | @config('modules') 46 | class ModulesConfig: 47 | enabled: bool = True 48 | """是否允许加载模块""" 49 | globalDisabledModules: list[str] = field(default_factory=lambda: []) 50 | """全局禁用的模块列表""" 51 | disabledGroups: dict[str, list[int]] = field( 52 | default_factory=lambda: {'core_modules.bot_manage': [123456789, 123456780]} 53 | ) 54 | """分群禁用模块的列表""" 55 | 56 | 57 | basic_cfg = create(BasicConfig) 58 | modules_cfg = create(ModulesConfig, flush=True) 59 | -------------------------------------------------------------------------------- /libs/control/__init__.py: -------------------------------------------------------------------------------- 1 | from graia.ariadne.event.message import GroupMessage, MessageEvent 2 | from graia.ariadne.event.mirai import NudgeEvent 3 | from graia.broadcast import ExecutionStop 4 | from graia.broadcast.builtin.decorators import Depend 5 | from graia.broadcast.entities.event import Dispatchable 6 | 7 | from libs.config import modules_cfg 8 | 9 | 10 | def require_disable(module_name: str) -> Depend: 11 | def wrapper(event: Dispatchable): 12 | if module_name in modules_cfg.globalDisabledModules: 13 | raise ExecutionStop 14 | elif module_name in modules_cfg.disabledGroups: 15 | if isinstance(event, MessageEvent): 16 | if isinstance(event, GroupMessage) and event.sender.group.id in modules_cfg.disabledGroups[module_name]: 17 | raise ExecutionStop 18 | elif event.sender.id in modules_cfg.disabledGroups[module_name]: 19 | raise ExecutionStop 20 | elif isinstance(event, NudgeEvent) and event.target in modules_cfg.disabledGroups[module_name]: 21 | raise ExecutionStop 22 | elif hasattr(event, 'group') and getattr(event, 'group') in modules_cfg.disabledGroups[module_name]: 23 | raise ExecutionStop 24 | 25 | return Depend(wrapper) 26 | -------------------------------------------------------------------------------- /libs/control/interval.py: -------------------------------------------------------------------------------- 1 | """ 2 | 群、用户调用频率限制(bot主人与bot管理员可以无视,没有开关) 3 | 4 | Xenon 管理:https://github.com/McZoo/Xenon/blob/master/lib/control.py 5 | """ 6 | 7 | import time 8 | from asyncio import Lock 9 | from collections import defaultdict 10 | from typing import DefaultDict, Optional, Set, Tuple 11 | 12 | from graia.ariadne.app import Ariadne 13 | from graia.ariadne.message.chain import MessageChain 14 | from graia.ariadne.message.element import At, Plain 15 | from graia.ariadne.model import Group, Member 16 | from graia.broadcast import ExecutionStop 17 | from graia.broadcast.builtin.decorators import Depend 18 | 19 | from .permission import GroupPermission 20 | 21 | 22 | class GroupInterval: 23 | """用于管理群组调用bot的冷却的类,不应被实例化""" 24 | 25 | last_exec: DefaultDict[int, Tuple[int, float]] = defaultdict(lambda: (1, 0.0)) 26 | last_alert: DefaultDict[int, float] = defaultdict(float) 27 | sent_alert: Set[int] = set() 28 | lock: Optional[Lock] = None 29 | 30 | @classmethod 31 | async def get_lock(cls): 32 | if not cls.lock: 33 | cls.lock = Lock() 34 | return cls.lock 35 | 36 | @classmethod 37 | def require( 38 | cls, 39 | suspend_time: float, 40 | max_exec: int = 1, 41 | send_alert: bool = True, 42 | alert_time_interval: int = 5, 43 | override_level: int = GroupPermission.ADMIN, 44 | ) -> Depend: 45 | """ 46 | 指示用户每执行 `max_exec` 次后需要至少相隔 `suspend_time` 秒才能再次触发功能 47 | 等级在 `override_level` 以上的可以无视限制 48 | 49 | :param suspend_time: 冷却时间 50 | :param max_exec: 使用n次后进入冷却 51 | :param send_alert: 是否发送冷却提示 52 | :param alert_time_interval: 发送冷却提示时间间隔,在设定时间内不会重复警告 53 | :param override_level: 可超越限制的最小等级,默认为群管理员 54 | """ 55 | 56 | async def cd_check(app: Ariadne, group: Group, member: Member): 57 | if await GroupPermission.get(member) >= override_level: 58 | return 59 | current = time.time() 60 | async with (await cls.get_lock()): 61 | last = cls.last_exec[group.id] 62 | if current - last[1] >= suspend_time: 63 | cls.last_exec[group.id] = (1, current) 64 | if group.id in cls.sent_alert: 65 | cls.sent_alert.remove(group.id) 66 | return 67 | elif last[0] < max_exec: 68 | cls.last_exec[group.id] = (last[0] + 1, current) 69 | if group.id in cls.sent_alert: 70 | cls.sent_alert.remove(group.id) 71 | return 72 | if send_alert: 73 | if group.id not in cls.sent_alert: 74 | m, s = divmod(last[1] + suspend_time - current, 60) 75 | await app.send_message( 76 | group, MessageChain(Plain(f'功能冷却中...\n还有{f"{str(m)}分" if m else ""}{"%d" % s}秒结束')) 77 | ) 78 | cls.last_alert[group.id] = current 79 | cls.sent_alert.add(group.id) 80 | elif current - cls.last_alert[group.id] > alert_time_interval: 81 | cls.sent_alert.remove(group.id) 82 | raise ExecutionStop() 83 | 84 | return Depend(cd_check) 85 | 86 | 87 | class MemberInterval: 88 | """用于管理群成员调用bot的冷却的类,不应被实例化""" 89 | 90 | last_exec: DefaultDict[str, Tuple[int, float]] = defaultdict(lambda: (1, 0.0)) 91 | last_alert: DefaultDict[str, float] = defaultdict(float) 92 | sent_alert: Set[str] = set() 93 | lock: Optional[Lock] = None 94 | 95 | @classmethod 96 | async def get_lock(cls): 97 | if not cls.lock: 98 | cls.lock = Lock() 99 | return cls.lock 100 | 101 | @classmethod 102 | def require( 103 | cls, 104 | suspend_time: float, 105 | max_exec: int = 1, 106 | send_alert: bool = True, 107 | alert_time_interval: int = 5, 108 | override_level: int = GroupPermission.ADMIN, 109 | ) -> Depend: 110 | """ 111 | 指示用户每执行 `max_exec` 次后需要至少相隔 `suspend_time` 秒才能再次触发功能 112 | 等级在 `override_level` 以上的可以无视限制 113 | 114 | :param suspend_time: 冷却时间 115 | :param max_exec: 使用n次后进入冷却 116 | :param send_alert: 是否发送冷却提示 117 | :param alert_time_interval: 警告时间间隔,在设定时间内不会重复警告 118 | :param override_level: 可超越限制的最小等级,默认为群管理员 119 | """ 120 | 121 | async def cd_check(app: Ariadne, group: Group, member: Member): 122 | if await GroupPermission.get(member) >= override_level: 123 | return 124 | current = time.time() 125 | name = f'{member.id}_{group.id}' 126 | async with (await cls.get_lock()): 127 | last = cls.last_exec[name] 128 | if current - cls.last_exec[name][1] >= suspend_time: 129 | cls.last_exec[name] = (1, current) 130 | if name in cls.sent_alert: 131 | cls.sent_alert.remove(name) 132 | return 133 | elif last[0] < max_exec: 134 | cls.last_exec[name] = (last[0] + 1, current) 135 | if member.id in cls.sent_alert: 136 | cls.sent_alert.remove(name) 137 | return 138 | if send_alert: 139 | if member.id not in cls.sent_alert: 140 | m, s = divmod(last[1] + suspend_time - current, 60) 141 | await app.send_message( 142 | group, 143 | MessageChain( 144 | At(member.id), Plain(f' 你在本群暂时不可调用bot,正在冷却中...\n还有{f"{m}分" if m else ""}{"%d" % s}秒结束') 145 | ), 146 | ) 147 | cls.last_alert[name] = current 148 | cls.sent_alert.add(name) 149 | elif current - cls.last_alert[name] > alert_time_interval: 150 | cls.sent_alert.remove(name) 151 | raise ExecutionStop() 152 | 153 | return Depend(cd_check) 154 | 155 | 156 | class ManualInterval: 157 | """用于管理自定义的调用bot的冷却的类,不应被实例化""" 158 | 159 | last_exec: DefaultDict[str, Tuple[int, float]] = defaultdict(lambda: (1, 0.0)) 160 | 161 | @classmethod 162 | def require(cls, name: str, suspend_time: float, max_exec: int = 1) -> Tuple[bool, Optional[float]]: 163 | """ 164 | 指示用户每执行 `max_exec` 次后需要至少相隔 `suspend_time` 秒才能再次触发功能 165 | 166 | :param name: 需要被冷却的功能或自定义flag 167 | :param suspend_time: 冷却时间 168 | :param max_exec: 使用n次后进入冷却 169 | :return: True 为冷却中,False 反之,若为 False,还会返回剩余时间 170 | """ 171 | 172 | current = time.time() 173 | last = cls.last_exec[name] 174 | if current - cls.last_exec[name][1] >= suspend_time: 175 | cls.last_exec[name] = (1, current) 176 | return True, None 177 | elif last[0] < max_exec: 178 | cls.last_exec[name] = (last[0] + 1, current) 179 | return True, None 180 | return False, round(last[1] + suspend_time - current, 2) 181 | -------------------------------------------------------------------------------- /libs/control/permission.py: -------------------------------------------------------------------------------- 1 | """ 2 | 权限即黑名单检查 3 | 4 | 移植自 Xenon:https://github.com/McZoo/Xenon/blob/master/lib/control.py 5 | """ 6 | 7 | from dataclasses import field 8 | 9 | from graia.ariadne.app import Ariadne 10 | from graia.ariadne.message.chain import MessageChain 11 | from graia.ariadne.message.element import At, Plain 12 | from graia.ariadne.model import Group, Member, MemberPerm 13 | from graia.broadcast import ExecutionStop 14 | from graia.broadcast.builtin.decorators import Depend 15 | from kayaku import config, create 16 | 17 | from ..config import basic_cfg 18 | 19 | 20 | @config('permission') 21 | class PermConfig: 22 | group_whitelist: list[int] = field(default_factory=lambda: []) 23 | """白名单群组列表""" 24 | user_blacklist: list[int] = field(default_factory=lambda: []) 25 | """用户黑名单列表""" 26 | 27 | 28 | perm_cfg = create(PermConfig) 29 | 30 | 31 | class GroupPermission: 32 | """ 33 | 用于管理权限的类,不应被实例化 34 | 35 | 适用于群消息和来自群的临时会话 36 | """ 37 | 38 | BOT_MASTER: int = 100 # Bot主人 39 | BOT_ADMIN: int = 90 # Bot管理员 40 | OWNER: int = 30 # 群主 41 | ADMIN: int = 20 # 群管理员 42 | USER: int = 10 # 群成员/好友 43 | BANED: int = 0 # Bot黑名单成员 44 | DEFAULT: int = USER 45 | 46 | _levels = { 47 | MemberPerm.Member: USER, 48 | MemberPerm.Administrator: ADMIN, 49 | MemberPerm.Owner: OWNER, 50 | } 51 | 52 | @classmethod 53 | async def get(cls, target: Member) -> int: 54 | """ 55 | 获取用户的权限等级 56 | 57 | :param target: Friend 或 Member 实例 58 | :return: 等级,整数 59 | """ 60 | if target.id == basic_cfg.admin.masterId: 61 | return cls.BOT_MASTER 62 | elif target.id in basic_cfg.admin.admins: 63 | return cls.BOT_ADMIN 64 | elif isinstance(target, Member): 65 | return cls._levels[target.permission] 66 | else: 67 | return cls.DEFAULT 68 | 69 | @classmethod 70 | def require( 71 | cls, 72 | perm: MemberPerm | int = MemberPerm.Member, 73 | send_alert: bool = True, 74 | alert_text: str = '你没有权限执行此指令', 75 | ) -> Depend: 76 | """ 77 | 群消息权限检查 78 | 79 | 指示需要 `level` 以上等级才能触发 80 | 81 | :param perm: 至少需要什么权限才能调用 82 | :param send_alert: 是否发送无权限消息 83 | :param alert_text: 无权限提示的消息内容 84 | """ 85 | 86 | async def wrapper(app: Ariadne, group: Group, member: Member): 87 | if group.id not in perm_cfg.group_whitelist or member.id in perm_cfg.user_blacklist: 88 | raise ExecutionStop() 89 | if isinstance(perm, MemberPerm): 90 | target = cls._levels[perm] 91 | elif isinstance(perm, int): 92 | target = perm 93 | else: 94 | raise ValueError('perm 参数类型错误') 95 | if (await cls.get(member)) < target: 96 | if send_alert: 97 | await app.send_message(group, MessageChain(At(member.id), Plain(f' {alert_text}'))) 98 | raise ExecutionStop() 99 | 100 | return Depend(wrapper) 101 | -------------------------------------------------------------------------------- /libs/database/__init__.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | from asyncio import current_task 3 | from typing import Any, Sequence 4 | 5 | from sqlalchemy.engine import Row 6 | from sqlalchemy.engine.result import Result 7 | from sqlalchemy.engine.url import URL 8 | from sqlalchemy.ext.asyncio import ( 9 | AsyncSession, 10 | async_scoped_session, 11 | async_sessionmaker, 12 | create_async_engine, 13 | ) 14 | from sqlalchemy.ext.asyncio.engine import AsyncEngine 15 | from sqlalchemy.sql.base import Executable 16 | 17 | from .models import Base 18 | from .types import EngineOptions 19 | 20 | # sqlite_url = 'sqlite+aiosqlite:///data/redbot.db' 21 | # mysql_url = 'mysql+asyncmy://user:pass@hostname/dbname?charset=utf8mb4 22 | 23 | 24 | class DatabaseManager: 25 | engine: AsyncEngine 26 | session_factory: async_sessionmaker[AsyncSession] 27 | 28 | def __init__(self, url: str | URL, engine_options: EngineOptions | None = None): 29 | if engine_options is None: 30 | engine_options = {'echo': 'debug', 'pool_pre_ping': True} 31 | self.engine = create_async_engine(url, **engine_options) 32 | 33 | @classmethod 34 | def get_engine_url( 35 | cls, 36 | driver: str = 'asnycmy', 37 | db_type: str = 'mysql', 38 | host: str = 'localhost', 39 | port: int = 3306, 40 | username: str | None = None, 41 | passwd: str | None = None, 42 | database_name: str | None = None, 43 | **kwargs: dict[str, str], 44 | ) -> str: 45 | if db_type == 'mysql': 46 | if username is None or passwd is None or database_name is None: 47 | raise RuntimeError('Option `username` or `passwd` or `database_name` must in parameter.') 48 | url = f'mysql+{driver}://{username}:{passwd}@{host}:{port}/{database_name}' 49 | elif db_type == 'sqlite': 50 | url = f'sqlite+{driver}://' 51 | else: 52 | raise RuntimeError('Unsupport database type, please creating URL manually.') 53 | kw = ''.join(f'&{key}={value}' for key, value in kwargs.items()).lstrip('&') 54 | return url + kw if kw else url 55 | 56 | async def initialize(self, session_options: dict[str, Any] | None = None): 57 | if session_options is None: 58 | session_options = {'expire_on_commit': False} 59 | async with self.engine.begin() as conn: 60 | await conn.run_sync(Base.metadata.create_all) 61 | self.session_factory = async_sessionmaker(self.engine, **session_options) 62 | 63 | async def stop(self): 64 | # for AsyncEngine created in function scope, close and 65 | # clean-up pooled connections 66 | await self.engine.dispose() 67 | 68 | async def async_safe_session(self): 69 | """ 70 | 生成一个异步安全的session回话 71 | :return: 72 | """ 73 | self._scoped_session = async_scoped_session(self.session_factory, scopefunc=current_task) 74 | return self._scoped_session 75 | 76 | @contextlib.asynccontextmanager 77 | async def async_session(self): 78 | """ 79 | 异步session上下文管理封装 80 | :return: 81 | 82 | >>> async with db_manager.async_session() as session: 83 | >>> res = await session.execute("SELECT 1") 84 | >>> print(res.scalar()) 85 | 1 86 | 87 | """ 88 | scoped_session = await self.async_safe_session() 89 | try: 90 | yield scoped_session 91 | await self._scoped_session.commit() 92 | except Exception: 93 | await self._scoped_session.rollback() 94 | raise 95 | finally: 96 | await self._scoped_session.remove() 97 | 98 | async def exec(self, sql: Executable) -> Result: 99 | async with self.async_session() as session: 100 | return await session.execute(sql) 101 | 102 | async def select_all(self, sql: Executable) -> Sequence[Row]: 103 | result = await self.exec(sql) 104 | return result.all() 105 | 106 | async def select_first(self, sql: Executable) -> Row | None: 107 | result = await self.exec(sql) 108 | return result.first() 109 | 110 | async def add(self, row): 111 | scoped_session = await self.async_safe_session() 112 | try: 113 | scoped_session.add(row) 114 | await self._scoped_session.commit() 115 | await self._scoped_session.refresh(row) 116 | except Exception: 117 | await self._scoped_session.rollback() 118 | raise 119 | finally: 120 | await self._scoped_session.remove() 121 | 122 | async def add_many(self, rows: Sequence[Base]): 123 | scoped_session = await self.async_safe_session() 124 | try: 125 | scoped_session.add_all(rows) 126 | await self._scoped_session.commit() 127 | for row in rows: 128 | await self._scoped_session.refresh(row) 129 | except Exception: 130 | await self._scoped_session.rollback() 131 | raise 132 | finally: 133 | await self._scoped_session.remove() 134 | 135 | async def update_exist(self, row): 136 | scoped_session = await self.async_safe_session() 137 | try: 138 | await scoped_session.merge(row) 139 | await self._scoped_session.commit() 140 | await self._scoped_session.refresh(row) 141 | except Exception: 142 | await self._scoped_session.rollback() 143 | raise 144 | finally: 145 | await self._scoped_session.remove() 146 | 147 | async def delete_exist(self, row): 148 | async with self.async_session() as session: 149 | await session.delete(row) 150 | 151 | async def delete_many_exist(self, *rows): 152 | async with self.async_session() as session: 153 | for row in rows: 154 | await session.delete(row) 155 | -------------------------------------------------------------------------------- /libs/database/models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import AsyncAttrs 2 | from sqlalchemy.orm import DeclarativeBase 3 | 4 | 5 | class Base(AsyncAttrs, DeclarativeBase): 6 | pass 7 | -------------------------------------------------------------------------------- /libs/database/types.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, Dict, List, Literal, Optional, Type, Union 2 | 3 | from sqlalchemy.engine.interfaces import IsolationLevel, _ExecuteOptions, _ParamStyle 4 | from sqlalchemy.log import _EchoFlagType 5 | from sqlalchemy.pool import Pool, _CreatorFnType, _CreatorWRecFnType, _ResetStyleArgType 6 | from typing_extensions import TypedDict 7 | 8 | 9 | class EngineOptions(TypedDict, total=False): 10 | connect_args: Dict[Any, Any] 11 | convert_unicode: bool 12 | creator: Union[_CreatorFnType, _CreatorWRecFnType] 13 | echo: _EchoFlagType 14 | echo_pool: _EchoFlagType 15 | enable_from_linting: bool 16 | execution_options: _ExecuteOptions 17 | future: Literal[True] 18 | hide_parameters: bool 19 | implicit_returning: Literal[True] 20 | insertmanyvalues_page_size: int 21 | isolation_level: IsolationLevel 22 | json_deserializer: Callable[..., Any] 23 | json_serializer: Callable[..., Any] 24 | label_length: Optional[int] 25 | logging_name: str 26 | max_identifier_length: Optional[int] 27 | max_overflow: int 28 | module: Optional[Any] 29 | paramstyle: Optional[_ParamStyle] 30 | pool: Optional[Pool] 31 | poolclass: Optional[Type[Pool]] 32 | pool_logging_name: str 33 | pool_pre_ping: bool 34 | pool_size: int 35 | pool_recycle: int 36 | pool_reset_on_return: Optional[_ResetStyleArgType] 37 | pool_timeout: float 38 | pool_use_lifo: bool 39 | plugins: List[str] 40 | query_cache_size: int 41 | use_insertmanyvalues: bool 42 | kwargs: Dict[str, Any] 43 | -------------------------------------------------------------------------------- /libs/database_services.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from launart import ExportInterface, Launchable 4 | from loguru import logger 5 | 6 | from libs import db 7 | 8 | 9 | class DatabaseInitService(Launchable): 10 | id: str = 'database/init' 11 | 12 | @property 13 | def required(self) -> set[str | type[ExportInterface]]: 14 | return set() 15 | 16 | @property 17 | def stages(self) -> set[Literal['preparing', 'blocking', 'cleanup']]: 18 | return set() 19 | 20 | async def launch(self, _): 21 | logger.info('Initializing database...') 22 | await db.initialize() 23 | -------------------------------------------------------------------------------- /libs/fonts_provider.py: -------------------------------------------------------------------------------- 1 | from playwright.async_api import Request, Route 2 | from yarl import URL 3 | 4 | from static.path import lib_path 5 | 6 | font_path = lib_path / 'fonts' 7 | 8 | font_mime_map = { 9 | 'collection': 'font/collection', 10 | 'otf': 'font/otf', 11 | 'sfnt': 'font/sfnt', 12 | 'ttf': 'font/ttf', 13 | 'woff': 'font/woff', 14 | 'woff2': 'font/woff2', 15 | } 16 | 17 | 18 | async def fill_font(route: Route, request: Request): 19 | url = URL(request.url) 20 | if (font_path / url.name).exists(): 21 | await route.fulfill( 22 | path=font_path / url.name, 23 | content_type=font_mime_map.get(url.suffix), 24 | ) 25 | return 26 | await route.fallback() 27 | -------------------------------------------------------------------------------- /libs/send_action.py: -------------------------------------------------------------------------------- 1 | from typing import TypeVar, overload 2 | 3 | from graia.ariadne.app import Ariadne 4 | from graia.ariadne.event.message import ActiveMessage 5 | from graia.ariadne.message.chain import MessageChain 6 | from graia.ariadne.message.element import Plain 7 | from graia.ariadne.typing import SendMessageAction, SendMessageException 8 | from graia.ariadne.util.send import Ignore 9 | 10 | Exc_T = TypeVar('Exc_T', bound=SendMessageException) 11 | 12 | 13 | class Safe(SendMessageAction): 14 | """ 15 | 安全发送的 SendMessage action 16 | 17 | 行为: 18 | 在第一次尝试失败后先移除 quote, 19 | 之后每次失败时按顺序替换元素为其asDisplay: AtAll, At, Poke, Forward, MultimediaElement 20 | 若最后还是失败 (AccountMuted 等), 则会引发原始异常 (通过传入 ignore 决定) 21 | """ 22 | 23 | def __init__(self, ignore: bool = False) -> None: 24 | self.ignore: bool = ignore 25 | 26 | @overload 27 | @staticmethod 28 | async def exception(item) -> ActiveMessage: 29 | ... 30 | 31 | @overload 32 | async def exception(self, item) -> ActiveMessage: 33 | ... 34 | 35 | @staticmethod 36 | async def _handle(item: SendMessageException, ignore: bool): 37 | chain: MessageChain = item.send_data['message'] 38 | ariadne = Ariadne.current() 39 | 40 | def convert(msg_chain: MessageChain, type_: str) -> None: 41 | for ind, elem in enumerate(msg_chain.__root__[:]): 42 | if elem.type == type_: 43 | if elem.type == 'at': 44 | msg_chain.__root__[ind] = ( 45 | Plain(f'@{elem}({elem.target})') # type: ignore 46 | if elem.target is not None # type: ignore 47 | else Plain(f'@{elem.target}') # type: ignore 48 | ) 49 | else: 50 | msg_chain.__root__[ind] = Plain(str(elem)) 51 | 52 | for element_type in ('AtAll', 'At', 'Poke', 'Forward', 'MultimediaElement'): 53 | convert(chain, element_type) 54 | val = await ariadne.send_message(**item.send_data, action=Ignore) # type: ignore # noqa 55 | if val is not None: 56 | return val 57 | 58 | if not ignore: 59 | raise item 60 | 61 | @overload 62 | @staticmethod 63 | async def exception(s, i): 64 | ... 65 | 66 | @overload 67 | async def exception(s, i): # sourcery skip: instance-method-first-arg-name #type: ignore 68 | ... 69 | 70 | async def exception(s: 'Safe' | Exc_T, i: Exc_T | None = None): # type: ignore # noqa 71 | # sourcery skip: instance-method-first-arg-name 72 | if not isinstance(s, Safe): 73 | return await Safe._handle(s, True) 74 | if i: 75 | return await Safe._handle(i, s.ignore) 76 | -------------------------------------------------------------------------------- /libs/text2img.py: -------------------------------------------------------------------------------- 1 | import re 2 | from datetime import datetime 3 | 4 | from graiax.text2img.playwright import ( 5 | HTMLRenderer, 6 | MarkdownConverter, 7 | PageOption, 8 | ScreenshotOption, 9 | convert_text, 10 | ) 11 | from graiax.text2img.playwright.renderer import BuiltinCSS 12 | from jinja2 import Template 13 | 14 | from libs.fonts_provider import fill_font 15 | 16 | footer = ( 17 | '' 20 | f'' 21 | ) 22 | 23 | html_render = HTMLRenderer( 24 | page_option=PageOption(device_scale_factor=1.5), 25 | screenshot_option=ScreenshotOption(type='jpeg', quality=80, full_page=True, scale='device'), 26 | css=( 27 | BuiltinCSS.reset, 28 | BuiltinCSS.github, 29 | BuiltinCSS.one_dark, 30 | BuiltinCSS.container, 31 | "@font-face{font-family:'harmo';font-weight:300;" 32 | "src:url('http://static.graiax/fonts/HarmonyOS_Sans_SC_Light.ttf') format('truetype');}" 33 | "@font-face{font-family:'harmo';font-weight:400;" 34 | "src:url('http://static.graiax/fonts/HarmonyOS_Sans_SC_Regular.ttf') format('truetype');}" 35 | "@font-face{font-family:'harmo';font-weight:500;" 36 | "src:url('http://static.graiax/fonts/HarmonyOS_Sans_SC_Medium.ttf') format('truetype');}" 37 | "@font-face{font-family:'harmo';font-weight:600;" 38 | "src:url('http://static.graiax/fonts/HarmonyOS_Sans_SC_Bold.ttf') format('truetype');}" 39 | "*{font-family:'harmo',sans-serif}", 40 | ), 41 | page_modifiers=[ 42 | lambda page: page.route(lambda url: bool(re.match('^http://static.graiax/fonts/(.+)$', url)), fill_font) 43 | ], 44 | ) 45 | 46 | md_converter = MarkdownConverter() 47 | 48 | 49 | async def text2img(text: str, width: int = 800) -> bytes: 50 | html = convert_text(text) 51 | html += footer 52 | 53 | return await html_render.render( 54 | html, 55 | extra_page_option=PageOption(viewport={'width': width, 'height': 10}), 56 | ) 57 | 58 | 59 | async def md2img(text: str, width: int = 800) -> bytes: 60 | html = md_converter.convert(text) 61 | html += footer 62 | 63 | return await html_render.render( 64 | html, 65 | extra_page_option=PageOption(viewport={'width': width, 'height': 10}), 66 | ) 67 | 68 | 69 | async def template2img( 70 | template: str, 71 | render_option: dict[str, str], 72 | *, 73 | extra_page_option: PageOption | None = None, 74 | extra_screenshot_option: ScreenshotOption | None = None, 75 | ) -> bytes: 76 | """Jinja2 模板转图片 77 | Args: 78 | template (str): Jinja2 模板 79 | render_option (Dict[str, str]): Jinja2.Template.render 的参数 80 | return_html (bool): 返回生成的 HTML 代码而不是图片生成结果的 bytes 81 | extra_page_option (PageOption, optional): Playwright 浏览器 new_page 方法的参数 82 | extra_screenshot_option (ScreenshotOption, optional): Playwright 浏览器页面截图方法的参数 83 | extra_page_methods (Optional[List[Callable[[Page], Awaitable]]]): 84 | 默认为 None,用于 https://playwright.dev/python/docs/api/class-page 中提到的部分方法, 85 | 如 `page.route(...)` 等 86 | """ 87 | html_code: str = Template(template).render(**render_option) 88 | return await html_render.render( 89 | html_code, 90 | extra_page_option=extra_page_option, 91 | extra_screenshot_option=extra_screenshot_option, 92 | ) 93 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import pkgutil 2 | from pathlib import Path 3 | 4 | import kayaku 5 | from creart import create 6 | 7 | # from graia.amnesia.builtins.uvicorn import UvicornService 8 | from graia.ariadne.app import Ariadne 9 | from graia.ariadne.connection.config import ( 10 | HttpClientConfig, 11 | WebsocketClientConfig, 12 | config, 13 | ) 14 | from graia.ariadne.model import LogConfig 15 | from graia.saya import Saya 16 | from graiax.playwright import PlaywrightService 17 | 18 | kayaku.initialize({"{**}": "./config/{**}"}) 19 | 20 | from libs import log_level_handler, replace_logger # noqa: E402 21 | from libs.config import basic_cfg, modules_cfg # noqa: E402 22 | 23 | # from libs.fastapi_service import FastAPIStarletteService # noqa: E402 24 | from libs.database_services import DatabaseInitService # noqa: E402 25 | from libs.send_action import Safe # noqa: E402 26 | from static.path import modules_path, root_path # noqa: E402 27 | 28 | kayaku.bootstrap() 29 | 30 | # loop = create(AbstractEventLoop) # 若不需要 loop.run_until_complete(),则不需要此行 31 | # 若 create Saya 则可以省掉 bcc 和 scheduler 的 32 | # sche = create(GraiaScheduler) 33 | # bcc = create(Broadcast) 34 | saya = create(Saya) 35 | 36 | Ariadne.options['installed_log'] = True 37 | app = Ariadne( 38 | connection=config( 39 | basic_cfg.miraiApiHttp.account, # 你的机器人的 qq 号 40 | basic_cfg.miraiApiHttp.verifyKey, # 填入 verifyKey 41 | HttpClientConfig(host=basic_cfg.miraiApiHttp.host), 42 | WebsocketClientConfig(host=basic_cfg.miraiApiHttp.host), 43 | ), 44 | log_config=LogConfig(log_level_handler), 45 | ) 46 | app.default_send_action = Safe 47 | 48 | replace_logger(level=0 if basic_cfg.debug else 20, richuru=False) 49 | 50 | ignore = ('__init__.py', '__pycache__') 51 | 52 | with saya.module_context(): 53 | core_modules_path = Path(root_path, 'core_modules') 54 | for module in pkgutil.iter_modules([str(core_modules_path)]): 55 | if module.name in ignore or module.name[0] in ('#', '.', '_'): 56 | continue 57 | saya.require(f'core_modules.{module.name}') 58 | 59 | if modules_cfg.enabled: 60 | with saya.module_context(): 61 | for module in pkgutil.iter_modules([str(modules_path)]): 62 | if module.name in ignore or module.name[0] in ('#', '.', '_'): 63 | continue 64 | saya.require(f'modules.{module.name}') 65 | 66 | if basic_cfg.miraiApiHttp.account == 123456789: 67 | raise ValueError('在?¿ 填一下配置文件?') 68 | 69 | app.launch_manager.add_service(DatabaseInitService()) 70 | # app.launch_manager.add_service(FastAPIStarletteService()) 71 | # app.launch_manager.add_service(UvicornService()) 72 | app.launch_manager.add_service(PlaywrightService()) 73 | 74 | Ariadne.launch_blocking() 75 | kayaku.save_all() 76 | -------------------------------------------------------------------------------- /modules/bili_share_resolver.py: -------------------------------------------------------------------------------- 1 | """ 2 | 识别群内的B站链接、分享、av号、BV号并获取其对应的视频的信息 3 | 4 | 以下几种消息均可触发 5 | 6 | - 新版B站app分享的两种小程序 7 | - 旧版B站app分享的xml消息 8 | - B站概念版分享的json消息 9 | - 文字消息里含有B站视频地址,如 https://www.bilibili.com/video/{av/bv号} (m.bilibili.com 也可以 10 | - 文字消息里含有B站视频地址,如 https://b23.tv/3V31Ap 11 | - 文字消息里含有B站视频地址,如 https://b23.tv/3V31Ap 12 | - BV1xx411c7mD 13 | - av2 14 | """ 15 | 16 | import re 17 | import time 18 | from base64 import b64encode 19 | from dataclasses import dataclass 20 | from io import BytesIO 21 | from pathlib import Path 22 | from typing import Literal 23 | 24 | from graia.amnesia.builtins.aiohttp import AiohttpClientInterface 25 | from graia.ariadne.app import Ariadne 26 | from graia.ariadne.event.message import GroupMessage 27 | from graia.ariadne.message.chain import MessageChain 28 | from graia.ariadne.message.element import Image, Plain 29 | from graia.ariadne.model import Group, Member 30 | from graia.saya import Channel 31 | from graiax.shortcut.saya import decorate, listen 32 | from graiax.text2img.playwright import PageOption 33 | from launart import Launart 34 | from loguru import logger 35 | from PIL.Image import Image as PILImage 36 | from qrcode.main import QRCode 37 | 38 | from libs.control import require_disable 39 | from libs.control.interval import ManualInterval 40 | from libs.control.permission import GroupPermission 41 | from libs.text2img import template2img 42 | from static.path import root_path 43 | 44 | channel = Channel.current() 45 | 46 | channel.meta['name'] = 'B站视频信息获取' 47 | channel.meta['author'] = ['Red_lnn'] 48 | channel.meta['description'] = ( 49 | '识别群内的B站链接、分享、av号、BV号并获取其对应的视频的信息\n' 50 | '以下几种消息均可触发:\n' 51 | ' - 新版B站app分享的两种小程序\n' 52 | ' - 旧版B站app分享的xml消息\n' 53 | ' - B站概念版分享的json消息\n' 54 | ' - 文字消息里含有B站视频地址,如 https://www.bilibili.com/video/{av/bv号} (m.bilibili.com 也可以)\n' 55 | ' - 文字消息里含有B站视频地址,如 https://b23.tv/3V31Ap\n' 56 | ' - 文字消息里含有BV号,如 BV1xx411c7mD\n' 57 | ' - 文字消息里含有av号,如 av2' 58 | ) 59 | 60 | avid_re = '(av|AV)(\\d{1,12})' 61 | bvid_re = '[Bb][Vv]1([0-9a-zA-Z]{2})4[1y]1[0-9a-zA-Z]7([0-9a-zA-Z]{2})' 62 | 63 | 64 | @dataclass 65 | class VideoInfo: 66 | cover_url: str # 封面地址 67 | bvid: str # BV号 68 | avid: int # av号 69 | title: str # 视频标题 70 | sub_count: int # 视频分P数 71 | pub_timestamp: int # 视频发布时间(时间戳) 72 | upload_timestamp: int # 视频上传时间(时间戳,不一定准确) 73 | desc: str # 视频简介 74 | duration: int # 视频长度(单位:秒) 75 | up_mid: int # up主mid 76 | up_name: str # up主名称 77 | up_face: str # up主头像地址 78 | views: int # 播放量 79 | danmu: int # 弹幕数 80 | likes: int # 点赞数 81 | coins: int # 投币数 82 | replys: int # 评论数 83 | favorites: int # 收藏量 84 | 85 | 86 | @listen(GroupMessage) 87 | @decorate(GroupPermission.require(), require_disable(channel.module)) 88 | async def main(app: Ariadne, group: Group, message: MessageChain, member: Member): 89 | p = re.compile(f'({avid_re})|({bvid_re})') 90 | msg_str = message.as_persistent_string() 91 | if 'b23.tv/' in msg_str: 92 | msg_str = await b23_url_extract(msg_str) 93 | if not msg_str: 94 | return 95 | video_id = p.search(msg_str) 96 | if not video_id or video_id is None: 97 | return 98 | video_id = video_id.group() 99 | 100 | rate_limit, remaining_time = ManualInterval.require(f'{group.id}_{member.id}_bilibiliVideoInfo', 5, 2) 101 | if not rate_limit: 102 | await app.send_message(group, MessageChain(Plain(f'冷却中,剩余{remaining_time}秒,请稍后再试'))) 103 | return 104 | 105 | video_info = await get_video_info(video_id) 106 | if video_info['code'] == -404: 107 | return await app.send_message(group, MessageChain(Plain('视频不存在'))) 108 | elif video_info['code'] != 0: 109 | error_text = f'解析B站视频 {video_id} 时出错👇\n错误代码:{video_info["code"]}\n错误信息:{video_info["message"]}' 110 | logger.error(error_text) 111 | return await app.send_message(group, MessageChain(Plain(error_text))) 112 | else: 113 | video_info = await info_json_dump(video_info['data']) 114 | img: bytes = await gen_img(video_info) 115 | await app.send_message( 116 | group, 117 | MessageChain( 118 | Image(data_bytes=img), 119 | Plain( 120 | f'{video_info.title}\n' 121 | '—————————————\n' 122 | f'UP主:{video_info.up_name}\n' 123 | f'{math(video_info.views)}播放 {math(video_info.likes)}赞\n' 124 | f'链接:https://b23.tv/{video_info.bvid}' 125 | ), 126 | ), 127 | ) 128 | 129 | 130 | async def b23_url_extract(b23_url: str) -> Literal[False] | str: 131 | url = re.search(r'b23.tv[/\\]+([0-9a-zA-Z]+)', b23_url) 132 | if url is None: 133 | return False 134 | 135 | launart = Launart.current() 136 | session = launart.get_interface(AiohttpClientInterface).service.session 137 | 138 | async with session.get(f'https://{url.group()}', allow_redirects=True) as resp: 139 | target = str(resp.url) 140 | return target if 'www.bilibili.com/video/' in target else False 141 | 142 | 143 | async def get_video_info(video_id: str) -> dict: 144 | launart = Launart.current() 145 | session = launart.get_interface(AiohttpClientInterface).service.session 146 | 147 | if video_id[:2].lower() == 'av': 148 | async with session.get(f'http://api.bilibili.com/x/web-interface/view?aid={video_id[2:]}') as resp: 149 | return await resp.json() 150 | elif video_id[:2].lower() == 'bv': 151 | async with session.get(f'http://api.bilibili.com/x/web-interface/view?bvid={video_id}') as resp: 152 | return await resp.json() 153 | return {} 154 | 155 | 156 | async def info_json_dump(obj: dict) -> VideoInfo: 157 | return VideoInfo( 158 | cover_url=obj['pic'], 159 | bvid=obj['bvid'], 160 | avid=obj['aid'], 161 | title=obj['title'], 162 | sub_count=obj['videos'], 163 | pub_timestamp=obj['pubdate'], 164 | upload_timestamp=obj['ctime'], 165 | desc=obj['desc'].strip(), 166 | duration=obj['duration'], 167 | up_mid=obj['owner']['mid'], 168 | up_name=obj['owner']['name'], 169 | up_face=obj['owner']['face'], 170 | views=obj['stat']['view'], 171 | danmu=obj['stat']['danmaku'], 172 | likes=obj['stat']['like'], 173 | coins=obj['stat']['coin'], 174 | replys=obj['stat']['reply'], 175 | favorites=obj['stat']['favorite'], 176 | ) 177 | 178 | 179 | def math(num: int): 180 | if num < 10000: 181 | return str(num) 182 | elif num < 100000000: 183 | return ('%.2f' % (num / 10000)) + '万' 184 | else: 185 | return ('%.2f' % (num / 100000000)) + '亿' 186 | 187 | 188 | template = Path(root_path, 'static', 'jinja2_templates', 'bili_video.html').read_text(encoding='utf-8') 189 | 190 | 191 | async def gen_img(data: VideoInfo) -> bytes: 192 | video_length_m, video_length_s = divmod(data.duration, 60) # 将总的秒数转换为时分秒格式 193 | video_length_h, video_length_m = divmod(video_length_m, 60) 194 | if video_length_h == 0: 195 | video_length = f'{video_length_m}:{video_length_s}' 196 | else: 197 | video_length = f'{video_length_h}:{video_length_m}:{video_length_s}' 198 | 199 | desc = data.desc.strip().replace('\n', '
') 200 | 201 | launart = Launart.current() 202 | session = launart.get_interface(AiohttpClientInterface).service.session 203 | 204 | async with session.get(f'https://api.bilibili.com/x/relation/stat?vmid={data.up_mid}') as resp: 205 | result = await resp.json() 206 | fans_num: int = result['data']['follower'] 207 | 208 | qr = QRCode(version=2, box_size=4) 209 | qr.add_data(f'https://b23.tv/{data.bvid}') 210 | qr.make() 211 | qrcode: PILImage | None = qr.make_image()._img 212 | if qrcode is None: 213 | qrcode_src = '' 214 | else: 215 | output_buffer = BytesIO() 216 | qrcode.save(output_buffer, format='png') 217 | base64_str = b64encode(output_buffer.getvalue()).decode('utf-8') 218 | qrcode_src = f'data:image/png;base64,{base64_str}' 219 | 220 | return await template2img( 221 | template, 222 | { 223 | 'cover_src': data.cover_url, 224 | 'duration': video_length, 225 | 'title': data.title, 226 | 'play_num': math(data.views), 227 | 'danmaku_num': math(data.danmu), 228 | 'reply_num': math(data.replys), 229 | 'like_num': math(data.likes), 230 | 'coin_num': math(data.coins), 231 | 'favorite_num': math(data.favorites), 232 | 'desc': desc, 233 | 'publish_time': time.strftime('%Y/%m/%d %p %I:%M:%S', time.localtime(data.upload_timestamp)), 234 | 'profile_src': data.up_face, 235 | 'name': data.up_name, 236 | 'fans_num': math(fans_num), 237 | 'qrcode_src': qrcode_src, 238 | }, 239 | extra_page_option=PageOption(viewport={'width': 960, 'height': 10}), 240 | ) 241 | -------------------------------------------------------------------------------- /modules/dont_nudge_me.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import contextlib 3 | import os 4 | from pathlib import Path 5 | from random import choice, randrange, uniform 6 | 7 | from graia.ariadne.app import Ariadne 8 | from graia.ariadne.event.mirai import NudgeEvent 9 | from graia.ariadne.exception import UnknownTarget 10 | from graia.ariadne.message.chain import MessageChain 11 | from graia.ariadne.message.element import Image, Plain 12 | from graia.ariadne.model import Friend, Group 13 | from graia.saya import Channel 14 | from graiax.shortcut.saya import decorate, listen 15 | 16 | from libs.config import basic_cfg 17 | from libs.control import require_disable 18 | from libs.control.interval import ManualInterval 19 | from static.path import data_path 20 | 21 | channel = Channel.current() 22 | 23 | channel.meta['name'] = '别戳我' 24 | channel.meta['author'] = ['Red_lnn'] 25 | channel.meta['description'] = '戳一戳bot' 26 | 27 | msg = ( 28 | '别{}啦别{}啦,无论你再怎么{},我也不会多说一句话的~', 29 | '你再{}!你再{}!你再{}试试!!', 30 | '那...那里...那里不能{}...绝对...绝对不能(小声)...', 31 | '那里不可以...', 32 | '怎么了怎么了?发生什么了?!纳尼?没事?没事你{}我干哈?', 33 | '气死我了!别{}了别{}了!再{}就坏了呜呜...┭┮﹏┭┮', 34 | '呜…别{}了…', 35 | '呜呜…受不了了', 36 | '别{}了!...把手拿开呜呜..', 37 | 'hentai!八嘎!无路赛!', 38 | '変態!バカ!うるさい!', 39 | '。', 40 | '哼哼╯^╰', 41 | ) 42 | 43 | 44 | async def get_message(event: NudgeEvent): 45 | tmp = randrange(0, len(os.listdir(Path(data_path, 'Nudge'))) + len(msg)) 46 | if tmp < len(msg): 47 | return MessageChain(Plain(msg[tmp].replace('{}', event.msg_action[0]))) 48 | if not Path(data_path, 'Nudge').exists(): 49 | Path(data_path, 'Nudge').mkdir() 50 | elif len(os.listdir(Path(data_path, 'Nudge'))) == 0: 51 | return MessageChain(Plain(choice(msg).replace('{}', event.msg_action[0]))) 52 | return MessageChain(Image(path=Path(data_path, 'Nudge', os.listdir(Path(data_path, 'Nudge'))[tmp - len(msg)]))) 53 | 54 | 55 | @listen(NudgeEvent) 56 | @decorate(require_disable(channel.module)) 57 | async def main(app: Ariadne, event: NudgeEvent): 58 | if event.target != basic_cfg.miraiApiHttp.account: 59 | return 60 | elif not ManualInterval.require(f'{event.supplicant}_{event.target}', 3): 61 | return 62 | await asyncio.sleep(uniform(0.2, 0.6)) 63 | with contextlib.suppress(UnknownTarget): 64 | await app.send_nudge(event.supplicant, event.target) 65 | await asyncio.sleep(uniform(0.2, 0.6)) 66 | if isinstance(event.subject, Group | Friend): 67 | await app.send_message(event.subject, (await get_message(event))) 68 | -------------------------------------------------------------------------------- /modules/eat_what/__init__.py: -------------------------------------------------------------------------------- 1 | import random 2 | from pathlib import Path 3 | 4 | from aiofile import async_open 5 | from graia.ariadne.app import Ariadne 6 | from graia.ariadne.event.message import GroupMessage 7 | from graia.ariadne.message.chain import MessageChain 8 | from graia.ariadne.message.element import Plain, Source 9 | from graia.ariadne.message.parser.twilight import RegexMatch, SpacePolicy, Twilight 10 | from graia.ariadne.model import Group 11 | from graia.saya import Channel 12 | from graiax.shortcut.saya import decorate, dispatch, listen 13 | 14 | from libs.control import require_disable 15 | from libs.control.permission import GroupPermission 16 | 17 | channel = Channel.current() 18 | 19 | channel.meta['name'] = '吃啥' 20 | channel.meta['author'] = ['Red_lnn'] 21 | channel.meta['description'] = '[!!.]吃啥' 22 | 23 | 24 | async def get_food(): 25 | async with async_open(Path(Path(__file__).parent, 'foods.txt')) as afp: 26 | foods = await afp.read() 27 | return random.choice(foods.strip().split('\n')) 28 | 29 | 30 | @listen(GroupMessage) 31 | @dispatch(Twilight(RegexMatch(r'[!!.]吃啥').space(SpacePolicy.NOSPACE))) 32 | @decorate(GroupPermission.require(), require_disable(channel.module)) 33 | async def main(app: Ariadne, group: Group, source: Source): 34 | food = await get_food() 35 | chain = MessageChain(Plain(f'吃{food}')) 36 | await app.send_message(group, chain, quote=source) 37 | -------------------------------------------------------------------------------- /modules/eat_what/foods.txt: -------------------------------------------------------------------------------- 1 | 酸辣土豆丝 2 | 可乐鸡翅 3 | 麻婆豆腐 4 | 红烧肉 5 | 糖醋排骨 6 | 西红柿炒鸡蛋 7 | 青椒炒肉丝 8 | 鱼香肉丝 9 | 水煮肉片 10 | 西红柿炖牛腩 11 | 菠萝咕噜肉 12 | 咖喱鸡翅 13 | 香辣牛肉 14 | 水煮鱼 15 | 酸菜鱼 16 | 虾皮鸡蛋羹 17 | 皮蛋拌豆腐 18 | 清蒸大闸蟹 19 | 微波番茄虾 20 | 麻辣鱼 21 | 宫保鸡丁 22 | 家常豆腐 23 | 皮蛋瘦肉粥 24 | 红烧冬瓜 25 | 湘式小炒五花肉 26 | 剁椒鱼头 27 | 红烧鱼块 28 | 清蒸鲈鱼 29 | 酸辣汤 30 | 秘制红焖羊肉 31 | 酱牛肉 32 | 蒜苔炒腊肉 33 | 杭椒牛柳 34 | 红烧排骨 35 | 泡菜炒年糕 36 | 西红柿鸡蛋 37 | 土豆炖牛肉 38 | 回锅肉 39 | 木须肉 40 | 水晶猪皮冻 41 | 莲藕炖排骨 42 | 鱼香茄子 43 | 地三鲜 44 | 京酱肉丝 45 | 苦瓜炒蛋 46 | 啤酒鸭 47 | 西红柿鸡蛋汤 48 | 红烧茄子 49 | 干煸豆角 50 | 糖醋鱼 51 | 冬瓜排骨汤 52 | 凉拌黄瓜 53 | 鱼头豆腐汤 54 | 银耳莲子汤 55 | 清蒸鱼 56 | 葱油饼 57 | 米汤鸡蛋羹 58 | 蚂蚁上树 59 | 干煸菜花 60 | 香辣干锅土豆片 61 | 黄豆芽炒粉条 62 | 卤牛肉 63 | 苦瓜鸡蛋 64 | 盐烤花生米 65 | 香辣虾 66 | 小鸡炖蘑菇 67 | 蛋黄小米粥 68 | 杏仁瓦片 69 | 蜜汁叉烧排骨 70 | 栗子红烧肉 71 | 酸豆角炒鸡胗 72 | 干煸四季豆 73 | 尖椒土豆丝 74 | 青椒土豆丝 75 | 蒜香排骨 76 | 香辣烤凤爪 77 | 青椒炒肉 78 | 酸萝卜老鸭汤 79 | 葱爆羊肉 80 | 虫草花豆腐汤 81 | 酸辣蕨根粉 82 | 黄豆炖猪蹄 83 | 家常葱油饼 84 | 辣子鸡 85 | 盐水毛豆 86 | 牙签肉 87 | 避风塘炒蟹 88 | 五香毛豆 89 | 腊味煲仔饭 90 | 黑芝麻糊 91 | 炸藕盒 92 | 糯米枣 93 | 肉末香菇豆腐 94 | 洋葱拌木耳 95 | 黄金玉米烙 96 | 黄瓜拌粉皮 97 | 白灼虾 98 | 梅干菜干煸豆角 99 | 小炒圆白菜 100 | 家常红烧牛肉 101 | 炸酱面 102 | 肉末酸豆角 103 | 鲜橙蒸蛋 104 | 辣椒油 105 | 南瓜蒸百合 106 | 酸辣黄瓜 107 | 家常烧茄子 108 | 麦辣鸡腿堡 109 | 板烧鸡腿堡 110 | 炸薯条 111 | 香辣鸡腿堡 112 | 炸鸡翅 113 | 炸鸡腿 114 | 琵琶腿 115 | 鸡米花 116 | 香辣鸡肉卷 117 | 鳕鱼卷 118 | 上校鸡块 119 | 骨肉相连 120 | 黄金脆薯饼 121 | 脆皮全鸡 122 | 蜜汁手扒鸡 123 | M9和牛 124 | M10和牛 125 | M11和牛 126 | M12和牛 127 | 帝皇蟹 128 | 梭子蟹 129 | 鲱鱼罐头 130 | 豆汁 131 | 臭豆腐 132 | 豆浆 133 | 小笼包 134 | 豆浆油条 135 | 热干面 136 | 烙饼 137 | 丸子汤 138 | 煎蛋 139 | 鸡蛋灌饼 140 | 三明治 141 | 茶叶蛋 142 | 饭团 143 | 绿豆汤 144 | AD钙奶 145 | 红烧肉 146 | 芝麻脆皮鸡 147 | 油炸大排 148 | 大盘鸡 149 | 烤肉拌饭 150 | 压缩饼干 151 | 水 152 | 白开水 153 | 空气 154 | 泡面 155 | 肉夹馍 156 | 辣炒菜花 157 | 红豆芋圆奶昔粥 158 | 提拉米苏布丁 159 | 麻辣烫 160 | 可爱多 161 | 鲜肉月饼 162 | 汉堡王 163 | 香菜冰激凌 164 | 鲜芋雪冰 165 | 叫妈烤鸡 166 | 脊骨肉 167 | 芋圆奶茶 168 | 牛肉面 169 | 咖啡长崎蛋糕 170 | 叉烧肉饭团 171 | 汉堡王鱼堡 172 | 菜单 173 | 番茄鱼 174 | 凉皮 175 | 麻辣小面 176 | 麻辣牛肉 177 | 煎饼果子 178 | 粽子 179 | 汤达人 180 | 达人泡面 181 | 口水鸡 182 | 大坂烧烤鸡饭 183 | 大阪烧烤鸡饭 184 | 火锅鸡 185 | 关东煮 186 | 麻辣香锅 187 | 黄瓜元气森林 188 | 调料包 189 | 照烧鸡 190 | 白猫 191 | 酸辣粉 192 | 芝士焗饭 193 | 星冰乐 194 | 二手烟 195 | 瓜子 196 | 炒牛肉 197 | 烤年糕 198 | 元宵 199 | 火锅 200 | 杨掌柜粉面菜蛋 201 | 麻辣串 202 | 烧烤配啤酒 203 | 炸鸡 204 | 麻辣兔头 205 | 羊肉面 206 | 奥利给 207 | 手撕包菜 208 | 低软壳 209 | 洋葱 210 | 洋葱外皮 211 | 肉末蒸蛋 212 | 洋葱肉 213 | 上汤娃娃菜 214 | 野兽仙贝 215 | 粽子 216 | 兔肉粽 217 | 洋葱小笼包 218 | 辣椒炒西芹 219 | 鸡脖 220 | 鸡扒 221 | 喷香滴 222 | 李姐 223 | 朵小花花 224 | 吃牛子 225 | 合味道 226 | 合味道海鲜干拌面 227 | 番茄甜辣孜然香辣酱半鸡 228 | 薯片 229 | 澳洲大龙虾 230 | 绿豆饼 231 | 腐乳饼 232 | 腐乳 233 | 冰淇淋 234 | 雪糕 235 | 阿华田 236 | 盐焗鸡 237 | 白切鸡 238 | 白云猪手 239 | 黄油蟹 240 | 叉烧 241 | 礼云子蒸蛋 242 | 响螺瘦肉靓汤 243 | 花甲粥 244 | 白切鹅 245 | 牛杂 246 | 南乳花生 247 | 猪脚姜 248 | 牛蹄 249 | 番薯粥 250 | 菠萝油 251 | 花生饼 252 | 猪骨鱼蛋面 253 | 胡椒鸡脚汤 254 | 鱼 255 | 培根 256 | 四层培根芝士牛肉汉堡 257 | 四人汉堡炸鸡薯条套餐 258 | 小丸子 259 | 紫菜包饭拼盘 260 | 甜甜圈 261 | 麦旋风 262 | 芝士炸鸡狼牙土豆 263 | 糖豆拼盘 264 | 油爆大虾 265 | 薯条 266 | 炸鸡花 267 | 薯条拼盘 268 | 汉堡热狗薯条套餐 269 | 紫菜包饭火鸡面套餐 270 | 脏脏茶 271 | 鸡盒 272 | 鸡块 273 | 寿司套餐 274 | 青椒炸虾 275 | 炸虾热狗 276 | 鸡块薯条套餐 277 | 六层牛肉芝士汉堡 278 | 培根牛肉汉堡 279 | 焦糖盐巧克力棒配煎饼 280 | 虾炒面 281 | 奥利奥雪糕夹心 282 | 三份牛肉堡 283 | 汉堡小食盒 284 | 紫菜包饭小份 285 | 通心粉 286 | 费列罗甜筒 287 | 鸡块汉堡 288 | 双人餐 289 | 西班牙油条(吉事果) 290 | 小食组合 291 | 炸虾小份 292 | 双人套餐 293 | 炸鸡薯条 294 | 甜筒尖尖 295 | 鸡排意大利面 296 | 鸡腿鸡翅拼盘 297 | 芝士牛肉汉堡和薯条牛肉汉堡 298 | 炸鸡薯条欢乐派对餐 299 | 秋游分享寿司套餐 300 | 生猛海鲜 301 | 曲奇饼干 302 | 普通汉堡 303 | 多彩小食盒 304 | 九层牛肉堡 305 | 巧克力蛋糕 306 | 恒河水泡面 307 | 腐竹炒肉 308 | 寿喜锅 309 | 炒粉利 310 | 地锅鸡 311 | 蒜爆鱼 312 | 锅贴 313 | 鸭血粉丝汤 314 | 小笼包 315 | 饺子 316 | 大杯奥利奥巧克力奶昔 317 | 吊烧资本家 318 | 龙井虾仁 319 | 东坡肉 320 | 螃蟹肉饼蒸蛋 321 | 肉丸粥 322 | 关东煮 323 | 旺仔牛奶 324 | 重庆小面 325 | 黑椒鸡扒饭 326 | 碳烤松茸 327 | 金针番茄肥牛汤 328 | 蚝油笋片杏鲍菇 329 | 凤梨肉粽 330 | 披萨 331 | 桃 🍑 332 | peach 333 | 💊 334 | 药 335 | 屁 336 | 屎 337 | 💩 338 | ?吃什么吃?别吃了 339 | 个锤子 340 | 个桃桃,不过可能会好凉凉 341 | -------------------------------------------------------------------------------- /modules/mc_wiki_searcher.py: -------------------------------------------------------------------------------- 1 | """ 2 | 搜索我的世界中文Wiki 3 | 4 | 用法:在群内发送【!wiki {关键词}】即可 5 | """ 6 | 7 | from asyncio.exceptions import TimeoutError 8 | from urllib.parse import quote 9 | 10 | from graia.amnesia.builtins.aiohttp import AiohttpClientInterface 11 | from graia.ariadne.app import Ariadne 12 | from graia.ariadne.event.message import GroupMessage 13 | from graia.ariadne.message.chain import MessageChain 14 | from graia.ariadne.message.element import Plain 15 | from graia.ariadne.message.parser.twilight import ( 16 | RegexMatch, 17 | RegexResult, 18 | SpacePolicy, 19 | Twilight, 20 | ) 21 | from graia.ariadne.model import Group 22 | from graia.saya import Channel 23 | from graiax.shortcut.saya import decorate, dispatch, listen 24 | from lxml import etree 25 | 26 | from libs.control import require_disable 27 | from libs.control.permission import GroupPermission 28 | 29 | channel = Channel.current() 30 | 31 | channel.meta['name'] = '我的世界中文Wiki搜索' 32 | channel.meta['author'] = ['Red_lnn'] 33 | channel.meta['description'] = '[!!.]wiki <要搜索的关键词>' 34 | 35 | 36 | @listen(GroupMessage) 37 | @dispatch(Twilight(RegexMatch(r'[!!.]wiki').space(SpacePolicy.FORCE), 'keyword' @ RegexMatch(r'\S+'))) 38 | @decorate(GroupPermission.require(), require_disable(channel.module)) 39 | async def main(app: Ariadne, group: Group, keyword: RegexResult, aiohttp: AiohttpClientInterface): 40 | if keyword.result is None: 41 | return 42 | key_word: str = str(keyword.result).strip() 43 | search_parm: str = quote(key_word, encoding='utf-8') 44 | 45 | bili_search_url = f'https://searchwiki.biligame.com/mc/index.php?search={search_parm}' 46 | fandom_search_url = f'https://minecraft.fandom.com/zh/index.php?search={search_parm}' 47 | 48 | bili_url = f'https://wiki.biligame.com/mc/{search_parm}' 49 | fandom_url = f'https://minecraft.fandom.com/zh/wiki/{search_parm}?variant=zh-cn' 50 | 51 | try: 52 | async with aiohttp.service.session.get(bili_url) as resp: 53 | status_code = resp.status 54 | text = await resp.text() 55 | except TimeoutError: 56 | status_code = -1 57 | 58 | match status_code: 59 | case 404: 60 | msg = MessageChain( 61 | Plain( 62 | f'Minecraft Wiki 没有名为【{key_word}】的页面,' 63 | '要继续搜索请点击下面的链接:\n' 64 | f'Bilibili 镜像: {bili_search_url}\n' 65 | f'Fandom: {fandom_search_url}' 66 | ) 67 | ) 68 | case 200: 69 | tree = etree.HTML(text) # type: ignore # 虽然这里缺少参数,但可以运行,文档示例也如此 70 | title = tree.xpath('/html/head/title')[0].text 71 | introduction_list: list = tree.xpath( 72 | '//div[contains(@id,"toc")]/preceding::p[1]/descendant-or-self::*/text()' 73 | ) 74 | introduction = ''.join(introduction_list).strip() 75 | msg = MessageChain(Plain(f'{title}\n{introduction}\nBilibili 镜像: {bili_url}\nFandom: {fandom_url}')) 76 | case _: 77 | msg = MessageChain( 78 | Plain( 79 | f'无法查询 Minecraft Wiki,错误代码:{status_code}\n' 80 | f'要继续搜索【{key_word}】请点击下面的链接:\n' 81 | f'Bilibili 镜像: {bili_search_url}\n' 82 | f'Fandom: {fandom_search_url}' 83 | ) 84 | ) 85 | 86 | await app.send_message(group, msg) 87 | -------------------------------------------------------------------------------- /modules/minecraft_ping/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Ping mc服务器 3 | 4 | 获取指定mc服务器的信息 5 | 6 | > 命令:/ping [mc服务器地址] 7 | """ 8 | 9 | import socket 10 | from dataclasses import field 11 | 12 | from graia.ariadne.app import Ariadne 13 | from graia.ariadne.event.message import GroupMessage 14 | from graia.ariadne.message.chain import MessageChain 15 | from graia.ariadne.message.element import Plain 16 | from graia.ariadne.message.parser.twilight import RegexMatch, RegexResult, Twilight 17 | from graia.ariadne.model import Group 18 | from graia.saya import Channel 19 | from graiax.shortcut.saya import decorate, dispatch, listen 20 | from kayaku import config, create 21 | from loguru import logger 22 | 23 | from libs.control import require_disable 24 | from libs.control.interval import MemberInterval 25 | from libs.control.permission import GroupPermission 26 | 27 | from .ping_client import ping 28 | from .utils import is_domain, is_ip 29 | 30 | channel = Channel.current() 31 | 32 | channel.meta['name'] = 'Ping 我的世界服务器' 33 | channel.meta['author'] = ['Red_lnn'] 34 | channel.meta['description'] = '获取指定mc服务器的信息\n用法:\n[!!.]ping {mc服务器地址}' 35 | 36 | 37 | @config(channel.module) 38 | class McServerPingConfig: 39 | servers: dict[str, str] = field(default_factory=lambda: {'123456789': 'localhost:25565'}) 40 | """指定群组的默认服务器""" 41 | 42 | 43 | ping_cfg = create(McServerPingConfig) 44 | 45 | 46 | @listen(GroupMessage) 47 | @dispatch(Twilight(RegexMatch(r'[!!.]ping'), 'ping_target' @ RegexMatch(r'\S+', optional=True))) 48 | @decorate(GroupPermission.require(), MemberInterval.require(10), require_disable(channel.module)) 49 | async def main(app: Ariadne, group: Group, ping_target: RegexResult): 50 | if ping_target.matched and ping_target.result is not None: 51 | server_address = str(ping_target.result).strip() 52 | else: 53 | if str(group.id) not in ping_cfg.servers: 54 | await app.send_message(group, MessageChain(Plain('该群组没有设置默认服务器地址'))) 55 | return 56 | server_address = ping_cfg.servers[str(group.id)] 57 | 58 | if '://' in server_address: 59 | await app.send_message(group, MessageChain(Plain('不支持带有协议前缀的地址'))) 60 | return 61 | elif '/' in server_address: 62 | await app.send_message(group, MessageChain(Plain('ping目标地址出现意外字符'))) 63 | return 64 | 65 | if is_ip(server_address): 66 | kwargs = {'ip': server_address} 67 | elif ':' in server_address: 68 | host, port = server_address.split(':', 1) 69 | if is_ip(host) or is_domain(host): 70 | if port.isdigit(): 71 | kwargs = {'url': host, 'port': int(port)} 72 | else: 73 | await app.send_message(group, MessageChain(Plain('端口号格式不正确'))) 74 | return 75 | else: 76 | await app.send_message(group, MessageChain(Plain('目标地址不是一个有效的域名或IP(不支持中文域名)'))) 77 | return 78 | elif is_domain(server_address): 79 | kwargs = {'url': server_address} 80 | else: 81 | await app.send_message(group, MessageChain(Plain('目标地址不是一个有效的域名或IP(不支持中文域名)'))) 82 | return 83 | 84 | try: 85 | ping_result = await ping(**kwargs) 86 | except ConnectionRefusedError: 87 | await app.send_message(group, MessageChain(Plain('连接被目标拒绝,该地址(及端口)可能不存在 Minecraft 服务器'))) 88 | logger.warning(f'连接被目标拒绝,该地址(及端口)可能不存在Minecraft服务器,目标地址:{server_address}') 89 | return 90 | except socket.timeout: 91 | await app.send_message(group, MessageChain(Plain('连接超时'))) 92 | logger.warning(f'连接超时,目标地址:{server_address}') 93 | return 94 | except socket.gaierror as e: 95 | await app.send_message(group, MessageChain(Plain('出错了,可能是无法解析目标地址\n' + str(e)))) 96 | logger.exception(e) 97 | return 98 | 99 | if not ping_result: 100 | await app.send_message(group, MessageChain(Plain('无法解析目标地址'))) 101 | return 102 | 103 | if ping_result['motd'] is not None and ping_result['motd'] != '': 104 | motd_list: list[str] = ping_result['motd'].split('\n') 105 | motd = f' | {motd_list[0].strip()}' 106 | if len(motd_list) == 2: 107 | motd += f'\n | {motd_list[1].strip()}' 108 | else: 109 | motd = None 110 | msg_send = f'咕咕咕!🎉\n服务器版本: [{ping_result["protocol"]}] {ping_result["version"]}\n' 111 | msg_send += f'MOTD:\n{motd}\n' if motd is not None else '' 112 | msg_send += f'延迟: {ping_result["delay"]}ms\n在线人数: {ping_result["online_player"]}/{ping_result["max_player"]}' 113 | if ping_result['online_player'] != '0' and ping_result['player_list']: 114 | players_list = ''.join(f' | {_["name"]}\n' for _ in ping_result['player_list']) 115 | if int(ping_result['online_player']) != len(ping_result['player_list']): 116 | msg_send += f'\n在线列表:\n{players_list.rstrip()}\n | ...' 117 | else: 118 | msg_send += f'\n在线列表:\n{players_list.rstrip()}' 119 | 120 | await app.send_message(group, MessageChain(Plain(msg_send))) 121 | -------------------------------------------------------------------------------- /modules/minecraft_ping/aiodns_resolver.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Literal 3 | 4 | import aiodns 5 | from aiodns.error import DNSError 6 | 7 | resolver = aiodns.DNSResolver(loop=asyncio.get_event_loop(), nameservers=['119.29.29.29']) 8 | 9 | 10 | async def dns_resolver(domain: str) -> bool | str: 11 | try: 12 | result = await resolver.query(domain, 'A') 13 | return result[0].host 14 | except DNSError: 15 | return False 16 | 17 | 18 | async def dns_resolver_srv(domain: str) -> tuple[Literal[False] | str, Literal[False] | int]: 19 | try: 20 | result = await resolver.query(f'_minecraft._tcp.{domain}', 'SRV') 21 | return result[0].host, result[0].port 22 | except DNSError: 23 | return False, False 24 | -------------------------------------------------------------------------------- /modules/minecraft_ping/ping_client.py: -------------------------------------------------------------------------------- 1 | import re 2 | import socket 3 | import struct 4 | import time 5 | 6 | import orjson 7 | from graia.ariadne.util.async_exec import io_bound 8 | 9 | from .aiodns_resolver import dns_resolver_srv 10 | 11 | 12 | class PingClient: 13 | """ 14 | 改自 https://github.com/djkcyl/ABot-Graia/blob/MAH-V2/saya/MinecraftPing/ 15 | 16 | 可能会报如下错误: 17 | 1. ConnectionRefusedError: [WinError 10061] 由于目标计算机积极拒绝,无法连接。 18 | 2. socket.timeout: timed out 19 | 3. socket.gaierror 无效的主机名 20 | """ 21 | 22 | def __init__(self, host: str = 'localhost', port: int = 25565, timeout: int = 5): 23 | self._host = host 24 | self._port = port 25 | self._timeout = timeout 26 | 27 | @staticmethod 28 | def _unpack_varint(sock): 29 | data = 0 30 | for i in range(5): 31 | ordinal = sock.recv(1) 32 | if len(ordinal) == 0: 33 | break 34 | 35 | byte = ord(ordinal) 36 | data |= (byte & 0x7F) << 7 * i 37 | 38 | if not byte & 0x80: 39 | break 40 | 41 | return data 42 | 43 | @staticmethod 44 | def _pack_varint(data): 45 | ordinal = b'' 46 | 47 | while True: 48 | byte = data & 0x7F 49 | data >>= 7 50 | ordinal += struct.pack('B', byte | (0x80 if data > 0 else 0)) 51 | 52 | if data == 0: 53 | break 54 | 55 | return ordinal 56 | 57 | def _pack_data(self, data): 58 | if isinstance(data, str): 59 | data = data.encode('utf8') 60 | return self._pack_varint(len(data)) + data 61 | elif isinstance(data, int): 62 | return struct.pack('H', data) 63 | elif isinstance(data, float): 64 | return struct.pack('Q', int(data)) 65 | else: 66 | return data 67 | 68 | def _send_data(self, sock, *args): 69 | data = b'' 70 | 71 | for arg in args: 72 | data += self._pack_data(arg) 73 | 74 | sock.send(self._pack_varint(len(data)) + data) 75 | 76 | def _read_fully(self, sock, extra_varint=False) -> bytes: 77 | packet_length = self._unpack_varint(sock) 78 | packet_id = self._unpack_varint(sock) 79 | byte = b'' 80 | 81 | if extra_varint: 82 | if packet_id > packet_length: 83 | self._unpack_varint(sock) 84 | 85 | extra_length = self._unpack_varint(sock) 86 | 87 | while len(byte) < extra_length: 88 | byte += sock.recv(extra_length) 89 | 90 | else: 91 | byte = sock.recv(packet_length) 92 | 93 | return byte 94 | 95 | @staticmethod 96 | def _format_desc(data: dict) -> str: # type: ignore 97 | if 'extra' in data: 98 | return ''.join(part['text'] for part in data['extra']) 99 | elif 'text' in data: 100 | return re.sub(r'§[0-9a-gk-r]', '', data['text']) 101 | 102 | @io_bound 103 | def get_ping(self, format_: bool = True) -> dict: 104 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: 105 | sock.settimeout(5) 106 | sock.connect((self._host, self._port)) 107 | self._send_data(sock, b'\x00\x00', self._host, self._port, b'\x01') 108 | self._send_data(sock, b'\x00') 109 | data = self._read_fully(sock, extra_varint=True) 110 | 111 | self._send_data(sock, b'\x01', time.time() * 1000) 112 | unix = self._read_fully(sock) 113 | 114 | status = orjson.loads(data.decode('utf8')) 115 | if format_: 116 | status['description'] = self._format_desc(status['description']) 117 | status['delay'] = time.time() * 1000 - struct.unpack('Q', unix)[0] 118 | return status 119 | 120 | 121 | async def ping(ip: str | None = None, url: str | None = None, port: int | None = None) -> dict: 122 | if ip is not None and url is not None: 123 | raise ValueError('Both IP and URL exist') 124 | 125 | if ip is not None: 126 | host = ip 127 | elif url is not None: 128 | host = url 129 | if port is None: # url and not port 130 | host, port = await dns_resolver_srv(url) 131 | if not host: 132 | host = url 133 | port = port or 25565 134 | else: 135 | raise ValueError('Neither IP nor URL exists') 136 | 137 | client = PingClient(host=host, port=port or 25565) 138 | stats: dict = await client.get_ping() 139 | 140 | player_list = stats['players'].get('sample') or [] 141 | return { 142 | 'version': stats['version']['name'], 143 | 'protocol': str(stats['version']['protocol']), 144 | 'motd': stats['description'], 145 | 'delay': str(round(stats['delay'], 1)), 146 | 'online_player': str(stats['players']['online']), 147 | 'max_player': str(stats['players']['max']), 148 | 'player_list': player_list, 149 | } 150 | -------------------------------------------------------------------------------- /modules/minecraft_ping/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | def is_domain(value: str) -> bool: 5 | """ 6 | Return whether given value is a valid domain. 7 | If the value is valid domain name this function returns ``True``, otherwise False 8 | :param value: domain string to validate 9 | """ 10 | pattern = re.compile( 11 | r'^(([a-zA-Z]{1})|([a-zA-Z]{1}[a-zA-Z]{1})|' 12 | r'([a-zA-Z]{1}[0-9]{1})|([0-9]{1}[a-zA-Z]{1})|' 13 | r'([a-zA-Z0-9][-_.a-zA-Z0-9]{0,61}[a-zA-Z0-9]))\.' 14 | r'([a-zA-Z]{2,13}|[a-zA-Z0-9-]{2,30}.[a-zA-Z]{2,3})$' 15 | ) 16 | return bool(pattern.match(value)) 17 | 18 | 19 | def is_ip(host: str) -> bool: 20 | return bool( 21 | re.match( 22 | r'^((2(5[0-5]|[0-4]\d))|[0-1]?\d{1,2})(\.((2(5[0-5]|[0-4]\d))|[0-1]?\d{1,2})){3}$', 23 | host, 24 | ) 25 | ) 26 | -------------------------------------------------------------------------------- /modules/msg2img.py: -------------------------------------------------------------------------------- 1 | from base64 import b64encode 2 | 3 | from graia.amnesia.builtins.aiohttp import AiohttpClientInterface 4 | from graia.ariadne.app import Ariadne 5 | from graia.ariadne.event.message import GroupMessage 6 | from graia.ariadne.message.chain import MessageChain 7 | from graia.ariadne.message.element import At, AtAll, Image, Plain, Source 8 | from graia.ariadne.message.parser.twilight import RegexMatch, Twilight 9 | from graia.ariadne.model import Group, Member 10 | from graia.saya import Channel 11 | from graiax.shortcut.interrupt import FunctionWaiter 12 | from graiax.shortcut.saya import decorate, dispatch, listen 13 | 14 | from libs.control import require_disable 15 | from libs.control.interval import GroupInterval 16 | from libs.control.permission import GroupPermission 17 | from libs.text2img import md2img 18 | 19 | channel = Channel.current() 20 | 21 | channel.meta['name'] = '消息转图片' 22 | channel.meta['author'] = ['Red_lnn'] 23 | channel.meta['description'] = '仿锤子便签样式的消息转图片,支持纯文本与图像\n用法:\n [!!.](文本转图片|消息转图片)' 24 | 25 | 26 | @listen(GroupMessage) 27 | @dispatch(Twilight(RegexMatch(r'[!!.](文本转图片|消息转图片)'))) 28 | @decorate(GroupPermission.require(), GroupInterval.require(15), require_disable(channel.module)) 29 | async def main(app: Ariadne, group: Group, member: Member, source: Source, aiohttp: AiohttpClientInterface): 30 | await app.send_message(group, MessageChain(Plain('请发送要转换的内容')), quote=source) 31 | 32 | async def waiter(waiter_group: Group, waiter_member: Member, waiter_message: MessageChain) -> MessageChain | None: 33 | if waiter_group.id == group.id and waiter_member.id == member.id: 34 | return waiter_message.include(Plain, At, Image) 35 | 36 | answer = await FunctionWaiter(waiter, [GroupMessage]).wait(timeout=10) 37 | if answer is None: 38 | await app.send_message(group, MessageChain(Plain('已超时取消')), quote=source) 39 | return 40 | 41 | if len(answer) == 0: 42 | await app.send_message(group, MessageChain(Plain('你所发送的消息的类型错误')), quote=source) 43 | return 44 | 45 | content = '' 46 | 47 | for ind, elem in enumerate(answer[:]): 48 | if type(elem) in {At, AtAll}: 49 | answer.__root__[ind] = Plain(str(elem)) 50 | for i in answer[:]: 51 | if isinstance(i, Image) and i.url: 52 | async with aiohttp.service.session.get(i.url) as resp: 53 | img = b64encode(await resp.content.read()) 54 | content += f'\n\n\n\n' 55 | else: 56 | content += str(i) 57 | 58 | if content: 59 | img_bytes = await md2img(content) 60 | await app.send_message(group, MessageChain(Image(data_bytes=img_bytes))) 61 | -------------------------------------------------------------------------------- /modules/read_and_send_msg.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from graia.ariadne.app import Ariadne 4 | from graia.ariadne.event.message import ActiveMessage, GroupMessage, MessageEvent 5 | from graia.ariadne.exception import UnknownTarget 6 | from graia.ariadne.message.chain import MessageChain 7 | from graia.ariadne.message.element import Plain, Quote 8 | from graia.ariadne.model import Group, MemberPerm 9 | from graia.saya import Channel 10 | from graiax.shortcut.saya import decorate, listen 11 | 12 | from libs.control import require_disable 13 | from libs.control.permission import GroupPermission 14 | 15 | channel = Channel.current() 16 | 17 | channel.meta['name'] = '读取/发送消息的可持久化字符串' 18 | channel.meta['author'] = ['Red_lnn'] 19 | # fmt: off 20 | channel.meta['description'] = ( 21 | '仅限群管理员使用\n' 22 | ' - 回复需要读取的消息并且回复内容只含有“[!!.]读取消息”获得消息的可持久化字符串\n' 23 | ' - [!!.]发送消息 <可持久化字符串> —— 用于从可持久化字符串发送消息' 24 | ) 25 | # fmt: on 26 | 27 | 28 | @listen(GroupMessage) 29 | @decorate(GroupPermission.require(MemberPerm.Administrator, send_alert=False), require_disable(channel.module)) 30 | async def main(app: Ariadne, group: Group, message: MessageChain, quote: Quote | None): 31 | if quote is None: 32 | return 33 | if re.match(r'^[!!.]读取消息$', str(message)): 34 | try: 35 | msg = await app.get_message_from_id(quote.id) 36 | except UnknownTarget: 37 | await app.send_message(group, MessageChain(Plain('找不到该消息,对象不存在'))) 38 | return 39 | if isinstance(msg, (MessageEvent, ActiveMessage)): 40 | chain = msg.message_chain 41 | await app.send_message(group, MessageChain(Plain(f'消息ID: {quote.id}\n消息内容:{chain.as_persistent_string()}'))) 42 | elif re.match(r'^[!!.]发送消息 .+', str(message)): 43 | if msg := re.sub(r'[!!.]发送消息 ', '', str(message), count=1): 44 | await app.send_message(group, MessageChain.from_persistent_string(msg)) 45 | -------------------------------------------------------------------------------- /modules/renpin_checker.py: -------------------------------------------------------------------------------- 1 | """ 2 | 人品测试 3 | 4 | 每个QQ号每天可随机获得一个0-100的整数(人品值),在当天内该值不会改变,该值会存放于一json文件中,每日删除过期文件 5 | 6 | 用法:[!!.#](jrrp|抽签) (jrrp 即 JinRiRenPin) 7 | """ 8 | 9 | import datetime 10 | import random 11 | import re 12 | from pathlib import Path 13 | 14 | import orjson 15 | from aiofile import async_open 16 | from graia.ariadne.app import Ariadne 17 | from graia.ariadne.event.lifecycle import ApplicationLaunched 18 | from graia.ariadne.event.message import GroupMessage 19 | from graia.ariadne.message.chain import MessageChain 20 | from graia.ariadne.message.element import At, Image, Plain 21 | from graia.ariadne.message.parser.twilight import RegexMatch, Twilight 22 | from graia.ariadne.model import Group, Member 23 | from graia.saya import Channel 24 | from graia.scheduler.saya import SchedulerSchema 25 | from graia.scheduler.timers import crontabify 26 | from graiax.shortcut.saya import decorate, dispatch, listen 27 | from loguru import logger 28 | 29 | from libs.control import require_disable 30 | from libs.control.interval import MemberInterval 31 | from libs.control.permission import GroupPermission 32 | from libs.text2img import md2img 33 | from static.path import data_path 34 | 35 | channel = Channel.current() 36 | 37 | channel.meta['name'] = '人品测试' 38 | channel.meta['author'] = ['Red_lnn'] 39 | channel.meta['description'] = '每个QQ号每天可抽一次签并获得人品值\n用法:\n [!!.]jrrp / [!!.]抽签' 40 | 41 | # https://wiki.biligame.com/ys/%E3%80%8C%E5%BE%A1%E7%A5%9E%E7%AD%BE%E3%80%8D 42 | qianwens = { 43 | '大吉': [ 44 | '会起风的日子,无论干什么都会很顺利的一天。 \n' 45 | '周围的人心情也非常愉快,绝对不会发生冲突, \n还可以吃到一直想吃,但没机会吃的美味佳肴。 \n无论是工作,还是旅行,都一定会十分顺利吧。 \n那么,应当在这样的好时辰里,一鼓作气前进…', 46 | '宝剑出匣来,无往不利。出匣之光,亦能照亮他人。 \n今日能一箭射中空中的幻翼,能一击命中敌人的胸膛。 \n若没有目标,不妨四处转转,说不定会有意外之喜。 \n同时,也不要忘记和倒霉的同伴分享一下好运气哦。', 47 | '失而复得的一天。 \n原本以为石沉大海的事情有了好的回应, \n原本分道扬镳的朋友或许可以再度和好, \n不经意间想起了原本已经忘记了的事情。 \n世界上没有什么是永远无法挽回的, \n' 48 | '今天就是能够挽回失去事物的日子。', 49 | '浮云散尽月当空,逢此签者皆为上吉。 \n明镜在心清如许,所求之事心想则成。 \n合适顺心而为的一天,不管是想做的事情, \n还是想见的人,现在是行动起来的好时机。', 50 | ], 51 | '吉': [ 52 | '浮云散尽月当空,逢此签者皆为上吉。 \n明镜在心清如许,所求之事心想则成。 \n合适顺心而为的一天,不管是想做的事情, \n还是想见的人,现在是行动起来的好时机。', 53 | '十年磨一剑,今朝示霜刃。恶运已销,身临否极泰来之时。苦练多年未能一显身手的才能,现今有了大展身手的极好机会。若是遇到阻碍之事,亦不必迷惘,大胆地拔剑,痛快地战斗一番吧。', 54 | '枯木逢春,正当万物复苏之时。 \n陷入困境时,能得到解决办法。 \n举棋不定时,会有贵人来相助。 \n可以整顿一番心情,清理一番家装, \n说不定能发现意外之财。', 55 | '明明没有什么特别的事情,却感到心情轻快的日子。 \n在没注意过的角落可以找到本以为丢失已久的东西。 \n食物比平时更加鲜美,路上的风景也令人眼前一亮。 \n——这个世界上充满了新奇的美好事物——', 56 | '一如既往的一天。身体和心灵都适应了的日常。 \n出现了能替代弄丢的东西的物品,令人很舒心。 \n和常常遇见的人关系会变好,可能会成为朋友。 \n——无论是多寻常的日子,都能成为宝贵的回忆——', 57 | ], 58 | '末吉': [ 59 | '云遮月半边,雾起更迷离 \n抬头即是浮云遮月,低头则是浓雾漫漫 \n虽然一时前路迷惘,但也会有一切明了的时刻 \n现下不如趁此机会磨炼自我,等待拨云见皎月。', 60 | '空中的云层偏低,并且仍有堆积之势, \n不知何时雷雨会骤然从头顶倾盆而下。 \n但是等雷雨过后,还会有彩虹在等着。 \n宜循于旧,守于静,若妄为则难成之。', 61 | '平稳安详的一天。没有什么令人难过的事情会发生。 \n适合和久未联系的朋友聊聊过去的事情,一同欢笑。 \n吃东西的时候会尝到很久以前体验过的过去的味道。 \n——要珍惜身边的人与事——', 62 | '气压稍微有点低,是会令人想到遥远的过去的日子。 \n早已过往的年轻岁月,与再没联系过的故友的回忆, \n会让人感到一丝平淡的怀念,又稍微有一点点感伤。 \n——偶尔怀念过去也很好。放松心情面对未来吧——', 63 | ], 64 | '凶': [ 65 | '珍惜的东西可能会遗失,需要小心。 \n如果身体有不适,一定要注意休息。 \n在做出决定之前,一定要再三思考。', 66 | '内心空落落的一天。可能会陷入深深的无力感之中。 \n很多事情都无法理清头绪,过于钻牛角尖则易生病。 \n虽然一切皆陷于低潮谷底中,但也不必因此而气馁。 \n若能撑过一时困境,他日必另有一番作为。', 67 | '隐约感觉会下雨的一天。可能会遇到不顺心的事情。 \n应该的褒奖迟迟没有到来,服务生也可能会上错菜。 \n明明没什么大不了的事,却总感觉有些心烦的日子。 \n——难免有这样的日子——', 68 | ], 69 | } 70 | 71 | lucky_things = { 72 | '吉': [ 73 | '散发暖意的「鸡蛋」。\n\n鸡蛋孕育着无限的可能性,是未来之种。 \n反过来,这个世界对鸡蛋中的生命而言, \n也充满了令其兴奋的未知事物吧。 \n要温柔对待鸡蛋喔。', 74 | '节节高升的「竹笋」。\n\n竹笋拥有着无限的潜力, \n没有人知道一颗竹笋,到底能长成多高的竹子。 \n看着竹笋,会让人不由自主期待起未来吧。', 75 | '闪闪发亮的「下界之星」。\n\n下届之星可以做成直上云霄的信标。 \n而信标是这个世界最令人憧憬的事物之一吧,他能许以天地当中人们的祝福。', 76 | '色泽艳丽的「金萝卜」。\n\n人们常说表里如一是美德, \n但金萝卜明艳的外貌下隐藏着的是谦卑而甘甜的内在。', 77 | '生长多年的「钟乳石」。\n\n脆弱的滴水石锥在无人而幽黑的洞穴中历经多年的寂寞,才能结成钟乳石。 \n为目标而努力前行的人们,最终也必将拥有胜利的果实。', 78 | '难得一见的「附魔金苹果」。\n\n附魔金苹果非常地难寻,他藏匿于废弃遗迹的杂物中, \n与傲然挺立于此世的你一定很是相配。', 79 | '活蹦乱跳的「兔兔」。\n\n兔兔是爱好和平、不愿意争斗的小生物。 \n这份追求平和的心一定能为你带来幸福吧。', 80 | '不断发热的「烈焰棒」。\n\n烈焰棒的炙热来自于烈焰人那火辣辣的心。 \n万事顺利是因为心中自有一条明路。', 81 | ], 82 | '凶': [ 83 | '暗中发亮的「发光地衣」。\n\n发光地衣努力地发出微弱的光芒。 \n虽然比不过其他光源,但看清前路也够用了。', 84 | '树上掉落的「树苗」。\n\n并不是所有的树苗都能长成粗壮的大树, \n成长需要适宜的环境,更需要一点运气。 \n所以不用给自己过多压力,耐心等待彩虹吧。', 85 | '黯淡无光的「火药」。\n\n火药蕴含着无限的能量。 \n如果能够好好导引这股能量,说不定就能成就什么事业。', 86 | '随风飞翔的「蒲公英」。\n\n只要有草木生长的空间,就一定有蒲公英。 \n这么看来,蒲公英是世界上最强韧的生灵。 \n据说连坑坑洼洼的沼泽也能长出蒲公英呢。', 87 | '冰凉冰凉的「蓝冰」。\n\n冰山上的蓝冰散发着「生人勿进」的寒气。 \n但有时冰冷的气质,也能让人的心情与头脑冷静下来。 \n据此采取正确的判断,明智地行动。', 88 | '随波摇曳的「海草」。\n\n海草是相当温柔而坚强的植物, \n即使在苦涩的海水中,也不愿改变自己。 \n即使在逆境中,也不要放弃温柔的心灵。', 89 | '半丝半缕的「线」。\n\n有一些线特别细,细得轻轻一碰就会断开。 \n若是遇到无法整理的情绪,那么该断则断吧。', 90 | ], 91 | } 92 | 93 | 94 | @listen(GroupMessage) 95 | @dispatch(Twilight(RegexMatch(r'[!!.](jrrp|抽签)'))) 96 | @decorate(GroupPermission.require(), MemberInterval.require(10), require_disable(channel.module)) 97 | async def main(app: Ariadne, group: Group, member: Member): 98 | is_new, renpin, qianwen = await read_data(str(member.id)) 99 | img_bytes = await md2img(f'{qianwen}\n\n---\n悄悄告诉你噢,你今天的人品值是:{renpin}') 100 | if is_new: 101 | await app.send_message(group, MessageChain(At(member.id), Plain(' 你抽到一支签:'), Image(data_bytes=img_bytes))) 102 | else: 103 | await app.send_message( 104 | group, 105 | MessageChain( 106 | At(member.id), Plain(' 你今天已经抽到过一支签了,你没有好好保管吗?这样吧,再告诉你一次好了,你抽到的签是:'), Image(data_bytes=img_bytes) 107 | ), 108 | ) 109 | 110 | 111 | @channel.use(SchedulerSchema(crontabify('0 0 * * *'))) 112 | async def scheduled_del_outdated_data() -> None: 113 | """ 114 | 定时删除过时的数据文件 115 | """ 116 | for _ in data_path.iterdir(): 117 | if ( 118 | re.search('jrrp_20[0-9]{2}-[0-9]{2}-[0-9]{2}.json', str(_)) 119 | and _.parts[-1] != f'jrrp_{datetime.datetime.now().strftime("%Y-%m-%d")}.json' 120 | ): 121 | Path(data_path, _).unlink() 122 | logger.info(f'发现过期的数据文件 {_},已删除') 123 | 124 | 125 | @listen(ApplicationLaunched) 126 | async def del_outdated_data() -> None: 127 | """ 128 | 在bot启动时删除过时的数据文件 129 | """ 130 | for _ in data_path.iterdir(): 131 | if ( 132 | re.search('jrrp_20[0-9]{2}-[0-9]{2}-[0-9]{2}.json', str(_)) 133 | and _.parts[-1] != f'jrrp_{datetime.datetime.now().strftime("%Y-%m-%d")}.json' 134 | ): 135 | Path(data_path, _).unlink() 136 | logger.info(f'发现过期的数据文件 {_},已删除') 137 | 138 | 139 | def chouqian(renpin: int) -> str: 140 | if renpin >= 90: 141 | return '大吉' 142 | elif renpin >= 75: 143 | return '中吉' 144 | elif renpin >= 55: 145 | return '吉' 146 | elif renpin >= 30: 147 | return '末吉' 148 | elif renpin >= 10: 149 | return '凶' 150 | else: 151 | return '大凶' 152 | 153 | 154 | def gen_qianwen(renpin: int) -> str: 155 | match chouqian(renpin): 156 | case '大吉': 157 | return f'——大吉——\n\n{random.choice(qianwens["大吉"])}\n\n今天的幸运物是:{random.choice(lucky_things["吉"])}' 158 | case '中吉': 159 | return f'——中吉——\n\n{random.choice(qianwens["吉"])}\n\n今天的幸运物是:{random.choice(lucky_things["吉"])}' 160 | case '吉': 161 | return f'——吉——\n\n{random.choice(qianwens["吉"])}\n\n今天的幸运物是:{random.choice(lucky_things["吉"])}' 162 | case '末吉': 163 | return f'——末吉——\n\n{random.choice(qianwens["末吉"])}\n\n今天的幸运物是:{random.choice(lucky_things["凶"])}' 164 | case '凶': 165 | return f'——凶——\n\n{random.choice(qianwens["凶"])}\n\n今天的幸运物是:{random.choice(lucky_things["凶"])}' 166 | case '大凶': 167 | return f'——大凶——\n\n{random.choice(qianwens["凶"])}\n\n今天的幸运物是:{random.choice(lucky_things["凶"])}' 168 | case _: 169 | return '' 170 | 171 | 172 | async def read_data(qq: str) -> tuple[bool, int, str]: 173 | """ 174 | 在文件中读取指定QQ今日已生成过的随机数,若今日未生成,则新生成一个随机数并写入文件 175 | """ 176 | data_file_path = Path(data_path, f'jrrp_{datetime.datetime.now().strftime("%Y-%m-%d")}.json') 177 | try: 178 | async with async_open(data_file_path, 'r', encoding='utf-8') as afp: # 以 追加+读 的方式打开文件 179 | f_data = await afp.read() 180 | data = orjson.loads(f_data) if len(f_data) > 0 else {} 181 | except FileNotFoundError: 182 | data = {} 183 | if qq in data: 184 | return False, data[qq]['renpin'], data[qq]['qianwen'] 185 | renpin = random.randint(0, 100) 186 | qianwen = gen_qianwen(renpin) 187 | data[qq] = {'renpin': renpin, 'qianwen': qianwen} 188 | async with async_open(data_file_path, 'wb') as afp: 189 | await afp.write(orjson.dumps(data, option=orjson.OPT_INDENT_2 | orjson.OPT_APPEND_NEWLINE)) 190 | return True, renpin, qianwen 191 | -------------------------------------------------------------------------------- /modules/roll.py: -------------------------------------------------------------------------------- 1 | """ 2 | 用法: 3 | 4 | 在本Bot账号所在的任一一QQ群中发送 `!roll` 或 `!roll {任意字符}` 均可触发本插件功能 5 | 触发后会回复一个由0至100之间的任一随机整数 6 | """ 7 | 8 | from random import randint 9 | 10 | from graia.ariadne.app import Ariadne 11 | from graia.ariadne.event.message import GroupMessage 12 | from graia.ariadne.message.chain import MessageChain 13 | from graia.ariadne.message.element import Dice, Plain, Source 14 | from graia.ariadne.message.parser.twilight import ( 15 | RegexMatch, 16 | RegexResult, 17 | Twilight, 18 | WildcardMatch, 19 | ) 20 | from graia.ariadne.model import Group 21 | from graia.saya import Channel 22 | from graiax.shortcut.saya import decorate, dispatch, listen 23 | 24 | from libs.control import require_disable 25 | from libs.control.permission import GroupPermission 26 | 27 | channel = Channel.current() 28 | 29 | channel.meta['name'] = '随机数' 30 | channel.meta['author'] = ['Red_lnn'] 31 | channel.meta['description'] = '获得一个随机数\n用法:\n [!!.]roll {要roll的事件}\n [!!.](dice|骰子|色子)' 32 | 33 | 34 | @listen(GroupMessage) 35 | @dispatch(Twilight(RegexMatch(r'[!!.]roll'), 'target' @ WildcardMatch())) 36 | @decorate(GroupPermission.require(), require_disable(channel.module)) 37 | async def roll(app: Ariadne, group: Group, source: Source, target: RegexResult): 38 | if target.result is None: 39 | return 40 | t = str(target.result).strip() 41 | chain = MessageChain(Plain(f'{t}的概率为:{randint(0, 100)}')) if t else MessageChain(Plain(str(randint(0, 100)))) 42 | await app.send_message(group, chain, quote=source) 43 | 44 | 45 | @listen(GroupMessage) 46 | @dispatch(Twilight(RegexMatch(r'[!!.](dice|骰子|色子)'))) 47 | @decorate(GroupPermission.require(), require_disable(channel.module)) 48 | async def dice(app: Ariadne, group: Group): 49 | await app.send_message(group, MessageChain(Dice(randint(1, 6)))) 50 | -------------------------------------------------------------------------------- /modules/the_wondering_earth_counting_down.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | from re import RegexFlag 3 | 4 | from graia.ariadne.app import Ariadne 5 | from graia.ariadne.event.message import GroupMessage 6 | from graia.ariadne.message.chain import MessageChain 7 | from graia.ariadne.message.element import Image 8 | from graia.ariadne.message.parser.twilight import ( 9 | ArgResult, 10 | ArgumentMatch, 11 | RegexMatch, 12 | RegexResult, 13 | Twilight, 14 | WildcardMatch, 15 | ) 16 | from graia.ariadne.model import Group 17 | from graia.ariadne.util.async_exec import io_bound 18 | from graia.saya import Channel 19 | from graiax.shortcut.saya import decorate, dispatch, listen 20 | from PIL import Image as PILImage 21 | from PIL import ImageDraw, ImageFilter, ImageFont 22 | from PIL.ImageFont import FreeTypeFont 23 | 24 | from libs.control import require_disable 25 | from libs.control.permission import GroupPermission 26 | from static.path import lib_path 27 | 28 | channel = Channel.current() 29 | 30 | channel.meta['name'] = '流浪地球2倒计时生成' 31 | channel.meta['author'] = ['Red_lnn'] 32 | channel.meta['description'] = ( 33 | '小破球2倒计时生成\n\n用法:\n' 34 | '[.!!]倒计时 距月球危机\n' 35 | '还剩 16 秒\n' 36 | 'THE LUNAR CRISIS\n' 37 | 'IN 16 SECONDS\n\n参数:\n' 38 | ' --dark, -d, -D 黑暗模式\n' 39 | ' --gif, -g, -G GIF图(1fps)\n' 40 | ) 41 | 42 | 43 | @listen(GroupMessage) 44 | @dispatch( 45 | Twilight( 46 | RegexMatch('[.!!]倒计时'), 47 | 'target' @ WildcardMatch().flags(RegexFlag.DOTALL), 48 | 'gif' @ ArgumentMatch('--gif', '-g', '-G', action="store_true"), 49 | 'dark' @ ArgumentMatch('--dark', '-d', '-D', action="store_true"), 50 | ) 51 | ) 52 | @decorate( 53 | GroupPermission.require(), 54 | require_disable(channel.module), 55 | require_disable('core_modules.msg_loger'), 56 | ) 57 | async def main( 58 | app: Ariadne, 59 | group: Group, 60 | target: RegexResult, 61 | gif: ArgResult[bool], 62 | dark: ArgResult[bool], 63 | ): 64 | if target.result is None: 65 | raise ValueError('输入格式不正确') 66 | 67 | try: 68 | cn_1, text2, en_1, en_2 = str(target.result).split('\n') 69 | prefix, number, suffix = text2.split(' ') 70 | except ValueError: 71 | await app.send_message( 72 | group, MessageChain('格式错误,注意空格和换行,参考:\n.倒计时 距月球危机\n还剩 16 秒\nTHE LUNAR CRISIS\nIN 16 SECONDS') 73 | ) 74 | return 75 | 76 | cn_font = ImageFont.truetype(str(lib_path / 'fonts' / 'HarmonyOS_Sans_SC_Bold.ttf'), 100) 77 | number_font = ImageFont.truetype(str(lib_path / 'fonts' / 'HarmonyOS_Sans_SC_Bold.ttf'), 285) 78 | en_font = ImageFont.truetype(str(lib_path / 'fonts' / 'HarmonyOS_Sans_SC_Bold.ttf'), 45) 79 | font_color = '#eee' if dark.result else '#2c2c2c' 80 | bg_color = '#2c2c2c' if dark.result else '#eeeeee' 81 | 82 | if gif.result: 83 | frames: list[PILImage.Image] = [] 84 | origin_num_len = len(number) 85 | for _ in range(int(number), 0, -1): 86 | current_num = str(_) 87 | current_num_len = len(current_num) 88 | num = ( 89 | # 下面写死了2个空格,等宽字体应用1个空格 90 | " " * 2 * (origin_num_len - current_num_len) + current_num 91 | if current_num_len < origin_num_len 92 | else current_num 93 | ) 94 | frames.append( 95 | await the_wondering_earth_counting_down( 96 | cn_1, prefix, num, suffix, en_1, en_2, cn_font, number_font, en_font, font_color, bg_color 97 | ) 98 | ) 99 | with BytesIO() as f: 100 | frames[0].save( 101 | f, 102 | format='GIF', 103 | save_all=True, 104 | append_images=frames[1:], 105 | duration=1000, 106 | optimize=True, 107 | loop=0, 108 | ) 109 | await app.send_message(group, MessageChain(Image(data_bytes=f.getvalue()))) 110 | else: 111 | with BytesIO() as f: 112 | im = ( 113 | await the_wondering_earth_counting_down( 114 | cn_1, prefix, number, suffix, en_1, en_2, cn_font, number_font, en_font, font_color, bg_color 115 | ) 116 | ).convert('RGB') 117 | im.save(f, format='JPEG', quality=90, optimize=True, progressive=True, subsampling=2, qtables='web_high') 118 | await app.send_message(group, MessageChain(Image(data_bytes=f.getvalue()))) 119 | 120 | 121 | def get_box(text: str, font: FreeTypeFont): 122 | bbox = font.getbbox(text) 123 | return bbox[0], -bbox[1], bbox[2], bbox[3] - bbox[1] # startX, startY, width, height 124 | 125 | 126 | @io_bound 127 | def the_wondering_earth_counting_down( 128 | cn_1: str, 129 | prefix: str, 130 | number: str, 131 | suffix: str, 132 | en_1: str, 133 | en_2: str, 134 | cn_font: FreeTypeFont, 135 | number_font: FreeTypeFont, 136 | en_font: FreeTypeFont, 137 | font_color: str, 138 | bg_color: str, 139 | ) -> PILImage.Image: 140 | """流浪地球倒计时生成 141 | 142 | Args: 143 | text (str): 倒计时文本,格式:'距月球危机\n还剩 16 秒\nTHE LUNAR CRISIS\nIN 16 SECONDS' 144 | 145 | Returns: 146 | PILImage.Image: 图片生成结果的PILImage 147 | """ 148 | 149 | en_1 = en_1.upper() 150 | en_2 = en_2.upper() 151 | 152 | box1 = get_box(cn_1, cn_font) 153 | box_prefix = get_box(prefix, cn_font) 154 | box_number = get_box(number, number_font) 155 | box_suffix = get_box(suffix, cn_font) 156 | box3 = get_box(en_1, en_font) 157 | box4 = get_box(en_2, en_font) 158 | 159 | MARGIN_TOP = 150 160 | MARGIN_LEFT = 125 161 | CN_ROW_SPACE = 30 162 | EN_ROW_SPACE = 20 163 | 164 | width = ( 165 | 2 * MARGIN_LEFT 166 | + box1[0] 167 | + box1[2] 168 | + max(box3[2] - box_prefix[2], box4[2] - box_prefix[2], box_number[2] + box_suffix[2]) 169 | ) 170 | height = ( 171 | 2 * MARGIN_TOP 172 | + box1[1] 173 | + box1[3] 174 | + box_prefix[3] 175 | - 2 * box_prefix[1] 176 | + box3[3] 177 | + box4[3] 178 | + 2 * CN_ROW_SPACE 179 | + EN_ROW_SPACE 180 | ) 181 | 182 | im = PILImage.new('RGBA', (width, height), f'{bg_color}00') 183 | draw = ImageDraw.Draw(im, 'RGBA') 184 | 185 | draw.text((MARGIN_LEFT + box1[0], MARGIN_TOP + box1[1]), cn_1, font_color, cn_font) 186 | draw.text( 187 | (MARGIN_LEFT + box1[2] - box_prefix[0] - box_prefix[2], MARGIN_TOP + box1[1] + box1[3] + CN_ROW_SPACE), 188 | prefix, 189 | font_color, 190 | cn_font, 191 | ) 192 | draw.text( 193 | (MARGIN_LEFT + box1[2] - box_prefix[0], MARGIN_TOP + box1[1] + box1[3] + box_prefix[3] + CN_ROW_SPACE), 194 | number, 195 | '#d03440', 196 | number_font, 197 | anchor='lb', 198 | ) 199 | draw.text( 200 | (MARGIN_LEFT + box1[2] - box_prefix[0] + box_number[2], MARGIN_TOP + box1[1] + box1[3] + CN_ROW_SPACE), 201 | suffix, 202 | font_color, 203 | cn_font, 204 | ) 205 | draw.text( 206 | ( 207 | MARGIN_LEFT + box1[2] - box_prefix[0] - box_prefix[2], 208 | MARGIN_TOP + box1[1] + box1[3] + box_prefix[3] + 2 * CN_ROW_SPACE, 209 | ), 210 | en_1, 211 | font_color, 212 | en_font, 213 | ) 214 | draw.text( 215 | ( 216 | MARGIN_LEFT + box1[2] - box_prefix[0] - box_prefix[2], 217 | MARGIN_TOP + box1[1] + box1[3] + box_prefix[3] + box3[3] + 2 * CN_ROW_SPACE + EN_ROW_SPACE, 218 | ), 219 | en_2, 220 | font_color, 221 | en_font, 222 | ) 223 | draw.rectangle( 224 | ( 225 | MARGIN_LEFT + box1[2] - box_prefix[0] - box_prefix[2] - 25, 226 | MARGIN_TOP + box1[1] + box1[3] + CN_ROW_SPACE, 227 | MARGIN_LEFT + box1[2] - box_prefix[0] - box_prefix[2] - 15, 228 | MARGIN_TOP 229 | + box1[1] 230 | + box1[3] 231 | + box_prefix[3] 232 | - 2 * box_prefix[1] 233 | + box3[3] 234 | + box4[3] 235 | + 2 * CN_ROW_SPACE 236 | + EN_ROW_SPACE, 237 | ), 238 | '#d03440', 239 | ) 240 | 241 | im_new = PILImage.new('RGBA', (width, height), f'{bg_color}ff') 242 | im_new.paste(im, (0, 0), mask=im.split()[3]) 243 | im_new = im_new.filter(ImageFilter.GaussianBlur(5)) 244 | 245 | # x, y = im_new.size 246 | # for i, k in itertools.product(range(x), range(y)): 247 | # color = im_new.getpixel((i, k)) 248 | # color = color[:-1] + (int(0.4 * color[-1]),) 249 | # im_new.putpixel((i, k), color) 250 | im_new = PILImage.blend(im_new, PILImage.new('RGBA', (width, height), f'{bg_color}00'), 0.33) 251 | 252 | im_result = PILImage.new('RGBA', (width, height), f'{bg_color}ff') 253 | im_result = PILImage.alpha_composite(im_result, im_new) 254 | im_result = PILImage.alpha_composite(im_result, im) 255 | 256 | # im_result.show() 257 | return im_result 258 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "RedBot" 3 | version = "0.1.0" 4 | description = "一个使用 Graia Ariadne 搭建的 QQ 机器人" 5 | authors = [ 6 | { name = "Redlnn", email = "w731347477@gmail.com" }, 7 | ] 8 | dependencies = [ 9 | "aiofile>=3.8.7", 10 | "aiohttp[speedups]>=3.8.5", 11 | "asyncmy>=0.2.8", 12 | "aiosqlite>=0.19.0", 13 | "graia-ariadne[full]>=0.11.7", 14 | "graiax-fastapi>=0.3.0", 15 | "graiax-playwright>=0.2.7", 16 | "graiax-shortcut>=0.3.0", 17 | "graiax-text2img-playwright>=0.4.2", 18 | "lxml>=4.9.3", 19 | "orjson>=3.9.5", 20 | "pillow>=10.0.0", 21 | "psutil>=5.9.5", 22 | "qrcode>=7.4.2", 23 | "sqlalchemy[asyncio]>=2.0.20", 24 | "uvicorn[standard]>=0.23.2", 25 | "kayaku>=0.5.2", 26 | ] 27 | requires-python = ">=3.10,<4.0" 28 | license = { text = "AGPL-3.0-only" } 29 | readme = "README.md" 30 | 31 | [project.urls] 32 | repository = "https://github.com/Redlnn/redbot" 33 | 34 | [build-system] 35 | requires = ["pdm-pep517>=1.0.0"] 36 | build-backend = "pdm.pep517.api" 37 | 38 | [tool.black] 39 | line-length = 120 40 | target-version = ["py310"] 41 | skip-string-normalization = true 42 | safe = true 43 | 44 | [tool.isort] 45 | profile = "black" 46 | 47 | [tool.pdm] 48 | [tool.pdm.dev-dependencies] 49 | dev = [ 50 | "isort>=5.12.0", 51 | "black>=23.7.0", 52 | "flake8>=6.1.0", 53 | "pip>=23.2.1", 54 | "wheel>=0.41.1", 55 | "setuptools>=68.1.0", 56 | ] 57 | -------------------------------------------------------------------------------- /static/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Redlnn/redbot/8b13a1ff7a56b46539b81d62fdbff8a32970399a/static/__init__.py -------------------------------------------------------------------------------- /static/fonts/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Redlnn/redbot/8b13a1ff7a56b46539b81d62fdbff8a32970399a/static/fonts/.gitkeep -------------------------------------------------------------------------------- /static/jinja2_templates/bili_video.html: -------------------------------------------------------------------------------- 1 |
你感兴趣的视频都在B站
{{ duration }}

{{ title }}

{{ play_num }}播放 · {{ danmaku_num }}弹幕 · {{ reply_num }}评论
{{ like_num }}点赞 · {{ coin_num }}投币 · {{ favorite_num }}收藏

{{ desc }}

发布于{{ publish_time }}

9 | -------------------------------------------------------------------------------- /static/path.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | root_path: Path = Path(__file__).parent.parent.resolve() 4 | logs_path: Path = Path(root_path, 'logs') 5 | modules_path: Path = Path(root_path, 'modules') 6 | data_path: Path = Path(root_path, 'data') 7 | lib_path: Path = Path(root_path, 'static') 8 | 9 | logs_path.mkdir(parents=True, exist_ok=True) 10 | modules_path.mkdir(parents=True, exist_ok=True) 11 | data_path.mkdir(parents=True, exist_ok=True) 12 | lib_path.mkdir(parents=True, exist_ok=True) 13 | --------------------------------------------------------------------------------