├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── config.py ├── plugins ├── __init__.py ├── _utils │ ├── __init__.py │ ├── alias.py │ └── register.py ├── bili_dynamic │ ├── README.md │ ├── __init__.py │ ├── connection.py │ └── register.py ├── bili_extractor │ ├── README.md │ └── __init__.py ├── find_living │ ├── README.md │ ├── __init__.py │ └── run.py ├── help │ ├── README.md │ └── __init__.py ├── live_monitor │ ├── README.md │ ├── __init__.py │ ├── channels │ │ ├── __init__.py │ │ ├── base.py │ │ ├── bili.py │ │ ├── cc.py │ │ └── youtube.py │ ├── enums.py │ └── monitor.py ├── pic_finder │ ├── README.md │ └── __init__.py ├── random_picture │ ├── __init__.py │ └── sources │ │ ├── __init__.py │ │ ├── base.py │ │ └── cat.py ├── revoke │ ├── README.md │ └── __init__.py └── setu │ ├── README.md │ ├── SetuData.py │ └── __init__.py ├── requirements.txt └── run.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | 3 | .idea/ 4 | test/ 5 | data/ 6 | config_private.py 7 | mypy.ini 8 | 9 | ### VirtualEnv template 10 | # Virtualenv 11 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 12 | .Python 13 | [Bb]in 14 | [Ii]nclude 15 | [Ll]ib 16 | [Ll]ib64 17 | [Ll]ocal 18 | [Ss]cripts 19 | pyvenv.cfg 20 | .venv 21 | pip-selfcheck.json 22 | 23 | ### Python template 24 | # Byte-compiled / optimized / DLL files 25 | __pycache__/ 26 | *.py[cod] 27 | *$py.class 28 | 29 | # C extensions 30 | *.so 31 | 32 | # Distribution / packaging 33 | develop-eggs/ 34 | dist/ 35 | downloads/ 36 | eggs/ 37 | .eggs/ 38 | lib/ 39 | lib64/ 40 | parts/ 41 | sdist/ 42 | var/ 43 | wheels/ 44 | pip-wheel-metadata/ 45 | share/python-wheels/ 46 | *.egg-info/ 47 | .installed.cfg 48 | *.egg 49 | MANIFEST 50 | 51 | # PyInstaller 52 | # Usually these files are written by a python script from a template 53 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 54 | *.manifest 55 | *.spec 56 | 57 | # Installer logs 58 | pip-log.txt 59 | pip-delete-this-directory.txt 60 | 61 | # Unit test / coverage reports 62 | htmlcov/ 63 | .tox/ 64 | .nox/ 65 | .coverage 66 | .coverage.* 67 | .cache 68 | nosetests.xml 69 | coverage.xml 70 | *.cover 71 | *.py,cover 72 | .hypothesis/ 73 | .pytest_cache/ 74 | cover/ 75 | 76 | # Translations 77 | *.mo 78 | *.pot 79 | 80 | # Django stuff: 81 | *.log 82 | local_settings.py 83 | db.sqlite3 84 | db.sqlite3-journal 85 | 86 | # Flask stuff: 87 | instance/ 88 | .webassets-cache 89 | 90 | # Scrapy stuff: 91 | .scrapy 92 | 93 | # Sphinx documentation 94 | docs/_build/ 95 | 96 | # PyBuilder 97 | .pybuilder/ 98 | target/ 99 | 100 | # Jupyter Notebook 101 | .ipynb_checkpoints 102 | 103 | # IPython 104 | profile_default/ 105 | ipython_config.py 106 | 107 | # pyenv 108 | # For a library or package, you might want to ignore these files since the code is 109 | # intended to run in multiple environments; otherwise, check them in: 110 | # .python-version 111 | 112 | # pipenv 113 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 114 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 115 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 116 | # install all needed dependencies. 117 | #Pipfile.lock 118 | 119 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 120 | __pypackages__/ 121 | 122 | # Celery stuff 123 | celerybeat-schedule 124 | celerybeat.pid 125 | 126 | # SageMath parsed files 127 | *.sage.py 128 | 129 | # Environments 130 | .env 131 | env/ 132 | venv/ 133 | ENV/ 134 | env.bak/ 135 | venv.bak/ 136 | 137 | # Spyder project settings 138 | .spyderproject 139 | .spyproject 140 | 141 | # Rope project settings 142 | .ropeproject 143 | 144 | # mkdocs documentation 145 | /site 146 | 147 | # mypy 148 | .mypy_cache/ 149 | .dmypy.json 150 | dmypy.json 151 | 152 | # Pyre type checker 153 | .pyre/ 154 | 155 | # pytype static type analyzer 156 | .pytype/ 157 | 158 | # Cython debug symbols 159 | cython_debug/ 160 | 161 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## 0.7.0 - 2020-09-12 10 | ### Added 11 | - 新功能:随机猫猫图片 12 | 13 | ### Changed 14 | - 查找B限:跟随B站分区修改 15 | 16 | ## 0.6.7 - 2020-07-10 17 | ### Added 18 | - 新功能:使用说明 19 | 20 | ### Changed 21 | - 直播监控:用户交互 22 | 23 | ## 0.6.6 - 2020-06-24 24 | ### Changed 25 | - 直播监控:youtube频道信息解析 26 | 27 | ## 0.6.5 - 2020-06-23 28 | ### Added 29 | - 直播监控:网易cc获取封面 30 | - 直播监控:异常处理 31 | 32 | ### Fixed 33 | - 动态监控:修复一处函数调用错误 34 | 35 | ### Changed 36 | - 直播监控:网易cc,youtube频道信息解析 37 | 38 | ## 0.6.4 - 2020-06-19 39 | ### Fixed 40 | - 直播监控:修复无法发送不带图片的消息 41 | - 动态监控:避免重复上传图片 42 | 43 | ### Changed 44 | - 色图:更新文档 45 | 46 | ## 0.6.3 - 2020-06-16 47 | ### Changed 48 | - 直播监控:含空格的频道名现在可以用其中任何一部分匹配 49 | 50 | ### Fixed 51 | - 直播监控:输出格式修正 52 | - 直播监控:移除命令不能正常工作 53 | 54 | ## 0.6.2 - 2020-06-15 55 | ### Changed 56 | - 直播监控:极大改进了开播判断逻辑 57 | 58 | ## 0.6.1 - 2020-06-14 59 | ### Added 60 | - 更新日志 61 | - 功能:查找B限 62 | 63 | ### Fixed 64 | - 直播监控:开播逻辑判断 65 | 66 | ## 0.6.0 - 2020-06-13 67 | ### Added 68 | - 功能:直播监控 69 | 70 | ## 0.1.0 - 2020-05-20 71 | ### Added 72 | - 初始发布 73 | - 项目框架 74 | - 功能:色图 75 | 76 | 77 | [Unreleased]: https://github.com/Lycreal/mirai_bot/compare/master...dev -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:slim 2 | 3 | ADD requirements.txt /root/ 4 | 5 | RUN pip3 install --no-cache-dir -r /root/requirements.txt && \ 6 | rm /root/requirements.txt 7 | -------------------------------------------------------------------------------- /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 | 663 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | MiraiBot 2 | ------------- 3 | 4 | 5 | ### 简介 6 | 7 | 基于 [mirai](https://github.com/mamoe/mirai) 内核和 [python-mirai](https://github.com/GreyElaina/python-mirai) 接口的QQ机器人。 8 | 9 | Also see: [Lycreal/cqbot](https://github.com/Lycreal/cqbot) 10 | 11 | ### 功能 12 | 13 | - [使用说明](plugins/help) 14 | - [搜图](plugins/pic_finder) 15 | - [撤回](plugins/revoke) 16 | - [bilibili 动态监控](plugins/bili_dynamic) 17 | - [bilibili 小程序解析](plugins/bili_extractor) 18 | - [直播监控](plugins/live_monitor) 19 | - [随机图片](plugins/random_picture) 20 | 21 | ### 部署 22 | (建议在虚拟环境中运行python) 23 | 24 | 0. 运行 [mirai-console](https://github.com/mamoe/mirai-console) 并安装 [mirai-api-http](https://github.com/mamoe/mirai-api-http) 插件。 25 | 1. `git clone https://github.com/Lycreal/MiraiBot && cd MiraiBot` 26 | 2. `pip3 install -r requirements.txt` 27 | 3. \[可选\] 在项目根目录创建文件 `config_private.py` 并编辑(详见 `config.py` ) 28 | 4. `python3 run.py "mirai://localhost:8080/ws?authKey=&qq="` 29 | 30 | ### 经过测试的运行环境 31 | [bugfix-2021](https://github.com/Lycreal/python-mirai/tree/bugfix-2021) 32 | ```plain 33 | mirai-core-all-2.5.0 34 | mirai-console-2.5.0 35 | mirai-console-terminal-2.5.0 36 | mirai-api-http-v1.10.0 37 | ``` 38 | [kuriyama==0.3.6](https://github.com/Lycreal/python-mirai/tree/master) 39 | ```plain 40 | mirai-core-1.0.2 41 | mirai-core-qqandroid-1.1.3 42 | mirai-console-0.5.2 43 | mirai-api-http-v1.7.2 44 | ``` 45 | ### 相关项目 46 | 47 | - [mirai](https://github.com/mamoe/mirai) 48 | - [mirai-console](https://github.com/mamoe/mirai-console) 49 | - [mirai-api-http](https://github.com/mamoe/mirai-api-http) 50 | - [python-mirai](https://github.com/GreyElaina/python-mirai) 51 | 52 | ## License 53 | [GNU AGPLv3](LICENSE) 54 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | __all__ = [ 4 | 'data_path', 5 | 'setu_apikey', 'setu_proxy', 'setu_r18' 6 | ] 7 | 8 | data_path = os.path.join(os.path.dirname(__file__), 'data') 9 | 10 | # 在根目录下创建 config_private.py , 写入以下内容并按需修改 11 | # ============= START ============= 12 | 13 | setu_apikey: str = '' 14 | setu_r18: str = '0' 15 | setu_proxy: str = 'disable' 16 | 17 | # ============== END ============== 18 | 19 | try: 20 | from config_private import * 21 | except ImportError: 22 | pass 23 | -------------------------------------------------------------------------------- /plugins/__init__.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | from pathlib import Path 3 | from mirai import Mirai 4 | from mirai.logger import Session as SessionLogger 5 | 6 | 7 | def load_plugins(app: Mirai, *debug: str): 8 | plugin_dir = Path(__file__).parent 9 | module_prefix = plugin_dir.name 10 | 11 | if debug: 12 | [load_plugin(app, f'{module_prefix}.{p}') for p in debug] 13 | return 14 | for plugin in plugin_dir.iterdir(): 15 | if plugin.is_dir() \ 16 | and not plugin.name.startswith('_') \ 17 | and plugin.joinpath('__init__.py').exists(): 18 | load_plugin(app, f'{module_prefix}.{plugin.name}') 19 | 20 | 21 | # noinspection PyUnusedLocal 22 | def load_plugin(app: Mirai, module_path: str): 23 | try: 24 | module = importlib.import_module(module_path) 25 | # 无需调用app.include_others(),否则会导致重复注册事件,原理不明 26 | # app.include_others(module.sub_app) 27 | SessionLogger.info(f'Succeeded to import "{module_path}"') 28 | except Exception as e: 29 | SessionLogger.error(f'Failed to import "{module_path}", error: {e}') 30 | SessionLogger.exception(e) 31 | -------------------------------------------------------------------------------- /plugins/_utils/__init__.py: -------------------------------------------------------------------------------- 1 | import typing as T 2 | from datetime import datetime, timedelta 3 | 4 | from pydantic import BaseModel 5 | from mirai import ( 6 | Mirai, Member, Friend, 7 | MessageChain, At 8 | ) 9 | 10 | from .alias import MESSAGE_T 11 | 12 | # https://mirai-py.originpages.com/tutorial/annotations.html 13 | Sender = T.Union[Member, Friend] 14 | Type = str 15 | 16 | 17 | def reply(app: Mirai, sender: "Sender", event_type: "Type"): 18 | """app_reply = reply(app, sender, event_type) 19 | app_reply(message) 20 | """ 21 | async def wrapper(message: MESSAGE_T, *, at_sender: bool = False): 22 | if at_sender: 23 | if isinstance(message, list): 24 | message.insert(0, At(sender.id)) 25 | elif isinstance(message, MessageChain): 26 | message.__root__.insert(0, At(sender.id)) 27 | else: 28 | raise TypeError(f"not supported type for reply: {message.__class__.__name__}") 29 | if event_type == "GroupMessage": 30 | await app.sendGroupMessage(sender.group, message) 31 | elif event_type == "FriendMessage": 32 | await app.sendFriendMessage(sender, message) 33 | else: 34 | raise ValueError("Not supported event type") 35 | 36 | return wrapper 37 | 38 | 39 | def at_me(app: Mirai, message: MessageChain): 40 | at: T.Optional[At] = message.getFirstComponent(At) 41 | if at: 42 | return at.target == app.qq 43 | else: 44 | return False 45 | 46 | 47 | class CoolDown(BaseModel): 48 | """example: 49 | cd = CoolDown(app='app1', td=20) 50 | cd.update(123) 51 | cd.check(123) 52 | """ 53 | app: str 54 | td: float # timedelta 55 | value: T.Dict[int, datetime] = {} 56 | 57 | def update(self, mid: int) -> None: 58 | self.value.update({mid: datetime.now()}) 59 | 60 | def check(self, mid: int) -> bool: 61 | ret = datetime.now() >= self.value.get(mid, datetime.utcfromtimestamp(0)) + timedelta(seconds=self.td) 62 | return ret 63 | 64 | 65 | def shuzi2number(shuzi: T.Optional[str]) -> int: 66 | s = {'一': 1, '两': 2, '二': 2, '三': 3, 67 | '四': 4, '五': 5, '六': 6, '七': 7, 68 | '八': 8, '九': 9, '十': 10} 69 | if not shuzi: 70 | return 1 71 | elif shuzi.isdecimal(): 72 | return int(shuzi) 73 | elif shuzi in s.keys(): 74 | return s[shuzi] 75 | else: 76 | return 1 77 | -------------------------------------------------------------------------------- /plugins/_utils/alias.py: -------------------------------------------------------------------------------- 1 | import typing as T 2 | 3 | from mirai import MessageChain 4 | from mirai.image import InternalImage 5 | from mirai.event.message.base import BaseMessageComponent 6 | 7 | MESSAGE_T = T.Union[ 8 | MessageChain, 9 | BaseMessageComponent, 10 | T.List[T.Union[BaseMessageComponent, InternalImage]], 11 | str 12 | ] 13 | -------------------------------------------------------------------------------- /plugins/_utils/register.py: -------------------------------------------------------------------------------- 1 | import typing as T 2 | import json 3 | from pathlib import Path 4 | from pydantic import BaseModel, ValidationError 5 | 6 | from config import data_path 7 | 8 | Path(data_path).mkdir(exist_ok=True) 9 | 10 | 11 | class Target(BaseModel): 12 | name: str = '' 13 | id: str 14 | groups: T.Set[int] 15 | 16 | def __str__(self): 17 | return self.name or self.id 18 | 19 | 20 | class Database(BaseModel): 21 | __root__: T.List[Target] = [] 22 | 23 | @classmethod 24 | def load(cls, file: Path) -> "Database": 25 | try: 26 | db: Database = cls.parse_file(file) 27 | except (FileNotFoundError, json.JSONDecodeError, ValidationError): 28 | db = cls() 29 | return db 30 | 31 | def save(self, file: Path) -> None: 32 | with file.open('w', encoding='utf8') as f: 33 | json.dump([json.loads(target.json()) for target in self.__root__], f, ensure_ascii=False, indent=2) 34 | 35 | def add(self, target: Target) -> None: 36 | for saved_target in self.__root__: 37 | if saved_target.id == target.id: 38 | saved_target.groups.update(target.groups) 39 | break 40 | else: 41 | self.__root__.append(target) 42 | 43 | def remove(self, target: Target) -> bool: 44 | for saved_target in self.__root__: 45 | if saved_target.id == target.id: 46 | # noinspection Mypy 47 | [saved_target.groups.discard(group) for group in target.groups] 48 | return True 49 | else: 50 | return False 51 | 52 | def show(self, group_id: int) -> T.List[Target]: 53 | ret = [saved_target for saved_target in self.__root__ if group_id in saved_target.groups] 54 | return ret 55 | -------------------------------------------------------------------------------- /plugins/bili_dynamic/README.md: -------------------------------------------------------------------------------- 1 | bilibili 动态监控 2 | ------------- 3 | 4 | ### 功能 5 | 6 | 将监控中的bilibili账号的新动态转发到对应的群。 7 | 8 | ### 使用方法 9 | 10 | ```plain 11 | 支持自然语言解析 12 | 动态监控 [<增加|删除> [ ...]] 13 | 14 | url: bilibili账号url,必须包含uid 15 | 16 | 例: 17 | 增加动态监控 https://space.bilibili.com/2 18 | 动态监控添加 space.bilibili.com/123 19 | 动态监控 删除 http://space.bilibili.com/123456 20 | ``` -------------------------------------------------------------------------------- /plugins/bili_dynamic/__init__.py: -------------------------------------------------------------------------------- 1 | import re 2 | import asyncio 3 | import traceback 4 | import typing as T 5 | 6 | from mirai import Mirai, GroupMessage, Plain, Image 7 | from mirai.logger import Event as EventLogger 8 | 9 | from .register import Target, Database, Platform 10 | from .connection import getDynamicStatus 11 | 12 | sub_app = Mirai(f"mirai://localhost:8080/?authKey=0&qq=0") 13 | 14 | 15 | class Command: 16 | cmd_T = T.Callable[..., T.Coroutine[T.Any, T.Any, None]] 17 | 18 | @classmethod 19 | def getCommand(cls, msg: str) -> T.Tuple[T.Optional[cmd_T], T.List[int]]: 20 | COMMAND = re.compile(r'动态监控').search(msg) 21 | ADD = re.compile(r'新增|增|添|加').search(msg) 22 | RM = re.compile(r'取消|删|减|除').search(msg) 23 | SHOW = re.compile(r'显示|列表').search(msg) 24 | uid_list = re.compile(r'space.bilibili.com/(\d+)').findall(msg) 25 | if not COMMAND: 26 | return None, uid_list 27 | elif not uid_list: 28 | return cls.show, uid_list 29 | elif ADD: 30 | return cls.add, uid_list 31 | elif RM: 32 | return cls.remove, uid_list 33 | elif SHOW: 34 | return cls.show, uid_list 35 | else: 36 | return cls.show, uid_list 37 | 38 | @staticmethod 39 | async def add(app: Mirai, message: GroupMessage, *uid_list: int): 40 | group_id = message.sender.group.id 41 | names = Database.add(*[await Target.init(uid, Platform.bili_dynamic, group_id) for uid in uid_list]) 42 | EventLogger.info(f'群「{message.sender.group.name}」增加动态监控:{",".join(names)}') 43 | await app.sendGroupMessage(group=message.sender.group, 44 | message=f'增加动态监控:{",".join(names)}', 45 | quoteSource=message.messageChain.getSource()) 46 | 47 | @staticmethod 48 | async def remove(app: Mirai, message: GroupMessage, *uid_list: int): 49 | group_id = message.sender.group.id 50 | names = Database.remove(*[await Target.init(uid, Platform.bili_dynamic, group_id) for uid in uid_list]) 51 | EventLogger.info(f'群「{message.sender.group.name}」移除动态监控:{",".join(names)}') 52 | await app.sendGroupMessage(group=message.sender.group, 53 | message=f'移除动态监控:{",".join(names)}', 54 | quoteSource=message.messageChain.getSource()) 55 | 56 | @staticmethod 57 | async def show(app: Mirai, message: GroupMessage, *_: int): 58 | group_id = message.sender.group.id 59 | names = Database.show(group_id) 60 | msg = '动态监控列表:\n{}'.format('\n'.join(names)) if names else '动态监控列表为空' 61 | EventLogger.info(f'群「{message.sender.group.name}」{msg}') 62 | await app.sendGroupMessage(group=message.sender.group, 63 | message=msg, 64 | quoteSource=message.messageChain.getSource()) 65 | 66 | 67 | @sub_app.receiver("GroupMessage") 68 | async def GMHandler(app: Mirai, message: GroupMessage): 69 | command, uid_list = Command.getCommand(message.toString()) 70 | if command: 71 | try: 72 | await command(app, message, *uid_list) 73 | except Exception as e: 74 | EventLogger.error(e) 75 | EventLogger.exception(e) 76 | 77 | 78 | @sub_app.subroutine 79 | async def execute(app: Mirai) -> None: 80 | delay = 20 81 | while True: 82 | targets = Database.load().__root__ 83 | if not targets: 84 | await asyncio.sleep(delay) 85 | continue 86 | for target in targets: 87 | if target.groups: 88 | try: 89 | await asyncio.sleep(delay) 90 | resp = await getDynamicStatus(target.uid) 91 | if resp: 92 | footer = f"\n\n动态地址: https://t.bilibili.com/{resp.dynamic_id}" 93 | EventLogger.info(f'{target.name}动态更新:https://t.bilibili.com/{resp.dynamic_id}') 94 | # noinspection PyTypeChecker,PydanticTypeChecker 95 | components = [Plain(resp.msg)] + \ 96 | [await app.uploadImage( 97 | 'group', await Image.fromRemote(url) 98 | ) for url in resp.imgs] + \ 99 | [Plain(footer)] 100 | [asyncio.create_task( 101 | app.sendGroupMessage(group=group_id, message=components) 102 | ) for group_id in target.groups] 103 | except Exception as e: 104 | EventLogger.error(f'动态检查出错:{target.name} {e}') 105 | EventLogger.error(traceback.format_exc()) 106 | continue 107 | -------------------------------------------------------------------------------- /plugins/bili_dynamic/connection.py: -------------------------------------------------------------------------------- 1 | import json 2 | import typing as T 3 | import aiohttp 4 | from collections import namedtuple 5 | 6 | # {uid:dynamic_id} 7 | LAST: T.Dict[int, int] = {} 8 | 9 | Resp = namedtuple('Resp', 'msg imgs dynamic_id') 10 | 11 | 12 | async def getDynamicStatus(uid: int, debug=0) -> T.Optional[Resp]: 13 | cards_data = await getCards(uid) 14 | 15 | last_dynamic = LAST.setdefault(uid, cards_data[0]['desc']['dynamic_id']) 16 | 17 | for i, card_data in enumerate(cards_data): 18 | if last_dynamic == card_data['desc']['dynamic_id']: 19 | break 20 | else: # 没有找到上次动态,可能为程序初次运行或动态被删除 21 | LAST[uid] = cards_data[0]['desc']['dynamic_id'] 22 | return None 23 | 24 | if debug: 25 | i = debug 26 | 27 | if i >= 1: 28 | LAST[uid] = cards_data[i - 1]['desc']['dynamic_id'] 29 | return CardData(cards_data[i - 1]).resolve() 30 | else: 31 | return None # 没有新动态 32 | 33 | 34 | async def getCards(uid: int) -> T.List[dict]: 35 | url = 'https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/space_history' 36 | params = { 37 | 'host_uid': str(uid), 38 | 'offset_dynamic_id': '0' 39 | } 40 | async with aiohttp.request('GET', url, params=params) as resp: 41 | res = await resp.read() 42 | cards_data = json.loads(res) 43 | return cards_data['data']['cards'] 44 | 45 | 46 | class CardData(dict): 47 | def __init__(self, obj): 48 | super(CardData, self).__init__(obj) 49 | self['card'] = deep_decode(self['card']) 50 | 51 | def resolve(self) -> Resp: 52 | name = self["desc"]["user_profile"]["info"]["uname"] 53 | c_type = self['desc']['type'] 54 | 55 | msg, imgs = self.resolve_card(self['card'], name, c_type) 56 | return Resp(msg, imgs, self['desc']['dynamic_id']) 57 | 58 | @staticmethod 59 | def resolve_card(card: dict, name: str, c_type: int) -> T.Tuple[str, T.List[str]]: 60 | try: 61 | if c_type == 1: # 转发 62 | content = card['item'].get('content') 63 | msg = f'(转发){name}:{content}\n{"=" * 20}\n' 64 | 65 | origin_type = card['item']['orig_type'] 66 | if origin_type != 1024: # 没有被删 67 | origin_name = card['origin_user']['info']['uname'] 68 | msg_a, img_urls = CardData.resolve_card(card['origin'], origin_name, origin_type) 69 | else: # 被删了, 一般不会发生 70 | msg_a, img_urls = card['item']['tips'], [] 71 | msg += msg_a 72 | elif c_type == 2: # 图片动态 73 | description = card['item'].get('description') 74 | msg = f'(动态){name}:\n{description}' 75 | img_urls = [pic_info['img_src'] for pic_info in card['item']['pictures']] 76 | elif c_type == 4: # 文字动态 77 | content = card['item'].get('content') 78 | msg = f'(动态){name}:\n{content}' 79 | img_urls = [] 80 | elif c_type == 8: # 视频动态 81 | dynamic = card.get('dynamic') 82 | title = card.get('title') 83 | pic = card.get('pic') 84 | msg = f'(视频){name}:《{title}》\n{dynamic}' 85 | img_urls = [pic] 86 | elif c_type == 64: # 专栏动态 87 | dynamic = card.get('dynamic', '') 88 | title = card.get('title') 89 | banner_url = card.get('banner_url') 90 | msg = f'(专栏){name}:《{title}》\n{dynamic}' 91 | img_urls = [banner_url] 92 | elif c_type == 256: # 音乐动态 93 | title = card.get('title') 94 | intro = card.get('intro') 95 | cover = card.get('cover') 96 | msg = f'(音乐){name}:《{title}》\n{intro}' 97 | img_urls = [cover] 98 | elif c_type == 2048: # 特殊动态类型(头像框、直播日历等) 99 | content = card['vest'].get('content') 100 | title = card['sketch'].get('title') 101 | msg = f'(动态){name}:{content}\n{title}' 102 | img_urls = [] 103 | elif c_type == 4200: # 直播间动态 104 | roomid = card.get('roomid') 105 | cover = card.get('user_cover') or card.get('cover') 106 | title = card.get('title') 107 | msg = f'(直播){name}:{title} https://live.bilibili.com/{roomid}' 108 | img_urls = [cover] 109 | else: # 未知 110 | msg = f'{name}:(未知动态类型{c_type})' 111 | img_urls = [] 112 | except (TypeError, KeyError): 113 | msg = f'{name}:(动态类型{c_type},解析失败)' 114 | img_urls = [] 115 | if not msg.endswith('\n') and img_urls: 116 | msg += '\n' 117 | return msg, img_urls 118 | 119 | 120 | def deep_decode(j: T.Union[dict, list, str]) -> T.Union[dict, list, str]: 121 | """将str完全解析为json""" 122 | if isinstance(j, dict): 123 | j = j.copy() 124 | for k, v in j.items(): 125 | j[k] = deep_decode(v) 126 | elif isinstance(j, list): 127 | j = j.copy() 128 | for i, v in enumerate(j): 129 | j[i] = deep_decode(v) 130 | elif isinstance(j, str): 131 | try: 132 | j = deep_decode(json.loads(j)) 133 | except json.decoder.JSONDecodeError: 134 | pass 135 | return j 136 | -------------------------------------------------------------------------------- /plugins/bili_dynamic/register.py: -------------------------------------------------------------------------------- 1 | import typing as T 2 | import json 3 | from enum import Enum 4 | from pathlib import Path 5 | from pydantic import BaseModel, ValidationError 6 | import aiohttp 7 | 8 | from config import data_path 9 | 10 | Path(data_path).mkdir(exist_ok=True) 11 | SAVE_FILE = Path(data_path).joinpath('bili_dynamic.json') 12 | 13 | 14 | class Platform(Enum): 15 | bili_dynamic = 'bili_dynamic' 16 | 17 | 18 | class Target(BaseModel): 19 | name: str 20 | uid: int 21 | platform: Platform 22 | groups: T.Set[int] 23 | 24 | @classmethod 25 | async def init(cls, uid: T.Union[int, str], platform: Platform, group_id: int) -> "Target": 26 | if platform == Platform.bili_dynamic: 27 | url = f'https://api.bilibili.com/x/space/acc/info?mid={uid}' 28 | async with aiohttp.request('GET', url) as resp: 29 | name = (await resp.json(encoding='utf8'))['data']['name'] 30 | return cls(name=name, uid=uid, platform=platform, groups={group_id}) 31 | else: 32 | raise 33 | 34 | def __eq__(self, other): 35 | if isinstance(other, self.__class__): 36 | return (self.uid, self.platform) == (other.uid, other.platform) 37 | else: 38 | return False 39 | 40 | # def __hash__(self): 41 | # return hash((self.uid, self.platform)) 42 | 43 | 44 | class Database(BaseModel): 45 | __root__: T.List[Target] = [] 46 | 47 | @classmethod 48 | def load(cls) -> "Database": 49 | try: 50 | db: Database = cls.parse_file(SAVE_FILE) 51 | except (FileNotFoundError, json.JSONDecodeError, ValidationError): 52 | db = cls() 53 | return db 54 | 55 | def save_to_file(self) -> None: 56 | with SAVE_FILE.open('w', encoding='utf8') as f: 57 | json.dump([json.loads(target.json()) for target in self.__root__], f, ensure_ascii=False, indent=2) 58 | 59 | # @classmethod 60 | # def save(cls, *data_array: Target): 61 | # db: cls = cls.load() 62 | # for data in data_array: 63 | # for i, saved_target in enumerate(db.__root__): 64 | # if saved_target == data: 65 | # db.__root__[i] = data 66 | # break 67 | # else: 68 | # db.__root__.append(data) 69 | # db.save_to_file() 70 | 71 | @classmethod 72 | def add(cls, *data_array: Target) -> T.List[str]: 73 | db: Database = cls.load() 74 | for data in data_array: 75 | for saved_target in db.__root__: 76 | if saved_target == data: 77 | saved_target.groups.update(data.groups) 78 | break 79 | else: 80 | db.__root__.append(data) 81 | db.save_to_file() 82 | return [target.name for target in data_array] 83 | 84 | @classmethod 85 | def remove(cls, *data_array: Target) -> T.List[str]: 86 | db: Database = cls.load() 87 | for data in data_array: 88 | for saved_target in db.__root__: 89 | if saved_target == data: 90 | # noinspection Mypy 91 | [saved_target.groups.discard(group) for group in data.groups] 92 | break 93 | else: 94 | pass 95 | db.save_to_file() 96 | return [target.name for target in data_array] 97 | 98 | @classmethod 99 | def show(cls, group_id: int) -> T.List[str]: 100 | db: Database = cls.load() 101 | ret = [saved_target.name for saved_target in db.__root__ if group_id in saved_target.groups] 102 | return ret 103 | -------------------------------------------------------------------------------- /plugins/bili_extractor/README.md: -------------------------------------------------------------------------------- 1 | bilibili 小程序解析 2 | ------------- 3 | 4 | ### 功能 5 | 6 | 自动解析群聊中的 bilibili 小程序分享链接。 7 | 8 | ~~(请使用最新版本手机QQ查看)~~ 9 | -------------------------------------------------------------------------------- /plugins/bili_extractor/__init__.py: -------------------------------------------------------------------------------- 1 | import typing as T 2 | from mirai import Mirai, Group, GroupMessage, MessageChain, LightApp, Plain, Image 3 | import re 4 | import json 5 | import asyncio 6 | import aiohttp 7 | import lxml.html 8 | import urllib.parse 9 | 10 | __plugin_name__ = 'B站小程序解析' 11 | __plugin_usage__ = r'''自动解析B站小程序分享链接,显示视频标题并推测视频链接''' 12 | 13 | sub_app = Mirai(f"mirai://localhost:8080/?authKey=0&qq=0") 14 | 15 | 16 | @sub_app.receiver(GroupMessage) 17 | async def extract(app: Mirai, group: Group, message: MessageChain): 18 | light_app: T.Optional[LightApp] = message.getFirstComponent(LightApp) 19 | if light_app: 20 | content: dict = json.loads(light_app.content) 21 | if content.get("prompt") == "[QQ小程序]哔哩哔哩": 22 | title: str = content['meta']['detail_1']['desc'] 23 | url = content['meta']['detail_1'].get('qqdocurl') 24 | if not url: 25 | url = await search_bili_by_title(title) 26 | url = shorten(url) if url else '未找到视频地址' 27 | preview = content['meta']['detail_1'].get('preview') 28 | if not urllib.parse.urlparse(preview).scheme: 29 | preview = 'http://' + preview 30 | await app.sendGroupMessage(group, [Plain(f'{title}\n{url}\n'), await Image.fromRemote(preview)]) 31 | 32 | 33 | def shorten(_url: str) -> str: 34 | url_patterns = ( 35 | re.compile(r'/(av\d+|BV\w+)'), 36 | re.compile(r'/(ep\d+)'), 37 | re.compile(r'b23.tv/(\w+)'), 38 | ) 39 | for p in url_patterns: 40 | vid = p.search(_url) 41 | if vid: 42 | _url = f'https://b23.tv/{vid[1]}' 43 | break 44 | return _url 45 | 46 | 47 | async def search_bili_by_title(title: str) -> T.Optional[str]: 48 | """ 49 | :return: url 50 | """ 51 | # remove brackets 52 | brackets_pattern = re.compile(r'[()\[\]{}()【】]') 53 | title_without_brackets = brackets_pattern.sub(' ', title).strip() 54 | search_url = f'https://search.bilibili.com/video?keyword={urllib.parse.quote(title_without_brackets)}' 55 | 56 | try: 57 | async with aiohttp.request('GET', search_url, timeout=aiohttp.client.ClientTimeout(10)) as resp: 58 | text = await resp.text(encoding='utf8') 59 | content: lxml.html.HtmlElement = lxml.html.fromstring(text) 60 | except asyncio.TimeoutError: 61 | return None 62 | 63 | for video in content.xpath('//li[@class="video-item matrix"]/a[@class="img-anchor"]'): 64 | if title == ''.join(video.xpath('./attribute::title')): 65 | url = ''.join(video.xpath('./attribute::href')) 66 | break 67 | else: 68 | url = None 69 | return url 70 | -------------------------------------------------------------------------------- /plugins/find_living/README.md: -------------------------------------------------------------------------------- 1 | 查找 B 限 2 | ------------- 3 | 4 | ### 功能 5 | 6 | 显示当前进行中的bilibili限定直播 7 | 8 | ### 使用方法 9 | 10 | 在群聊中发送 `B限` 或 `b限` 或 `.b` 。 11 | -------------------------------------------------------------------------------- /plugins/find_living/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 查找B限 3 | 显示当前进行中的B限 4 | 命令:B限 b限 5 | """ 6 | 7 | from mirai import Mirai, Group, GroupMessage, MessageChain 8 | 9 | from .run import do_search 10 | 11 | sub_app = Mirai(f"mirai://localhost:8080/?authKey=0&qq=0") 12 | 13 | 14 | @sub_app.receiver(GroupMessage) 15 | async def find_living(app: Mirai, group: Group, message: MessageChain): 16 | if message.toString() in ['b限', 'B限', '.b']: 17 | m: str = await do_search() 18 | if m: 19 | msg = '当前进行中的B限:\n' + m 20 | else: 21 | msg = '无进行中的B限' 22 | await app.sendGroupMessage(group, msg) 23 | -------------------------------------------------------------------------------- /plugins/find_living/run.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import asyncio 3 | import math 4 | import re 5 | from typing import Dict, List 6 | from dataclasses import dataclass 7 | 8 | # https://github.com/lovelyyoshino/Bilibili-Live-API/blob/master/API.getRoomList.md 9 | API_URL = 'https://api.live.bilibili.com/room/v3/area/getRoomList' 10 | 11 | 12 | @dataclass 13 | class Room: 14 | roomid: int 15 | uid: int 16 | title: str 17 | uname: str 18 | online: int 19 | 20 | def __init__(self, roomid, uid, title, uname, online, **_): 21 | self.roomid = roomid 22 | self.uid = uid 23 | self.title = title 24 | self.uname = uname 25 | self.online = online 26 | 27 | 28 | def params(page, page_size=99): 29 | return { 30 | 'area_id': 371, 31 | 'page_size': page_size, 32 | 'page': page + 1, 33 | 'sort_type': 'online' 34 | } 35 | 36 | 37 | async def do_search_once(page: int): 38 | async with aiohttp.request('GET', API_URL, params=params(page)) as f: 39 | data_json = await f.json(encoding='utf8') 40 | count = data_json['data']['count'] 41 | living_list: List[Dict] = data_json['data']['list'] 42 | room_list = [Room(**room) for room in living_list] 43 | return room_list, count 44 | 45 | 46 | async def do_search(): 47 | room_list, count = await do_search_once(0) 48 | 49 | tasks = [do_search_once(i) for i in range(1, math.ceil(count / 99))] 50 | [room_list.extend(r[0]) for r in await asyncio.gather(*tasks)] 51 | # for task in asyncio.as_completed(tasks): 52 | # room_list += (await task)[0] 53 | 54 | return '\n'.join([f'{r.uname}: {r.title}' for r in room_list if re.search(r'[bB].*限', r.title)]) 55 | -------------------------------------------------------------------------------- /plugins/help/README.md: -------------------------------------------------------------------------------- 1 | 使用说明 2 | ------------- 3 | 4 | ### 功能 5 | 6 | 显示使用说明。 7 | 8 | ### 使用方法 9 | 10 | 在群聊或私聊中发送 `使用说明`,`帮助` 或 `help` 。 11 | -------------------------------------------------------------------------------- /plugins/help/__init__.py: -------------------------------------------------------------------------------- 1 | from mirai import Mirai, FriendMessage, GroupMessage, MessageChain 2 | 3 | from .._utils import at_me, Sender, Type, reply 4 | 5 | sub_app = Mirai(f"mirai://localhost:8080/?authKey=0&qq=0") 6 | 7 | 8 | @sub_app.receiver(FriendMessage) 9 | @sub_app.receiver(GroupMessage) 10 | async def show_help(app: Mirai, sender: "Sender", event_type: "Type", message: MessageChain): 11 | app_reply = reply(app, sender, event_type) 12 | 13 | keywords = ['使用说明', '帮助', 'help'] 14 | text = message.toString().strip().lower() 15 | if (text in keywords) or (at_me(app, message) and any(keyword in text for keyword in keywords)): 16 | await app_reply("使用说明:https://github.com/Lycreal/MiraiBot#miraibot") 17 | -------------------------------------------------------------------------------- /plugins/live_monitor/README.md: -------------------------------------------------------------------------------- 1 | 直播监控 2 | ------------- 3 | 4 | ### 功能 5 | 6 | 将监控频道的开播信息发送到对应的群。 7 | 支持的频道类型: bilibili, youtube, 网易cc 8 | 支持批量添加/删除频道 9 | 10 | ### 使用方法 11 | 12 | ```plain 13 | 直播监控添加 <频道1> <频道2> ... 14 | 直播监控移除 <频道1> <频道2> ... 15 | 直播监控列表 [页码] 16 | 直播监控详细列表 [页码] 17 | 18 | 频道: 频道url 或 频道名称(仅用于移除) 19 | 20 | 例: 21 | 直播监控添加 https://live.bilibili.com/1 22 | 直播监控移除 哔哩哔哩直播 23 | 直播监控列表 1 24 | ``` -------------------------------------------------------------------------------- /plugins/live_monitor/__init__.py: -------------------------------------------------------------------------------- 1 | """直播监控 2 | 3 | 使用方法: 4 | 直播监控添加 <频道1> <频道2> ... 5 | 直播监控移除 <频道1> <频道2> ... 6 | 直播监控列表 [页码] 7 | 直播监控详细列表 [页码] 8 | 9 | 详细说明:https://github.com/Lycreal/MiraiBot/blob/master/plugins/live_monitor/README.md 10 | """ 11 | 12 | import re 13 | import asyncio 14 | import traceback 15 | import typing as T 16 | 17 | from mirai import Mirai, Group, MessageChain, GroupMessage, Plain, Image 18 | from mirai.logger import Event as EventLogger 19 | 20 | from .monitor import Monitor 21 | from .channels import ChannelResolveError 22 | from .enums import ChannelTypes 23 | 24 | sub_app = Mirai(f"mirai://localhost:8080/?authKey=0&qq=0") 25 | 26 | 27 | class Command: 28 | @classmethod 29 | def getCommand(cls, msg: str) -> T.Optional[T.Callable[[Group, str], T.Coroutine[T.Any, T.Any, str]]]: 30 | if '直播' in msg and '监控' in msg: 31 | command_map = { 32 | re.compile(r'新增|增|添|加'): cls.add, 33 | re.compile(r'取消|删|减|除'): cls.remove, 34 | re.compile(r'显示|列表'): cls.show 35 | } 36 | for pattern in command_map.keys(): 37 | if pattern.search(msg): 38 | return command_map[pattern] 39 | else: 40 | return cls.help 41 | return None 42 | 43 | @staticmethod 44 | async def add(group: Group, msg: str): 45 | matches: T.Dict[ChannelTypes, T.List[str]] = { 46 | ChannelTypes.bili_live: re.compile(r'live.bilibili.com/(\d+)').findall(msg), 47 | ChannelTypes.youtube_live: re.compile(r'UC[\w-]{22}').findall(msg), 48 | ChannelTypes.cc_live: re.compile(r'cc.163.com/(\d+)').findall(msg) 49 | } 50 | count = [ 51 | monitors[channel_type].add(cid, group.id) 52 | for channel_type, cids in matches.items() 53 | for cid in cids 54 | ].count(True) 55 | return f'已添加{count}个频道' 56 | 57 | @staticmethod 58 | async def remove(group: Group, msg: str): 59 | matches: T.Dict[ChannelTypes, T.List[str]] = { 60 | ChannelTypes.bili_live: re.compile(r'live.bilibili.com/(\d+)').findall(msg), 61 | ChannelTypes.youtube_live: re.compile(r'UC[\w-]{22}').findall(msg), 62 | ChannelTypes.cc_live: re.compile(r'cc.163.com/(\d+)').findall(msg) 63 | } 64 | count = [ 65 | monitors[channel_type].remove(cid, group.id) 66 | for channel_type, cids in matches.items() 67 | for cid in cids + msg.split() 68 | ].count(True) 69 | return f'已移除{count}个频道' 70 | 71 | @staticmethod 72 | async def show(group: Group, msg: str): 73 | page = int(msg.split()[-1]) - 1 if msg.split()[-1].isdecimal() else 0 74 | page_size = 10 75 | channel_names = [(channel_type, target.name, target.id) 76 | for channel_type in monitors.keys() 77 | for target in monitors[channel_type].database.__root__ 78 | if group.id in target.groups 79 | ] 80 | page_total = len(channel_names) // page_size + bool(len(channel_names) % page_size) 81 | ret = f'直播监控列表:第{page + 1}页/共{page_total}页\n' 82 | 83 | tmp: T.Dict[ChannelTypes, T.List[str]] = {} 84 | for channel_type, name, tid in channel_names[page_size * page:page_size * (page + 1)]: 85 | tmp.setdefault(channel_type, []).append( 86 | f'{name}\t{tid}' if '详细' in msg else (name or tid) 87 | ) 88 | for channel_type, names in tmp.items(): 89 | ret += '[{}]\n{}\n'.format( 90 | channel_type.name, 91 | '\n'.join(names) 92 | ) 93 | return ret 94 | 95 | # noinspection PyUnusedLocal 96 | @staticmethod 97 | async def help(group: Group, msg: str): 98 | return __doc__.strip() 99 | 100 | 101 | @sub_app.receiver(GroupMessage) 102 | async def GMHandler(app: Mirai, group: Group, message: MessageChain): 103 | command = Command.getCommand(message.toString()) 104 | if command: 105 | try: 106 | msg = await command(group, message.toString()) 107 | await app.sendGroupMessage(group, msg.strip()) 108 | except Exception as e: 109 | EventLogger.error(e) 110 | EventLogger.error(traceback.format_exc()) 111 | 112 | 113 | def wrapper(channel_type: ChannelTypes, duration: float): 114 | async def exec_wrapper(app: Mirai): 115 | monitor = monitors[channel_type] 116 | while True: 117 | await asyncio.sleep(duration) 118 | if not app.enabled: 119 | pass 120 | asyncio.create_task(execute(app, monitor)) 121 | 122 | return exec_wrapper 123 | 124 | 125 | async def execute(app: Mirai, monitor: Monitor) -> None: 126 | # noinspection PyBroadException 127 | try: 128 | resp, groups = await monitor.run() 129 | if resp: 130 | EventLogger.info(f'{resp.name}直播:{resp.url}') 131 | 132 | if resp.cover: 133 | cover: Image = await app.uploadImage("group", await Image.fromRemote(resp.cover)) 134 | components = [Plain(f'(直播){resp.name}: {resp.title}\n{resp.url}\n'), cover] 135 | else: 136 | components = [Plain(f'(直播){resp.name}: {resp.title}\n{resp.url}')] 137 | 138 | tasks = [asyncio.create_task( 139 | app.sendGroupMessage(group=group_id, message=components) 140 | ) for group_id in groups] 141 | 142 | done, pending = await asyncio.wait(tasks) 143 | for task in done: 144 | if e := task.exception(): 145 | EventLogger.error(e) 146 | 147 | except ChannelResolveError as e: 148 | EventLogger.warning(e) 149 | except Exception: 150 | EventLogger.error(traceback.format_exc()) 151 | 152 | 153 | monitors: T.Dict[ChannelTypes, Monitor] = { 154 | channel_type: Monitor(channel_type) 155 | for channel_type in [ 156 | ChannelTypes.bili_live, 157 | ChannelTypes.youtube_live, 158 | ChannelTypes.cc_live 159 | ] 160 | } 161 | sub_app.subroutine(wrapper(ChannelTypes.bili_live, 10)) 162 | sub_app.subroutine(wrapper(ChannelTypes.youtube_live, 20)) 163 | sub_app.subroutine(wrapper(ChannelTypes.cc_live, 30)) 164 | -------------------------------------------------------------------------------- /plugins/live_monitor/channels/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import BaseChannel, LiveCheckResponse, ChannelResolveError 2 | from .bili import BiliChannel 3 | from .youtube import YoutubeChannel 4 | from .cc import NetEaseChannel 5 | 6 | __all__ = ['BaseChannel', 'LiveCheckResponse', 'ChannelResolveError', 7 | 'BiliChannel', 'YoutubeChannel', 'NetEaseChannel'] 8 | -------------------------------------------------------------------------------- /plugins/live_monitor/channels/base.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import difflib 3 | import os.path 4 | from typing import Optional 5 | from datetime import datetime, timezone, timedelta 6 | 7 | import aiohttp 8 | from pydantic.dataclasses import dataclass 9 | 10 | 11 | @dataclass 12 | class LiveCheckResponse: 13 | name: str 14 | live_status: Optional[int] 15 | title: str 16 | url: str 17 | cover: Optional[str] # 封面url 18 | 19 | 20 | class ChannelResolveError(Exception): 21 | pass 22 | 23 | 24 | class BaseChannel(abc.ABC): 25 | TIMEZONE = timezone(timedelta(hours=8)) 26 | 27 | def __init__(self, cid: str, name=''): 28 | self.cid: str = cid # 频道id 29 | 30 | self.ch_name: str = name # 频道名,初始化时设置,或检查状态时设置 31 | 32 | self.start_signal: bool = False 33 | self.start_time: datetime = datetime.fromtimestamp(0, self.TIMEZONE) 34 | self.start_nicely: bool = True 35 | 36 | self.last_check_status: int = 1 # 0 for down, 1 for living 37 | self.last_check_living: datetime = datetime.fromtimestamp(0, self.TIMEZONE) 38 | self.last_judge_title: str = '' 39 | 40 | @property 41 | @abc.abstractmethod 42 | def api_url(self): 43 | raise NotImplementedError 44 | 45 | @abc.abstractmethod 46 | async def resolve(self, html_s: str) -> LiveCheckResponse: 47 | raise NotImplementedError 48 | 49 | # 播报策略 50 | def judge(self, response: LiveCheckResponse, strategies=...) -> bool: 51 | """ 52 | 判断 response 是否满足开播信号条件 53 | """ 54 | if strategies is ...: 55 | strategies = [0b0101, 0b1011] 56 | 57 | if response.live_status == 1 != self.last_check_status: # 新开播 58 | self.start_signal = True 59 | self.start_time = datetime.now(self.TIMEZONE) 60 | self.start_nicely = datetime.now(self.TIMEZONE) - self.last_check_living >= timedelta(hours=1) # 非连续直播 61 | if response.live_status == 1: # 开播中 62 | self.last_check_living = datetime.now(self.TIMEZONE) 63 | else: 64 | self.start_signal = False 65 | self.last_check_status = response.live_status 66 | 67 | situation = sum(condition << i for i, condition in enumerate([ 68 | # 未提醒的新开播 69 | self.start_signal, 70 | # 非连续直播 71 | self.start_nicely, 72 | # 标题变化较大 73 | difflib.SequenceMatcher(None, response.title, self.last_judge_title).quick_ratio() < 0.7, 74 | # 离最近一次开播2分钟以上 75 | datetime.now(self.TIMEZONE) - self.start_time >= timedelta(minutes=2) 76 | ])) 77 | 78 | if any(strategy == strategy & situation for strategy in strategies): 79 | self.start_signal = False 80 | self.last_judge_title = response.title 81 | return True 82 | else: 83 | return False 84 | 85 | async def update(self, timeout: float = 15, strategies=...) -> Optional[LiveCheckResponse]: 86 | try: 87 | async with aiohttp.request('GET', self.api_url, timeout=aiohttp.ClientTimeout(timeout)) as resp: 88 | html_s = await resp.text(encoding='utf8') 89 | response = await self.resolve(html_s) 90 | except Exception as e: 91 | file = e.__traceback__.tb_next.tb_frame.f_globals["__file__"] 92 | lineno = e.__traceback__.tb_next.tb_lineno 93 | raise ChannelResolveError( 94 | f'Fetching channel information failed: {self.ch_name or self.cid}\n' 95 | f' [{os.path.basename(file)}][{lineno}] {e.__class__.__name__}: {str(e)}' 96 | ) from e 97 | 98 | judge = self.judge(response, strategies) 99 | return response if judge else None 100 | 101 | def __str__(self): 102 | return self.ch_name or self.cid 103 | -------------------------------------------------------------------------------- /plugins/live_monitor/channels/bili.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import aiohttp 4 | 5 | from .base import BaseChannel, LiveCheckResponse 6 | 7 | 8 | class BiliChannel(BaseChannel): 9 | @property 10 | def api_url(self): 11 | return f'https://api.live.bilibili.com/room/v1/Room/get_info?id={self.cid}' 12 | 13 | async def resolve(self, html_s): 14 | json_d: dict = json.loads(html_s) 15 | 16 | if not self.ch_name: 17 | uid: int = json_d['data']['uid'] 18 | self.ch_name = await self.get_name(uid) 19 | 20 | return LiveCheckResponse(name=self.ch_name, 21 | live_status=json_d['data']['live_status'], 22 | title=json_d['data']['title'], 23 | url=f'https://live.bilibili.com/{self.cid}', 24 | cover=json_d['data']['user_cover']) 25 | 26 | @staticmethod 27 | async def get_name(uid: int) -> str: 28 | url = f'https://api.bilibili.com/x/space/acc/info?mid={uid}' 29 | async with aiohttp.request('GET', url, timeout=aiohttp.ClientTimeout(10)) as resp: 30 | j = await resp.json(encoding='utf-8') 31 | name = j['data']['name'] 32 | return name 33 | -------------------------------------------------------------------------------- /plugins/live_monitor/channels/cc.py: -------------------------------------------------------------------------------- 1 | import re 2 | import ast 3 | import html 4 | import typing as T 5 | 6 | import lxml.html 7 | 8 | from .base import BaseChannel, LiveCheckResponse 9 | 10 | 11 | class NetEaseChannel(BaseChannel): 12 | @property 13 | def api_url(self) -> str: 14 | return f'https://cc.163.com/search/all/?query={self.cid}&only=live' 15 | 16 | async def resolve(self, content: str) -> LiveCheckResponse: 17 | tree: lxml.html.HtmlElement = lxml.html.fromstring(content) 18 | script: str = ''.join(tree.xpath('body/script[contains(text(),"searchResult")]/text()')) 19 | script = re.search(r"'anchor': (\[.*\]|)", html.unescape(script), re.M)[1] 20 | anchors: T.List[T.Dict[str, T.Any]] = ast.literal_eval(script) if script else [] 21 | 22 | for anchor in anchors: 23 | if str(anchor['cuteid']) == self.cid: 24 | self.ch_name = anchor['nickname'] 25 | live_status = anchor['status'] 26 | title = anchor['title'] 27 | cover = anchor['cover'] 28 | break 29 | else: 30 | live_status = 0 31 | title = '' 32 | cover = None 33 | 34 | return LiveCheckResponse(name=self.ch_name, 35 | live_status=live_status, 36 | title=title, 37 | url=f'https://cc.163.com/{self.cid}/', 38 | cover=cover) 39 | -------------------------------------------------------------------------------- /plugins/live_monitor/channels/youtube.py: -------------------------------------------------------------------------------- 1 | import re 2 | import json 3 | from typing import Dict, Any 4 | 5 | import lxml.html 6 | 7 | from .base import BaseChannel, LiveCheckResponse 8 | 9 | 10 | class YoutubeChannel(BaseChannel): 11 | @property 12 | def api_url(self): 13 | return f'https://www.youtube.com/channel/{self.cid}/live' 14 | 15 | async def resolve(self, content: str): 16 | if '"isLive":true' not in content and self.cid in content: 17 | return LiveCheckResponse( 18 | name=self.ch_name, 19 | live_status=0, 20 | title='', 21 | url=self.api_url, 22 | cover=None 23 | ) 24 | 25 | if resp := self.parse_html_1(content): 26 | return resp 27 | elif resp := self.parse_html_2(content): 28 | return resp 29 | elif resp := self.parse_html_3(content): 30 | return resp 31 | elif resp := self.parse_html_4(content): 32 | return resp 33 | else: 34 | # ========== DEBUG ========== 35 | import os 36 | from config import data_path 37 | debug_filepath = os.path.join(data_path, 'youtube_live_debug.html') 38 | with open(debug_filepath, 'w', encoding='utf8') as f: 39 | f.write(content) 40 | # ========== DEBUG ========== 41 | 42 | raise AssertionError(f'获取直播间信息失败, html页面保存至{debug_filepath}') 43 | 44 | def parse_html_1(self, content: str): 45 | if 'videoDetails' in content: 46 | try: 47 | tree: lxml.html.HtmlElement = lxml.html.fromstring(content) 48 | script = ''.join(tree.xpath('body/script[contains(text(),"ytInitialPlayerResponse")]/text()')) 49 | script = re.search(r'var ytInitialPlayerResponse = ({.*});', script)[1] 50 | videoDetails = json.loads(script)['videoDetails'] 51 | return self.parse_videoDetails(videoDetails) 52 | except: 53 | return None 54 | 55 | def parse_html_2(self, content: str): 56 | if 'videoDetails' in content: 57 | try: 58 | tree: lxml.html.HtmlElement = lxml.html.fromstring(content) 59 | script = ''.join(tree.xpath('body/script[contains(text(),"ytInitialPlayerResponse")]/text()')) 60 | script = re.search(r'window\["ytInitialPlayerResponse"] = ({.*?});', script)[1] 61 | videoDetails = json.loads(script)['videoDetails'] 62 | return self.parse_videoDetails(videoDetails) 63 | except: 64 | return None 65 | 66 | def parse_html_3(self, content: str): 67 | if 'videoDetails' in content: 68 | try: 69 | tree: lxml.html.HtmlElement = lxml.html.fromstring(content) 70 | script = ''.join(tree.xpath('//div[@id="player-wrap"]/script[contains(text(),"player_response")]/text()')) 71 | script = re.search(r'ytplayer.config = ({.*?});', script)[1] 72 | script = json.loads(script)['args']['player_response'] 73 | videoDetails: Dict[str, Any] = json.loads(script)['videoDetails'] 74 | return self.parse_videoDetails(videoDetails) 75 | except: 76 | return None 77 | 78 | def parse_html_4(self, content: str): 79 | try: 80 | tree: lxml.html.HtmlElement = lxml.html.fromstring(content) 81 | script = ''.join(tree.xpath('body/script[contains(text(),"RELATED_PLAYER_ARGS")]/text()')) 82 | script = re.search(r'\'RELATED_PLAYER_ARGS\':(.*),', script)[1] 83 | watch_next_response: Dict[str, Any] = json.loads(json.loads(script)['watch_next_response']) 84 | 85 | videoMetadataRenderer: Dict[str, Any] = watch_next_response['contents']['twoColumnWatchNextResults']['results']['results']['contents'][0]['itemSectionRenderer']['contents'][0]['videoMetadataRenderer'] 86 | shareVideoEndpoint: Dict[str, Any] = videoMetadataRenderer['shareButton']['buttonRenderer']['navigationEndpoint']['shareVideoEndpoint'] 87 | 88 | self.ch_name = ''.join(run['text'] for run in videoMetadataRenderer['owner']['videoOwnerRenderer']['title']['runs']) 89 | badges = videoMetadataRenderer.get('badges') 90 | live_status = 1 if badges and 'liveBadge' in badges[0].keys() else 0 91 | vid = shareVideoEndpoint['videoId'] 92 | title = shareVideoEndpoint['videoTitle'] 93 | live_url = shareVideoEndpoint['videoShareUrl'] 94 | return LiveCheckResponse( 95 | name=self.ch_name, 96 | live_status=live_status, 97 | title=title, 98 | url=live_url, 99 | cover=f'https://i.ytimg.com/vi/{vid}/hq720.jpg' 100 | ) 101 | except: 102 | return None 103 | 104 | def parse_videoDetails(self, videoDetails: dict): 105 | self.ch_name = videoDetails['author'] 106 | live_status = videoDetails.get('isLive', 0) 107 | title = videoDetails['title'] 108 | vid = videoDetails['videoId'] 109 | live_url = f'https://youtu.be/{vid}' 110 | return LiveCheckResponse( 111 | name=self.ch_name, 112 | live_status=live_status, 113 | title=title, 114 | url=live_url, 115 | cover=f'https://i.ytimg.com/vi/{vid}/hq720.jpg' 116 | ) 117 | -------------------------------------------------------------------------------- /plugins/live_monitor/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from .channels import * 3 | 4 | 5 | class ChannelTypes(Enum): 6 | bili_live = BiliChannel 7 | youtube_live = YoutubeChannel 8 | cc_live = NetEaseChannel 9 | -------------------------------------------------------------------------------- /plugins/live_monitor/monitor.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import List, Set, Tuple, Type, Optional 3 | 4 | from config import data_path 5 | from .._utils.register import Target, Database 6 | from .channels import BaseChannel, LiveCheckResponse 7 | from .enums import ChannelTypes 8 | 9 | 10 | class Monitor: 11 | def __init__(self, channel: ChannelTypes): 12 | self.channel_type = channel.name 13 | self.channel: Type[BaseChannel] = channel.value 14 | 15 | Path(data_path).mkdir(exist_ok=True) 16 | self.save_file = Path(data_path).joinpath(f'{self.channel_type}.json') 17 | 18 | self.database: Database = Database.load(self.save_file) 19 | self.channels: List[BaseChannel] = [self.channel(target.id, target.name) for target in self.database.__root__] 20 | 21 | self.pos = -1 22 | 23 | def add(self, cid: str, group: int): 24 | self.database.add(Target(id=cid, groups={group})) 25 | self.database.save(self.save_file) 26 | for channel in self.channels: 27 | if channel.cid == cid: 28 | return True 29 | else: 30 | self.channels.append(self.channel(cid)) 31 | return True 32 | 33 | def remove(self, cid: str, group: int): 34 | for channel in self.channels: 35 | if cid in channel.ch_name.split(): 36 | cid = channel.cid 37 | if cid == channel.cid: 38 | self.database.remove(Target(id=cid, groups={group})) 39 | self.database.save(self.save_file) 40 | return True 41 | else: 42 | return False 43 | 44 | def next(self) -> Optional[BaseChannel]: 45 | if self.channels: 46 | self.pos = self.pos + 1 if self.pos < len(self.channels) - 1 else 0 47 | return self.channels[self.pos] 48 | else: 49 | return None 50 | 51 | async def run(self, strategies=...) -> Tuple[Optional[LiveCheckResponse], Set[int]]: 52 | channel: BaseChannel = self.next() 53 | 54 | if channel: 55 | for target in self.database.__root__: 56 | if target.id == channel.cid: 57 | break 58 | else: 59 | return None, set() # Should not happen 60 | 61 | if not target.groups: 62 | self.channels.remove(channel) 63 | self.database.__root__.remove(target) 64 | self.database.save(self.save_file) 65 | return None, set() 66 | 67 | resp = await channel.update(strategies=strategies) 68 | 69 | target.name = channel.ch_name 70 | self.database.save(self.save_file) 71 | 72 | return resp, target.groups 73 | else: 74 | return None, set() 75 | -------------------------------------------------------------------------------- /plugins/pic_finder/README.md: -------------------------------------------------------------------------------- 1 | 搜图 2 | ------------- 3 | 4 | ### 功能 5 | 6 | 基于 [saucenao](https://saucenao.com/) 搜索二次元图片。 7 | 8 | ### 使用方法 9 | 10 | 在群聊中发送 11 | 12 | ```plain 13 | 搜图 <要搜索的图片> 14 | ``` -------------------------------------------------------------------------------- /plugins/pic_finder/__init__.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import lxml.html 3 | import typing as T 4 | from mirai import Mirai, Group, GroupMessage, MessageChain, Image 5 | 6 | sub_app = Mirai(f"mirai://localhost:8080/?authKey=0&qq=0") 7 | 8 | 9 | @sub_app.receiver(GroupMessage) 10 | async def find_pic(app: Mirai, group: Group, message: MessageChain): 11 | if '搜图' in message.toString(): 12 | image: T.Optional[Image] = message.getFirstComponent(Image) 13 | if image and image.url: 14 | await app.sendGroupMessage(group, await do_search(image.url)) 15 | 16 | 17 | async def do_search(url: str): 18 | # saucenao 19 | s_url = f'https://saucenao.com/search.php?url={url}' 20 | 21 | s_info = await get_saucenao_detail(s_url) 22 | 23 | if s_info and percent_to_int(s_info[0]['Similarity']) > 0.6: 24 | msg = '' 25 | for k, v in s_info[0].items(): 26 | if k != 'Content': 27 | msg += f'{k}: {v}\n' 28 | else: 29 | msg += f'{v}\n' 30 | return msg.strip() 31 | else: 32 | msg = '未找到相似图片\n' 33 | return msg.strip() 34 | 35 | 36 | async def get_saucenao_detail(s_url): 37 | async with aiohttp.client.request('GET', s_url) as resp: 38 | text = await resp.text(encoding='utf8') 39 | 40 | html_e: lxml.html.HtmlElement = lxml.html.fromstring(text) 41 | results = [ 42 | { 43 | 'Similarity': ''.join( 44 | r.xpath('.//div[@class="resultsimilarityinfo"]/text()')), 45 | 'Title': ''.join( 46 | r.xpath('.//div[@class="resulttitle"]/descendant-or-self::text()')), 47 | 'Content': '\n'.join( 48 | r.xpath('.//div[@class="resultcontentcolumn"]/descendant-or-self::text()')).replace(': \n', ': '), 49 | 'URL': ''.join( 50 | r.xpath('.//div[@class="resultcontentcolumn"]/a[1]/attribute::href')), 51 | } 52 | for r in html_e.xpath('//div[@class="result"]/table[@class="resulttable"]') 53 | ] 54 | return results 55 | 56 | 57 | # 百分数转为int 58 | def percent_to_int(string): 59 | if string.endswith('%'): 60 | return float(string.rstrip("%")) / 100 61 | else: 62 | return float(string) 63 | 64 | # async def shorten_img_url(url: str): 65 | # i_url = f'https://iqdb.org/?url={url}' 66 | # async with aiohttp.client.request('GET', i_url) as resp: 67 | # text = await resp.text(encoding='utf8') 68 | # 69 | # html_e: lxml.html.HtmlElement = lxml.html.fromstring(text) 70 | # img_uri = html_e.xpath('//img[contains(attribute::src,"/thu/thu_")]/attribute::src')[0] 71 | # img_url = f'https://iqdb.org{img_uri}' 72 | # return img_url 73 | -------------------------------------------------------------------------------- /plugins/random_picture/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from mirai import ( 4 | Mirai, GroupMessage, FriendMessage, 5 | MessageChain, 6 | Image 7 | ) 8 | from mirai.logger import Event as EventLogger 9 | 10 | from .sources import CatPicture 11 | from .._utils import at_me, reply, Sender, Type 12 | 13 | sub_app = Mirai(f"mirai://localhost:8080/?authKey=0&qq=0") 14 | 15 | sources = { 16 | 'cat': CatPicture() 17 | } 18 | 19 | 20 | @sub_app.receiver(FriendMessage) 21 | @sub_app.receiver(GroupMessage) 22 | async def GMHandler(app: Mirai, sender: "Sender", event_type: "Type", message: MessageChain): 23 | app_reply = reply(app, sender, event_type) 24 | for tag, source in sources.items(): 25 | keywords = source.keywords 26 | text = message.toString() 27 | if (text in keywords) or (at_me(app, message) and any(keyword in text for keyword in keywords)): 28 | EventLogger.info(f'随机图片{tag}:请求者{sender.id}') 29 | try: 30 | image_url = await source.get() 31 | await app_reply([await Image.fromRemote(image_url)], at_sender=True) 32 | except asyncio.TimeoutError: 33 | await app_reply("请求超时", at_sender=True) 34 | except Exception as e: 35 | import traceback 36 | EventLogger.error(e) 37 | EventLogger.error(traceback.format_exc()) 38 | break 39 | -------------------------------------------------------------------------------- /plugins/random_picture/sources/__init__.py: -------------------------------------------------------------------------------- 1 | from .cat import CatPicture 2 | 3 | __all__ = ['CatPicture'] 4 | -------------------------------------------------------------------------------- /plugins/random_picture/sources/base.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import typing as T 3 | 4 | import aiohttp 5 | from pydantic import BaseModel 6 | 7 | 8 | class PictureSource(BaseModel, abc.ABC): 9 | api_url: str 10 | encoding: str = 'utf8' 11 | keywords: T.List[str] = [] 12 | 13 | async def get(self, *args, **kwargs): 14 | content = await self.fetch() 15 | image_url = self.resolve(content) 16 | return image_url 17 | 18 | async def fetch(self): 19 | async with aiohttp.request('GET', self.api_url, timeout=aiohttp.client.ClientTimeout(10)) as resp: 20 | return await resp.text(self.encoding) 21 | 22 | @abc.abstractmethod 23 | async def resolve(self, content: str): 24 | return NotImplementedError 25 | -------------------------------------------------------------------------------- /plugins/random_picture/sources/cat.py: -------------------------------------------------------------------------------- 1 | import json 2 | import typing as T 3 | 4 | from .base import PictureSource 5 | 6 | 7 | class CatPicture(PictureSource): 8 | # https://thecatapi.com 9 | api_url: str = 'https://api.thecatapi.com/v1/images/search' 10 | keywords: T.List[str] = ['猫猫'] 11 | 12 | def resolve(self, content: str): 13 | return json.loads(content)[0]['url'] 14 | -------------------------------------------------------------------------------- /plugins/revoke/README.md: -------------------------------------------------------------------------------- 1 | 撤回消息 2 | ------------- 3 | 4 | ### 功能 5 | 6 | 撤回 bot 或其他人(需要权限)发送的消息。 7 | 8 | ### 使用方法 9 | 10 | 对想要撤回的消息回复“撤回”。 11 | -------------------------------------------------------------------------------- /plugins/revoke/__init__.py: -------------------------------------------------------------------------------- 1 | import typing as T 2 | from mirai import ( 3 | Mirai, Group, 4 | GroupMessage, 5 | MessageChain, Source, Quote, Plain 6 | ) 7 | 8 | from mirai.exceptions import UnknownReceiverTarget 9 | 10 | from .._utils.alias import MESSAGE_T 11 | 12 | sub_app = Mirai(f"mirai://localhost:8080/?authKey=0&qq=0") 13 | 14 | 15 | @sub_app.receiver(GroupMessage) 16 | async def revoke(app: Mirai, group: Group, message: MessageChain, source: Source): 17 | def reply(msg: MESSAGE_T, at: bool = False): 18 | _s = source if at else None 19 | return app.sendGroupMessage(group, msg, _s) 20 | 21 | plain: Plain 22 | if any(plain.text.strip() == '撤回' for plain in message.getAllofComponent(Plain)): 23 | quote: T.Optional[Quote] = message.getFirstComponent(Quote) 24 | if quote: 25 | try: 26 | await app.revokeMessage(quote.id) 27 | await app.revokeMessage(source.id) 28 | except UnknownReceiverTarget as e: 29 | if quote.senderId == app.qq: 30 | await reply(str(e), at=True) 31 | except PermissionError: 32 | pass 33 | -------------------------------------------------------------------------------- /plugins/setu/README.md: -------------------------------------------------------------------------------- 1 | 色图 2 | ------------- 3 | 4 | ### 功能 5 | 6 | 发送色图。 7 | 8 | ### 使用方法 9 | 10 | ```plain 11 | [张][keyword]色图 12 | num: 色图数量 13 | keyword: 色图关键词 14 | ``` 15 | 16 | ### 注意事项 17 | 18 | 本功能依赖 [随机色图](https://api.lolicon.app/#/setu) 提供的服务。 19 | 20 | 服务需要APIKEY,在 [此处](https://api.lolicon.app/#/setu?id=apikey) 申请。 21 | 22 | > 允许不带 APIKEY,但调用额度很低,并且具有一定限制,仅供测试使用 23 | 24 | 若本机无法访问 pixiv 图片服务器,可将 `setu_proxy` 清空。 25 | 26 | 以下配置与 [请求](https://api.lolicon.app/#/setu?id=%e8%af%b7%e6%b1%82) 一节中的 `apikey`, `r18` , `proxy` 对应。 27 | 28 | ### 配置 29 | 30 | ```python 31 | setu_apikey = '' 32 | setu_r18 = '' 33 | setu_proxy = 'disable' 34 | ``` 35 | 36 | ### 鸣谢 37 | 38 | [随机色图](https://api.lolicon.app/#/setu) 39 | -------------------------------------------------------------------------------- /plugins/setu/SetuData.py: -------------------------------------------------------------------------------- 1 | import json 2 | import asyncio 3 | import aiohttp 4 | import PIL.Image 5 | from io import BytesIO 6 | from pathlib import Path 7 | from typing import List, Set 8 | from datetime import datetime, timedelta 9 | from pydantic import BaseModel, validator, ValidationError 10 | from urllib.parse import urlparse 11 | 12 | from config import data_path, setu_apikey, setu_proxy, setu_r18 13 | 14 | Path(data_path).mkdir(exist_ok=True) 15 | SAVE_FILE = Path(data_path).joinpath('setu.json') 16 | 17 | 18 | class SetuData(BaseModel): 19 | pid: int = None 20 | p: int = None 21 | uid: int = None 22 | title: str = None 23 | author: str = None 24 | url: str 25 | r18: bool = None 26 | width: int = None 27 | height: int = None 28 | tags: List[str] = None 29 | 30 | @property 31 | def purl(self) -> str: 32 | return 'https://www.pixiv.net/artworks/{}'.format(Path(urlparse(self.url).path).stem.split('_')[0]) 33 | 34 | def __eq__(self, other): 35 | if isinstance(other, self.__class__): 36 | return self.url == other.url 37 | else: 38 | return False 39 | 40 | def __hash__(self): 41 | return hash(self.url) 42 | 43 | def save(self) -> None: 44 | """保存至文件""" 45 | SetuDatabase.save(self) 46 | 47 | async def get(self, check_size: bool = True) -> bytes: 48 | """从网络获取图像""" 49 | try: 50 | headers = {'Referer': 'https://www.pixiv.net/'} if 'i.pximg.net' in self.url else {} 51 | async with aiohttp.request('GET', self.url, headers=headers, timeout=aiohttp.ClientTimeout(10)) as resp: 52 | img_bytes: bytes = await resp.read() 53 | if check_size: 54 | img: PIL.Image.Image = PIL.Image.open(BytesIO(initial_bytes=img_bytes)) 55 | if img.size != (self.width, self.height): 56 | raise ValueError(f'Image Size Error: expected {(self.width, self.height)} but got {img.size}') 57 | except (asyncio.TimeoutError, ValueError) as e: 58 | raise e 59 | except PIL.UnidentifiedImageError: 60 | raise ValueError(f'Image load fail {str(img_bytes[:20])}...') 61 | return img_bytes 62 | 63 | 64 | class SetuDatabase(BaseModel): 65 | __root__: Set[SetuData] = set() 66 | 67 | @classmethod 68 | def load_from_file(cls) -> "SetuDatabase": 69 | try: 70 | db: SetuDatabase = cls.parse_file(SAVE_FILE) 71 | except (FileNotFoundError, json.JSONDecodeError, ValidationError): 72 | db = cls() 73 | return db 74 | 75 | def save_to_file(self) -> None: 76 | with SAVE_FILE.open('w', encoding='utf8') as f: 77 | json.dump([data.dict() for data in self.__root__], f, ensure_ascii=False, indent=2) 78 | 79 | @classmethod 80 | def save(cls, *data_array: SetuData) -> None: 81 | db: SetuDatabase = cls.load_from_file() 82 | for data in data_array: 83 | db.__root__.discard(data) 84 | db.__root__.add(data) 85 | db.save_to_file() 86 | 87 | 88 | class SetuResp(BaseModel): 89 | code: int 90 | msg: str 91 | quota: int = 0 92 | quota_min_ttl: int = 0 93 | time_to_recover: datetime = None 94 | count: int = 0 95 | data: List[SetuData] = [] 96 | 97 | @validator('time_to_recover', pre=True, always=True) 98 | def get_ttr(cls, _, values): 99 | quota_min_ttl: int = values['quota_min_ttl'] 100 | return datetime.now() + timedelta(seconds=quota_min_ttl) 101 | 102 | def save(self) -> None: 103 | SetuDatabase.save(*self.data) 104 | 105 | @staticmethod 106 | async def get(keyword='') -> "SetuResp": 107 | api_url = 'https://api.lolicon.app/setu/' 108 | params = { 109 | "apikey": setu_apikey, 110 | "r18": setu_r18, 111 | "keyword": keyword, 112 | "num": 10, 113 | "proxy": setu_proxy, 114 | "size1200": 'false' 115 | } 116 | async with aiohttp.request('GET', api_url, params=params) as response: 117 | setu_j = await response.read() 118 | resp: SetuResp = SetuResp.parse_raw(setu_j) 119 | resp.save() 120 | return resp 121 | -------------------------------------------------------------------------------- /plugins/setu/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import random 3 | import re 4 | from typing import List, Set, Union 5 | 6 | from mirai import ( 7 | Mirai, 8 | Group, Member, GroupMessage, 9 | Image, Plain, At) 10 | from mirai.event.message.chain import Source 11 | from mirai.logger import Event as EventLogger 12 | 13 | from .SetuData import SetuData, SetuResp, SetuDatabase 14 | from .._utils import CoolDown, shuzi2number 15 | 16 | cd = CoolDown(app='setu', td=20) 17 | 18 | sub_app = Mirai(f"mirai://localhost:8080/?authKey=0&qq=0") 19 | 20 | LAST_QUOTA: int = 300 21 | 22 | 23 | @sub_app.receiver("GroupMessage") 24 | async def GMHandler(app: Mirai, message: GroupMessage): 25 | match = re.match(r'(?:.*?([\d一二两三四五六七八九十]*)张|来点)?(.{0,10}?)的?色图$', message.toString()) 26 | if match: 27 | number: int = shuzi2number(match[1]) 28 | if number > 10: 29 | number = 1 30 | keyword = match[2] 31 | try: 32 | await setuExecutor(app, message, number, keyword) 33 | except Exception as e: 34 | import traceback 35 | EventLogger.error(e) 36 | EventLogger.error(traceback.format_exc()) 37 | 38 | elif message.toString() == '色图配额': 39 | await checkQuota(app, message) 40 | 41 | 42 | async def checkQuota(app: Mirai, message: GroupMessage): 43 | resp = await SetuResp.get('色图配额') 44 | await app.sendGroupMessage(group=message.sender.group, 45 | message=f'剩余配额:{resp.quota}\n恢复时间:{resp.time_to_recover.strftime("%m-%d %H:%M")}', 46 | quoteSource=message.messageChain.getSource()) 47 | 48 | 49 | async def setuExecutor(app: Mirai, message: GroupMessage, number: int, keyword: str): 50 | """根据关键词获取data_array,并调用sendSetu""" 51 | global LAST_QUOTA 52 | member_id: int = message.sender.id 53 | if keyword == '': 54 | if len(SetuDatabase.load_from_file().__root__) >= 300 and LAST_QUOTA < 200: 55 | resp = SetuResp(code=-430, msg='空关键词') 56 | else: 57 | resp = await SetuResp.get() 58 | LAST_QUOTA = resp.quota 59 | elif cd.check(member_id): 60 | resp = await SetuResp.get(keyword) 61 | LAST_QUOTA = resp.quota 62 | else: 63 | resp = SetuResp(code=-3, msg='你的请求太快了,休息一下吧') 64 | 65 | if resp.code == 0: 66 | cd.update(member_id) 67 | await sendSetu(app, message, resp.data, number) 68 | elif resp.code in [429, -430]: 69 | db = SetuDatabase.load_from_file() 70 | await sendSetu(app, message, db.__root__, number) 71 | else: 72 | group: Group = message.sender.group 73 | source: Source = message.messageChain.getSource() 74 | await app.sendGroupMessage(group, resp.msg, source) 75 | 76 | 77 | async def sendSetu(app: Mirai, message: GroupMessage, data_array: Union[Set[SetuData], List[SetuData]], number: int): 78 | """发送data_array""" 79 | sender: Member = message.sender 80 | group: Group = message.sender.group 81 | 82 | async def send(prefix_: str, data_: SetuData): 83 | try: 84 | setu_b: bytes = await data_.get() 85 | await app.sendGroupMessage(group, 86 | [At(sender.id), Plain(prefix_ + data_.purl + '\n'), Image.fromBytes(setu_b)]) 87 | EventLogger.info(f"{prefix_}色图已发送,标签:{','.join(data_.tags)}") 88 | except asyncio.TimeoutError as e: 89 | EventLogger.warn('连接超时' + str(e)) 90 | raise e 91 | except ValueError as e: 92 | EventLogger.warn('图片尺寸检查失败' + str(e)) 93 | raise e 94 | 95 | number = min(number, len(data_array)) 96 | 97 | # 延时逐个启动任务 98 | tasks: List[asyncio.Task[None]] = [] 99 | for i, data in enumerate(random.sample(data_array, k=number)): 100 | prefix = f'[{i + 1}/{number}]' if number > 1 else '' 101 | task = asyncio.create_task(send(prefix, data)) 102 | tasks.append(task) 103 | await asyncio.wait(tasks, timeout=5) 104 | 105 | # 等待返回 106 | done, pending = await asyncio.wait(tasks, timeout=10) 107 | num_exception = len([task.exception() for task in done if task.exception()]) 108 | num_timeout = len([t.cancel() for t in pending]) 109 | 110 | # 报告结果 111 | if num_exception or num_timeout: 112 | msg_timeout = f'{num_timeout}个任务超时' if num_timeout else '' 113 | msg_exception = f'{num_exception}个任务异常' if num_exception else '' 114 | msg = msg_timeout or msg_exception 115 | if msg_timeout and msg_exception: 116 | msg += ', ' + msg_exception 117 | await app.sendGroupMessage(group, msg) 118 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # kuriyama==0.3.6 2 | https://github.com/Lycreal/python-mirai/archive/refs/heads/bugfix-2021.zip 3 | 4 | pillow>=8.1.1 5 | lxml~=4.6.2 6 | aiohttp~=3.7.4 7 | pydantic~=1.6.1 8 | 9 | # pytest 10 | # pytest-asyncio 11 | # pytest-cov 12 | # coverage<5 13 | 14 | # mypy 15 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from mirai import Mirai 3 | from plugins import load_plugins 4 | 5 | if __name__ == '__main__': 6 | if len(sys.argv) >= 2: 7 | app = Mirai(sys.argv[1]) 8 | load_plugins(app) 9 | app.run() 10 | else: 11 | print(f'Usage: python3 {sys.argv[0]} mirai://localhost:8080/ws?authKey=$authKey&qq=$qq\n\n' 12 | 'Visit https://natriumlab.github.io/tutorial/hello-world.html#hello-world-2 for more details.') 13 | exit(1) 14 | --------------------------------------------------------------------------------