├── .gitignore
├── LICENSE
├── README.md
├── bot
├── adapters
│ ├── __init__.py
│ ├── icqq_bot_client.py
│ ├── init.py
│ └── utils
│ │ └── dispatch_msg.py
├── builders
│ ├── __init__.py
│ ├── build_push_msg.py
│ ├── common.py
│ ├── dyn_builder.py
│ ├── live_builder.py
│ ├── pic_builder.py
│ ├── utils
│ │ └── msg_convert.py
│ └── wb_builder.py
├── commands
│ ├── __init__.py
│ ├── add_push.py
│ ├── get_push_config.py
│ ├── help.py
│ ├── init.py
│ ├── remove_push.py
│ ├── roll_dice.py
│ └── utils
│ │ ├── crawler.py
│ │ └── get_user_auth.py
├── constants
│ ├── command.py
│ └── type.py
├── handlers
│ ├── __init__.py
│ ├── bili_dyn.py
│ ├── bili_live.py
│ ├── common.py
│ ├── init.py
│ ├── utils
│ │ └── msg_preprocess.py
│ └── weibo.py
├── tasks
│ ├── __init__.py
│ ├── init.py
│ ├── receive_push.py
│ └── send_msg.py
└── utils
│ ├── __init__.py
│ ├── config.py
│ ├── db.py
│ ├── logger.py
│ ├── model.py
│ ├── msg_queue.py
│ └── pic_process.py
├── config_sample.yaml
├── docs
└── design.md
├── fonts
├── msyh.ttc
├── notocoloremoji.ttf
└── unifont.ttf
├── main.py
└── requirements.txt
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .nox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | *.py,cover
50 | .hypothesis/
51 | .pytest_cache/
52 | cover/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | .pybuilder/
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | # For a library or package, you might want to ignore these files since the code is
87 | # intended to run in multiple environments; otherwise, check them in:
88 | # .python-version
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
94 | # install all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # poetry
98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99 | # This is especially recommended for binary packages to ensure reproducibility, and is more
100 | # commonly ignored for libraries.
101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102 | #poetry.lock
103 |
104 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
105 | __pypackages__/
106 |
107 | # Celery stuff
108 | celerybeat-schedule
109 | celerybeat.pid
110 |
111 | # SageMath parsed files
112 | *.sage.py
113 |
114 | # Environments
115 | .env
116 | .venv
117 | env/
118 | venv/
119 | ENV/
120 | env.bak/
121 | venv.bak/
122 |
123 | # Spyder project settings
124 | .spyderproject
125 | .spyproject
126 |
127 | # Rope project settings
128 | .ropeproject
129 |
130 | # mkdocs documentation
131 | /site
132 |
133 | # mypy
134 | .mypy_cache/
135 | .dmypy.json
136 | dmypy.json
137 |
138 | # Pyre type checker
139 | .pyre/
140 |
141 | # pytype static type analyzer
142 | .pytype/
143 |
144 | # Cython debug symbols
145 | cython_debug/
146 |
147 | # PyCharm
148 | # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can
149 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
150 | # and can be added to the global gitignore or merged into this file. For a more nuclear
151 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
152 | #.idea/
153 |
154 | config*.yaml
155 | !config_sample.yaml
156 | *.json
157 | /logs/
158 | /data/
159 | /test/
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU GENERAL PUBLIC LICENSE
2 | Version 3, 29 June 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 General Public License is a free, copyleft license for
11 | software and other kinds of works.
12 |
13 | The licenses for most software and other practical works are designed
14 | to take away your freedom to share and change the works. By contrast,
15 | the GNU General Public License is intended to guarantee your freedom to
16 | share and change all versions of a program--to make sure it remains free
17 | software for all its users. We, the Free Software Foundation, use the
18 | GNU General Public License for most of our software; it applies also to
19 | any other work released this way by its authors. You can apply it to
20 | your programs, too.
21 |
22 | When we speak of free software, we are referring to freedom, not
23 | price. Our General Public Licenses are designed to make sure that you
24 | have the freedom to distribute copies of free software (and charge for
25 | them if you wish), that you receive source code or can get it if you
26 | want it, that you can change the software or use pieces of it in new
27 | free programs, and that you know you can do these things.
28 |
29 | To protect your rights, we need to prevent others from denying you
30 | these rights or asking you to surrender the rights. Therefore, you have
31 | certain responsibilities if you distribute copies of the software, or if
32 | you modify it: responsibilities to respect the freedom of others.
33 |
34 | For example, if you distribute copies of such a program, whether
35 | gratis or for a fee, you must pass on to the recipients the same
36 | freedoms that you received. You must make sure that they, too, receive
37 | or can get the source code. And you must show them these terms so they
38 | know their rights.
39 |
40 | Developers that use the GNU GPL protect your rights with two steps:
41 | (1) assert copyright on the software, and (2) offer you this License
42 | giving you legal permission to copy, distribute and/or modify it.
43 |
44 | For the developers' and authors' protection, the GPL clearly explains
45 | that there is no warranty for this free software. For both users' and
46 | authors' sake, the GPL requires that modified versions be marked as
47 | changed, so that their problems will not be attributed erroneously to
48 | authors of previous versions.
49 |
50 | Some devices are designed to deny users access to install or run
51 | modified versions of the software inside them, although the manufacturer
52 | can do so. This is fundamentally incompatible with the aim of
53 | protecting users' freedom to change the software. The systematic
54 | pattern of such abuse occurs in the area of products for individuals to
55 | use, which is precisely where it is most unacceptable. Therefore, we
56 | have designed this version of the GPL to prohibit the practice for those
57 | products. If such problems arise substantially in other domains, we
58 | stand ready to extend this provision to those domains in future versions
59 | of the GPL, as needed to protect the freedom of users.
60 |
61 | Finally, every program is threatened constantly by software patents.
62 | States should not allow patents to restrict development and use of
63 | software on general-purpose computers, but in those that do, we wish to
64 | avoid the special danger that patents applied to a free program could
65 | make it effectively proprietary. To prevent this, the GPL assures that
66 | patents cannot be used to render the program non-free.
67 |
68 | The precise terms and conditions for copying, distribution and
69 | modification follow.
70 |
71 | TERMS AND CONDITIONS
72 |
73 | 0. Definitions.
74 |
75 | "This License" refers to version 3 of the GNU General Public License.
76 |
77 | "Copyright" also means copyright-like laws that apply to other kinds of
78 | works, such as semiconductor masks.
79 |
80 | "The Program" refers to any copyrightable work licensed under this
81 | License. Each licensee is addressed as "you". "Licensees" and
82 | "recipients" may be individuals or organizations.
83 |
84 | To "modify" a work means to copy from or adapt all or part of the work
85 | in a fashion requiring copyright permission, other than the making of an
86 | exact copy. The resulting work is called a "modified version" of the
87 | earlier work or a work "based on" the earlier work.
88 |
89 | A "covered work" means either the unmodified Program or a work based
90 | on the Program.
91 |
92 | To "propagate" a work means to do anything with it that, without
93 | permission, would make you directly or secondarily liable for
94 | infringement under applicable copyright law, except executing it on a
95 | computer or modifying a private copy. Propagation includes copying,
96 | distribution (with or without modification), making available to the
97 | public, and in some countries other activities as well.
98 |
99 | To "convey" a work means any kind of propagation that enables other
100 | parties to make or receive copies. Mere interaction with a user through
101 | a computer network, with no transfer of a copy, is not conveying.
102 |
103 | An interactive user interface displays "Appropriate Legal Notices"
104 | to the extent that it includes a convenient and prominently visible
105 | feature that (1) displays an appropriate copyright notice, and (2)
106 | tells the user that there is no warranty for the work (except to the
107 | extent that warranties are provided), that licensees may convey the
108 | work under this License, and how to view a copy of this License. If
109 | the interface presents a list of user commands or options, such as a
110 | menu, a prominent item in the list meets this criterion.
111 |
112 | 1. Source Code.
113 |
114 | The "source code" for a work means the preferred form of the work
115 | for making modifications to it. "Object code" means any non-source
116 | form of a work.
117 |
118 | A "Standard Interface" means an interface that either is an official
119 | standard defined by a recognized standards body, or, in the case of
120 | interfaces specified for a particular programming language, one that
121 | is widely used among developers working in that language.
122 |
123 | The "System Libraries" of an executable work include anything, other
124 | than the work as a whole, that (a) is included in the normal form of
125 | packaging a Major Component, but which is not part of that Major
126 | Component, and (b) serves only to enable use of the work with that
127 | Major Component, or to implement a Standard Interface for which an
128 | implementation is available to the public in source code form. A
129 | "Major Component", in this context, means a major essential component
130 | (kernel, window system, and so on) of the specific operating system
131 | (if any) on which the executable work runs, or a compiler used to
132 | produce the work, or an object code interpreter used to run it.
133 |
134 | The "Corresponding Source" for a work in object code form means all
135 | the source code needed to generate, install, and (for an executable
136 | work) run the object code and to modify the work, including scripts to
137 | control those activities. However, it does not include the work's
138 | System Libraries, or general-purpose tools or generally available free
139 | programs which are used unmodified in performing those activities but
140 | which are not part of the work. For example, Corresponding Source
141 | includes interface definition files associated with source files for
142 | the work, and the source code for shared libraries and dynamically
143 | linked subprograms that the work is specifically designed to require,
144 | such as by intimate data communication or control flow between those
145 | subprograms and other parts of the work.
146 |
147 | The Corresponding Source need not include anything that users
148 | can regenerate automatically from other parts of the Corresponding
149 | Source.
150 |
151 | The Corresponding Source for a work in source code form is that
152 | same work.
153 |
154 | 2. Basic Permissions.
155 |
156 | All rights granted under this License are granted for the term of
157 | copyright on the Program, and are irrevocable provided the stated
158 | conditions are met. This License explicitly affirms your unlimited
159 | permission to run the unmodified Program. The output from running a
160 | covered work is covered by this License only if the output, given its
161 | content, constitutes a covered work. This License acknowledges your
162 | rights of fair use or other equivalent, as provided by copyright law.
163 |
164 | You may make, run and propagate covered works that you do not
165 | convey, without conditions so long as your license otherwise remains
166 | in force. You may convey covered works to others for the sole purpose
167 | of having them make modifications exclusively for you, or provide you
168 | with facilities for running those works, provided that you comply with
169 | the terms of this License in conveying all material for which you do
170 | not control copyright. Those thus making or running the covered works
171 | for you must do so exclusively on your behalf, under your direction
172 | and control, on terms that prohibit them from making any copies of
173 | your copyrighted material outside their relationship with you.
174 |
175 | Conveying under any other circumstances is permitted solely under
176 | the conditions stated below. Sublicensing is not allowed; section 10
177 | makes it unnecessary.
178 |
179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
180 |
181 | No covered work shall be deemed part of an effective technological
182 | measure under any applicable law fulfilling obligations under article
183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
184 | similar laws prohibiting or restricting circumvention of such
185 | measures.
186 |
187 | When you convey a covered work, you waive any legal power to forbid
188 | circumvention of technological measures to the extent such circumvention
189 | is effected by exercising rights under this License with respect to
190 | the covered work, and you disclaim any intention to limit operation or
191 | modification of the work as a means of enforcing, against the work's
192 | users, your or third parties' legal rights to forbid circumvention of
193 | technological measures.
194 |
195 | 4. Conveying Verbatim Copies.
196 |
197 | You may convey verbatim copies of the Program's source code as you
198 | receive it, in any medium, provided that you conspicuously and
199 | appropriately publish on each copy an appropriate copyright notice;
200 | keep intact all notices stating that this License and any
201 | non-permissive terms added in accord with section 7 apply to the code;
202 | keep intact all notices of the absence of any warranty; and give all
203 | recipients a copy of this License along with the Program.
204 |
205 | You may charge any price or no price for each copy that you convey,
206 | and you may offer support or warranty protection for a fee.
207 |
208 | 5. Conveying Modified Source Versions.
209 |
210 | You may convey a work based on the Program, or the modifications to
211 | produce it from the Program, in the form of source code under the
212 | terms of section 4, provided that you also meet all of these conditions:
213 |
214 | a) The work must carry prominent notices stating that you modified
215 | it, and giving a relevant date.
216 |
217 | b) The work must carry prominent notices stating that it is
218 | released under this License and any conditions added under section
219 | 7. This requirement modifies the requirement in section 4 to
220 | "keep intact all notices".
221 |
222 | c) You must license the entire work, as a whole, under this
223 | License to anyone who comes into possession of a copy. This
224 | License will therefore apply, along with any applicable section 7
225 | additional terms, to the whole of the work, and all its parts,
226 | regardless of how they are packaged. This License gives no
227 | permission to license the work in any other way, but it does not
228 | invalidate such permission if you have separately received it.
229 |
230 | d) If the work has interactive user interfaces, each must display
231 | Appropriate Legal Notices; however, if the Program has interactive
232 | interfaces that do not display Appropriate Legal Notices, your
233 | work need not make them do so.
234 |
235 | A compilation of a covered work with other separate and independent
236 | works, which are not by their nature extensions of the covered work,
237 | and which are not combined with it such as to form a larger program,
238 | in or on a volume of a storage or distribution medium, is called an
239 | "aggregate" if the compilation and its resulting copyright are not
240 | used to limit the access or legal rights of the compilation's users
241 | beyond what the individual works permit. Inclusion of a covered work
242 | in an aggregate does not cause this License to apply to the other
243 | parts of the aggregate.
244 |
245 | 6. Conveying Non-Source Forms.
246 |
247 | You may convey a covered work in object code form under the terms
248 | of sections 4 and 5, provided that you also convey the
249 | machine-readable Corresponding Source under the terms of this License,
250 | in one of these ways:
251 |
252 | a) Convey the object code in, or embodied in, a physical product
253 | (including a physical distribution medium), accompanied by the
254 | Corresponding Source fixed on a durable physical medium
255 | customarily used for software interchange.
256 |
257 | b) Convey the object code in, or embodied in, a physical product
258 | (including a physical distribution medium), accompanied by a
259 | written offer, valid for at least three years and valid for as
260 | long as you offer spare parts or customer support for that product
261 | model, to give anyone who possesses the object code either (1) a
262 | copy of the Corresponding Source for all the software in the
263 | product that is covered by this License, on a durable physical
264 | medium customarily used for software interchange, for a price no
265 | more than your reasonable cost of physically performing this
266 | conveying of source, or (2) access to copy the
267 | Corresponding Source from a network server at no charge.
268 |
269 | c) Convey individual copies of the object code with a copy of the
270 | written offer to provide the Corresponding Source. This
271 | alternative is allowed only occasionally and noncommercially, and
272 | only if you received the object code with such an offer, in accord
273 | with subsection 6b.
274 |
275 | d) Convey the object code by offering access from a designated
276 | place (gratis or for a charge), and offer equivalent access to the
277 | Corresponding Source in the same way through the same place at no
278 | further charge. You need not require recipients to copy the
279 | Corresponding Source along with the object code. If the place to
280 | copy the object code is a network server, the Corresponding Source
281 | may be on a different server (operated by you or a third party)
282 | that supports equivalent copying facilities, provided you maintain
283 | clear directions next to the object code saying where to find the
284 | Corresponding Source. Regardless of what server hosts the
285 | Corresponding Source, you remain obligated to ensure that it is
286 | available for as long as needed to satisfy these requirements.
287 |
288 | e) Convey the object code using peer-to-peer transmission, provided
289 | you inform other peers where the object code and Corresponding
290 | Source of the work are being offered to the general public at no
291 | charge under subsection 6d.
292 |
293 | A separable portion of the object code, whose source code is excluded
294 | from the Corresponding Source as a System Library, need not be
295 | included in conveying the object code work.
296 |
297 | A "User Product" is either (1) a "consumer product", which means any
298 | tangible personal property which is normally used for personal, family,
299 | or household purposes, or (2) anything designed or sold for incorporation
300 | into a dwelling. In determining whether a product is a consumer product,
301 | doubtful cases shall be resolved in favor of coverage. For a particular
302 | product received by a particular user, "normally used" refers to a
303 | typical or common use of that class of product, regardless of the status
304 | of the particular user or of the way in which the particular user
305 | actually uses, or expects or is expected to use, the product. A product
306 | is a consumer product regardless of whether the product has substantial
307 | commercial, industrial or non-consumer uses, unless such uses represent
308 | the only significant mode of use of the product.
309 |
310 | "Installation Information" for a User Product means any methods,
311 | procedures, authorization keys, or other information required to install
312 | and execute modified versions of a covered work in that User Product from
313 | a modified version of its Corresponding Source. The information must
314 | suffice to ensure that the continued functioning of the modified object
315 | code is in no case prevented or interfered with solely because
316 | modification has been made.
317 |
318 | If you convey an object code work under this section in, or with, or
319 | specifically for use in, a User Product, and the conveying occurs as
320 | part of a transaction in which the right of possession and use of the
321 | User Product is transferred to the recipient in perpetuity or for a
322 | fixed term (regardless of how the transaction is characterized), the
323 | Corresponding Source conveyed under this section must be accompanied
324 | by the Installation Information. But this requirement does not apply
325 | if neither you nor any third party retains the ability to install
326 | modified object code on the User Product (for example, the work has
327 | been installed in ROM).
328 |
329 | The requirement to provide Installation Information does not include a
330 | requirement to continue to provide support service, warranty, or updates
331 | for a work that has been modified or installed by the recipient, or for
332 | the User Product in which it has been modified or installed. Access to a
333 | network may be denied when the modification itself materially and
334 | adversely affects the operation of the network or violates the rules and
335 | protocols for communication across the network.
336 |
337 | Corresponding Source conveyed, and Installation Information provided,
338 | in accord with this section must be in a format that is publicly
339 | documented (and with an implementation available to the public in
340 | source code form), and must require no special password or key for
341 | unpacking, reading or copying.
342 |
343 | 7. Additional Terms.
344 |
345 | "Additional permissions" are terms that supplement the terms of this
346 | License by making exceptions from one or more of its conditions.
347 | Additional permissions that are applicable to the entire Program shall
348 | be treated as though they were included in this License, to the extent
349 | that they are valid under applicable law. If additional permissions
350 | apply only to part of the Program, that part may be used separately
351 | under those permissions, but the entire Program remains governed by
352 | this License without regard to the additional permissions.
353 |
354 | When you convey a copy of a covered work, you may at your option
355 | remove any additional permissions from that copy, or from any part of
356 | it. (Additional permissions may be written to require their own
357 | removal in certain cases when you modify the work.) You may place
358 | additional permissions on material, added by you to a covered work,
359 | for which you have or can give appropriate copyright permission.
360 |
361 | Notwithstanding any other provision of this License, for material you
362 | add to a covered work, you may (if authorized by the copyright holders of
363 | that material) supplement the terms of this License with terms:
364 |
365 | a) Disclaiming warranty or limiting liability differently from the
366 | terms of sections 15 and 16 of this License; or
367 |
368 | b) Requiring preservation of specified reasonable legal notices or
369 | author attributions in that material or in the Appropriate Legal
370 | Notices displayed by works containing it; or
371 |
372 | c) Prohibiting misrepresentation of the origin of that material, or
373 | requiring that modified versions of such material be marked in
374 | reasonable ways as different from the original version; or
375 |
376 | d) Limiting the use for publicity purposes of names of licensors or
377 | authors of the material; or
378 |
379 | e) Declining to grant rights under trademark law for use of some
380 | trade names, trademarks, or service marks; or
381 |
382 | f) Requiring indemnification of licensors and authors of that
383 | material by anyone who conveys the material (or modified versions of
384 | it) with contractual assumptions of liability to the recipient, for
385 | any liability that these contractual assumptions directly impose on
386 | those licensors and authors.
387 |
388 | All other non-permissive additional terms are considered "further
389 | restrictions" within the meaning of section 10. If the Program as you
390 | received it, or any part of it, contains a notice stating that it is
391 | governed by this License along with a term that is a further
392 | restriction, you may remove that term. If a license document contains
393 | a further restriction but permits relicensing or conveying under this
394 | License, you may add to a covered work material governed by the terms
395 | of that license document, provided that the further restriction does
396 | not survive such relicensing or conveying.
397 |
398 | If you add terms to a covered work in accord with this section, you
399 | must place, in the relevant source files, a statement of the
400 | additional terms that apply to those files, or a notice indicating
401 | where to find the applicable terms.
402 |
403 | Additional terms, permissive or non-permissive, may be stated in the
404 | form of a separately written license, or stated as exceptions;
405 | the above requirements apply either way.
406 |
407 | 8. Termination.
408 |
409 | You may not propagate or modify a covered work except as expressly
410 | provided under this License. Any attempt otherwise to propagate or
411 | modify it is void, and will automatically terminate your rights under
412 | this License (including any patent licenses granted under the third
413 | paragraph of section 11).
414 |
415 | However, if you cease all violation of this License, then your
416 | license from a particular copyright holder is reinstated (a)
417 | provisionally, unless and until the copyright holder explicitly and
418 | finally terminates your license, and (b) permanently, if the copyright
419 | holder fails to notify you of the violation by some reasonable means
420 | prior to 60 days after the cessation.
421 |
422 | Moreover, your license from a particular copyright holder is
423 | reinstated permanently if the copyright holder notifies you of the
424 | violation by some reasonable means, this is the first time you have
425 | received notice of violation of this License (for any work) from that
426 | copyright holder, and you cure the violation prior to 30 days after
427 | your receipt of the notice.
428 |
429 | Termination of your rights under this section does not terminate the
430 | licenses of parties who have received copies or rights from you under
431 | this License. If your rights have been terminated and not permanently
432 | reinstated, you do not qualify to receive new licenses for the same
433 | material under section 10.
434 |
435 | 9. Acceptance Not Required for Having Copies.
436 |
437 | You are not required to accept this License in order to receive or
438 | run a copy of the Program. Ancillary propagation of a covered work
439 | occurring solely as a consequence of using peer-to-peer transmission
440 | to receive a copy likewise does not require acceptance. However,
441 | nothing other than this License grants you permission to propagate or
442 | modify any covered work. These actions infringe copyright if you do
443 | not accept this License. Therefore, by modifying or propagating a
444 | covered work, you indicate your acceptance of this License to do so.
445 |
446 | 10. Automatic Licensing of Downstream Recipients.
447 |
448 | Each time you convey a covered work, the recipient automatically
449 | receives a license from the original licensors, to run, modify and
450 | propagate that work, subject to this License. You are not responsible
451 | for enforcing compliance by third parties with this License.
452 |
453 | An "entity transaction" is a transaction transferring control of an
454 | organization, or substantially all assets of one, or subdividing an
455 | organization, or merging organizations. If propagation of a covered
456 | work results from an entity transaction, each party to that
457 | transaction who receives a copy of the work also receives whatever
458 | licenses to the work the party's predecessor in interest had or could
459 | give under the previous paragraph, plus a right to possession of the
460 | Corresponding Source of the work from the predecessor in interest, if
461 | the predecessor has it or can get it with reasonable efforts.
462 |
463 | You may not impose any further restrictions on the exercise of the
464 | rights granted or affirmed under this License. For example, you may
465 | not impose a license fee, royalty, or other charge for exercise of
466 | rights granted under this License, and you may not initiate litigation
467 | (including a cross-claim or counterclaim in a lawsuit) alleging that
468 | any patent claim is infringed by making, using, selling, offering for
469 | sale, or importing the Program or any portion of it.
470 |
471 | 11. Patents.
472 |
473 | A "contributor" is a copyright holder who authorizes use under this
474 | License of the Program or a work on which the Program is based. The
475 | work thus licensed is called the contributor's "contributor version".
476 |
477 | A contributor's "essential patent claims" are all patent claims
478 | owned or controlled by the contributor, whether already acquired or
479 | hereafter acquired, that would be infringed by some manner, permitted
480 | by this License, of making, using, or selling its contributor version,
481 | but do not include claims that would be infringed only as a
482 | consequence of further modification of the contributor version. For
483 | purposes of this definition, "control" includes the right to grant
484 | patent sublicenses in a manner consistent with the requirements of
485 | this License.
486 |
487 | Each contributor grants you a non-exclusive, worldwide, royalty-free
488 | patent license under the contributor's essential patent claims, to
489 | make, use, sell, offer for sale, import and otherwise run, modify and
490 | propagate the contents of its contributor version.
491 |
492 | In the following three paragraphs, a "patent license" is any express
493 | agreement or commitment, however denominated, not to enforce a patent
494 | (such as an express permission to practice a patent or covenant not to
495 | sue for patent infringement). To "grant" such a patent license to a
496 | party means to make such an agreement or commitment not to enforce a
497 | patent against the party.
498 |
499 | If you convey a covered work, knowingly relying on a patent license,
500 | and the Corresponding Source of the work is not available for anyone
501 | to copy, free of charge and under the terms of this License, through a
502 | publicly available network server or other readily accessible means,
503 | then you must either (1) cause the Corresponding Source to be so
504 | available, or (2) arrange to deprive yourself of the benefit of the
505 | patent license for this particular work, or (3) arrange, in a manner
506 | consistent with the requirements of this License, to extend the patent
507 | license to downstream recipients. "Knowingly relying" means you have
508 | actual knowledge that, but for the patent license, your conveying the
509 | covered work in a country, or your recipient's use of the covered work
510 | in a country, would infringe one or more identifiable patents in that
511 | country that you have reason to believe are valid.
512 |
513 | If, pursuant to or in connection with a single transaction or
514 | arrangement, you convey, or propagate by procuring conveyance of, a
515 | covered work, and grant a patent license to some of the parties
516 | receiving the covered work authorizing them to use, propagate, modify
517 | or convey a specific copy of the covered work, then the patent license
518 | you grant is automatically extended to all recipients of the covered
519 | work and works based on it.
520 |
521 | A patent license is "discriminatory" if it does not include within
522 | the scope of its coverage, prohibits the exercise of, or is
523 | conditioned on the non-exercise of one or more of the rights that are
524 | specifically granted under this License. You may not convey a covered
525 | work if you are a party to an arrangement with a third party that is
526 | in the business of distributing software, under which you make payment
527 | to the third party based on the extent of your activity of conveying
528 | the work, and under which the third party grants, to any of the
529 | parties who would receive the covered work from you, a discriminatory
530 | patent license (a) in connection with copies of the covered work
531 | conveyed by you (or copies made from those copies), or (b) primarily
532 | for and in connection with specific products or compilations that
533 | contain the covered work, unless you entered into that arrangement,
534 | or that patent license was granted, prior to 28 March 2007.
535 |
536 | Nothing in this License shall be construed as excluding or limiting
537 | any implied license or other defenses to infringement that may
538 | otherwise be available to you under applicable patent law.
539 |
540 | 12. No Surrender of Others' Freedom.
541 |
542 | If conditions are imposed on you (whether by court order, agreement or
543 | otherwise) that contradict the conditions of this License, they do not
544 | excuse you from the conditions of this License. If you cannot convey a
545 | covered work so as to satisfy simultaneously your obligations under this
546 | License and any other pertinent obligations, then as a consequence you may
547 | not convey it at all. For example, if you agree to terms that obligate you
548 | to collect a royalty for further conveying from those to whom you convey
549 | the Program, the only way you could satisfy both those terms and this
550 | License would be to refrain entirely from conveying the Program.
551 |
552 | 13. Use with the GNU Affero General Public License.
553 |
554 | Notwithstanding any other provision of this License, you have
555 | permission to link or combine any covered work with a work licensed
556 | under version 3 of the GNU Affero General Public License into a single
557 | combined work, and to convey the resulting work. The terms of this
558 | License will continue to apply to the part which is the covered work,
559 | but the special requirements of the GNU Affero General Public License,
560 | section 13, concerning interaction through a network will apply to the
561 | combination as such.
562 |
563 | 14. Revised Versions of this License.
564 |
565 | The Free Software Foundation may publish revised and/or new versions of
566 | the GNU General Public License from time to time. Such new versions will
567 | be similar in spirit to the present version, but may differ in detail to
568 | address new problems or concerns.
569 |
570 | Each version is given a distinguishing version number. If the
571 | Program specifies that a certain numbered version of the GNU General
572 | Public License "or any later version" applies to it, you have the
573 | option of following the terms and conditions either of that numbered
574 | version or of any later version published by the Free Software
575 | Foundation. If the Program does not specify a version number of the
576 | GNU General Public License, you may choose any version ever published
577 | by the Free Software Foundation.
578 |
579 | If the Program specifies that a proxy can decide which future
580 | versions of the GNU General Public License can be used, that proxy's
581 | public statement of acceptance of a version permanently authorizes you
582 | to choose that version for the Program.
583 |
584 | Later license versions may give you additional or different
585 | permissions. However, no additional obligations are imposed on any
586 | author or copyright holder as a result of your choosing to follow a
587 | later version.
588 |
589 | 15. Disclaimer of Warranty.
590 |
591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
599 |
600 | 16. Limitation of Liability.
601 |
602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
610 | SUCH DAMAGES.
611 |
612 | 17. Interpretation of Sections 15 and 16.
613 |
614 | If the disclaimer of warranty and limitation of liability provided
615 | above cannot be given local legal effect according to their terms,
616 | reviewing courts shall apply local law that most closely approximates
617 | an absolute waiver of all civil liability in connection with the
618 | Program, unless a warranty or assumption of liability accompanies a
619 | copy of the Program in return for a fee.
620 |
621 | END OF TERMS AND CONDITIONS
622 |
623 | How to Apply These Terms to Your New Programs
624 |
625 | If you develop a new program, and you want it to be of the greatest
626 | possible use to the public, the best way to achieve this is to make it
627 | free software which everyone can redistribute and change under these terms.
628 |
629 | To do so, attach the following notices to the program. It is safest
630 | to attach them to the start of each source file to most effectively
631 | state the exclusion of warranty; and each file should have at least
632 | the "copyright" line and a pointer to where the full notice is found.
633 |
634 |
635 | Copyright (C)
636 |
637 | This program is free software: you can redistribute it and/or modify
638 | it under the terms of the GNU General Public License as published by
639 | the Free Software Foundation, either version 3 of the License, or
640 | (at your option) any later version.
641 |
642 | This program is distributed in the hope that it will be useful,
643 | but WITHOUT ANY WARRANTY; without even the implied warranty of
644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
645 | GNU General Public License for more details.
646 |
647 | You should have received a copy of the GNU General Public License
648 | along with this program. If not, see .
649 |
650 | Also add information on how to contact you by electronic and paper mail.
651 |
652 | If the program does terminal interaction, make it output a short
653 | notice like this when it starts in an interactive mode:
654 |
655 | Copyright (C)
656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657 | This is free software, and you are welcome to redistribute it
658 | under certain conditions; type `show c' for details.
659 |
660 | The hypothetical commands `show w' and `show c' should show the appropriate
661 | parts of the General Public License. Of course, your program's commands
662 | might be different; for a GUI interface, you would use an "about box".
663 |
664 | You should also get your employer (if you work as a programmer) or school,
665 | if any, to sign a "copyright disclaimer" for the program, if necessary.
666 | For more information on this, and how to apply and follow the GNU GPL, see
667 | .
668 |
669 | The GNU General Public License does not permit incorporating your program
670 | into proprietary programs. If your program is a subroutine library, you
671 | may consider it more useful to permit linking proprietary applications with
672 | the library. If this is what you want to do, use the GNU Lesser General
673 | Public License instead of this License. But first, please read
674 | .
675 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Dynamic Bot
2 |
3 | 多平台,多Bot实例的动态推送Bot框架
4 |
5 | ## 使用
6 | ### 环境配置
7 | 推荐使用Python 3.8
8 | ```shell
9 | pip install -r requirements.txt
10 | playwright install --with-deps chromium
11 | ```
12 |
13 | ### Crawler配置
14 | 本Bot设计为配合[Dynamic-Crawler](https://github.com/Cloud-wish/Dynamic-Crawler)使用,请事先部署好Crawler服务
15 |
16 | ### 修改配置文件
17 | 配置文件应命名为config.yaml,参考config_sample.yaml修改为想要的配置后启动
18 |
19 | ### 启动
20 | `python main.py`
21 |
22 | ## 功能扩展
23 | 参考[架构设计](https://github.com/Cloud-wish/Dynamic-Bot/blob/main/docs/design.md)
24 |
25 | ## 问题
26 | ### 指令列表
27 | - Bot启动后,群主/管理员在群聊内发送“帮助”即可显示指令列表与使用方法
28 | - [command.py](https://github.com/Cloud-wish/Dynamic-Bot/blob/main/bot/constants/command.py)中的`BOT_HELP`
29 |
30 | ### 动态图片字体显示异常
31 | 如果B站动态/微博截图出现字体显示出错/缺失的情况,是因为系统中未安装font文件夹下的字体,安装后即可正常显示
32 |
33 | ### 其它Bug
34 | Bug,功能建议或疑问,欢迎提[Issues](https://github.com/Cloud-wish/Dynamic-Bot/issues)
--------------------------------------------------------------------------------
/bot/adapters/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cloud-wish/Dynamic-Bot/f78506629658fad416faf21c8840c3af3e688015/bot/adapters/__init__.py
--------------------------------------------------------------------------------
/bot/adapters/icqq_bot_client.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import copy
3 | import httpx
4 | import traceback
5 | import websockets
6 | import json
7 | from functools import partial
8 |
9 | from ..utils.logger import get_logger
10 | from ..utils.model import BotType, Message, MessageType, MessageSender
11 | from ..utils.config import get_config_value
12 | from ..commands.init import is_command
13 |
14 | from .utils.dispatch_msg import dispatch_msg
15 | from .init import get_adapter_table
16 |
17 | logger = get_logger()
18 | adapters = get_adapter_table()
19 |
20 | async def receive_icqq_msg(message: dict[str], bot_config: dict[str], bot_id: str):
21 | # if(config_dict["bot"]["guild_enable"] and message.get('message_type', "") == 'guild' and message.get('sub_type', "") == 'channel'):
22 | # # 频道消息
23 | # content: str = message['message'].rstrip()
24 | # channel: tuple[str] = (str(message['guild_id']), str(message['channel_id']))
25 | # bot_uid = config_dict["bot"]["guild_id"]
26 | # print(message)
27 | if(message.get('message_type', "") == 'group' and message.get('sub_type', "") == 'normal'):
28 | # 群聊消息
29 | if message.get('anonymous', None):
30 | return
31 | guild_id = channel_id = str(message['group_id'])
32 | msg_type = MessageType.GROUP
33 | if "self" in message:
34 | bot_uid = str(message["self"]["user_id"])
35 | elif "self_id" in message: # 适配go-cqhttp
36 | bot_uid = str(message["self_id"])
37 | else:
38 | logger.error(f"群消息中未获取到Bot ID! 消息内容:{json.dumps(message)}")
39 | return
40 | else:
41 | return
42 | if(message['message'][0]['type'] == 'at' and message['message'][0]['data']['qq'] == bot_uid
43 | and len(message['message']) > 1 and message['message'][1]['type'] == 'text'):
44 | # 是at bot的消息
45 | content = message['message'][1]['data']['text'].lstrip()
46 | if is_command(content):
47 | user_id = str(message['sender']['user_id'])
48 | if msg_type == MessageType.GUILD:
49 | logger.debug(f"bot:{bot_id} 收到来自频道{guild_id}的子频道{channel_id}的{message['sender']['nickname']}的消息:{content}")
50 | elif msg_type == MessageType.GROUP:
51 | logger.debug(f"bot:{bot_id} 收到来自群聊{guild_id}的消息:{content}")
52 | received_msg = Message(
53 | content=content,
54 | guild_id=guild_id,
55 | channel_id=channel_id,
56 | msg_type=msg_type,
57 | bot_type=BotType.ICQQ,
58 | bot_id=bot_id,
59 | sender=MessageSender(
60 | user_id=user_id,
61 | nickname=message['sender']['nickname'],
62 | raw=message['sender']
63 | ),
64 | raw=message
65 | )
66 | await dispatch_msg(received_msg)
67 |
68 | @adapters.adapter(BotType.ICQQ)
69 | async def icqq_bot_client(bot_config: dict, bot_id: str):
70 | await websockets.serve(partial(receiver, bot_config=bot_config, bot_id=bot_id), bot_config["websocket"]["host"], bot_config["websocket"]["port"])
71 |
72 | async def receiver(websocket, bot_config: dict[str], bot_id: str):
73 | ws_relay_conn = None
74 | logger.info(f"bot:{bot_id} 成功建立与icqq的Websocket连接")
75 | try:
76 | async for message in websocket:
77 | if(bot_config["websocket"]["relay"]["enable"]):
78 | retry_count = bot_config["websocket"]["relay"]["retry_count"]
79 | relay_url = bot_config["websocket"]["relay"]["url"]
80 | for i in range(retry_count + 1):
81 | try:
82 | if(ws_relay_conn is None):
83 | ws_relay_conn = await websockets.connect(relay_url)
84 | logger.info(f"bot:{bot_id} 成功建立与转发消息接收端的Websocket连接")
85 | await ws_relay_conn.send(message)
86 | # logger.debug(f"bot:{bot_id} 向转发消息接收端发送了一条消息: {message}")
87 | break
88 | except Exception as e:
89 | logger.error(f"bot:{bot_id} 与转发消息接收端的Websocket连接出错!错误信息:\n{str(e)}")
90 | if(i < retry_count):
91 | logger.error(f"bot:{bot_id} 尝试第{i + 1}次重新连接转发消息接收端...")
92 | else:
93 | logger.error(f"bot:{bot_id} 重连次数超过限制, 放弃重连!消息内容: \n{message}")
94 | try:
95 | await ws_relay_conn.close()
96 | except:
97 | pass
98 | ws_relay_conn = None
99 | # logger.debug(message)
100 | data = json.loads(message)
101 | if(data.get("post_type", "") == "message"):
102 | await receive_icqq_msg(data, bot_config, bot_id)
103 | except Exception as e:
104 | logger.error(f"bot:{bot_id} 与icqq的Websocket连接出错! 错误信息:\n{traceback.format_exc()}")
105 | finally:
106 | if(not ws_relay_conn is None):
107 | await ws_relay_conn.close()
108 |
109 | async def icqq_send_group_msg(msg):
110 | send_msg = {
111 | "group_id": msg["guild_id"],
112 | "message": msg["data"]
113 | }
114 | if get_config_value("logger", "debug"):
115 | _send_msg = copy.deepcopy(send_msg)
116 | for data in _send_msg["message"]:
117 | if "file" in data.get("data", {}):
118 | data["data"]["file"] = f'size:{len(data["data"]["file"])}'
119 | logger.debug(f"bot:{msg['bot_id']} 要发送的消息内容:\n{json.dumps(_send_msg, ensure_ascii=False)}")
120 | if get_config_value("sender", "debug"):
121 | logger.debug(f"bot:{msg['bot_id']} 已设置debug模式, 消息不会真正发送")
122 | return
123 | try:
124 | http_api = get_config_value("bots", msg["bot_id"], "api")
125 | timeout = 15 # default
126 | try:
127 | timeout = float(get_config_value("sender", "timeout"))
128 | except:
129 | logger.info(f"发送消息请求超时时间设置不合法, 使用默认值 (15s)")
130 | pass
131 | async with httpx.AsyncClient() as client:
132 | resp = await client.post(f"{http_api}/send_group_msg", json = send_msg, timeout = timeout)
133 | try:
134 | resp = resp.json()
135 | if(resp['retcode'] != 0):
136 | logger.error(f"bot:{msg['bot_id']} 群聊{msg['guild_id']}消息发送失败!\ncode:{resp['retcode']} 错误信息:{resp}")
137 | except json.decoder.JSONDecodeError:
138 | logger.error(f"bot:{msg['bot_id']} 群聊{msg['guild_id']}消息发送出错!\n错误信息:\n{resp.text}")
139 | except httpx.ReadTimeout:
140 | logger.error(f"bot:{msg['bot_id']} 群聊{msg['guild_id']}消息发送超时!")
141 | except:
142 | logger.error(f"bot:{msg['bot_id']} 群聊{msg['guild_id']}消息发送出错!\n错误信息:\n{traceback.format_exc()}")
143 |
--------------------------------------------------------------------------------
/bot/adapters/init.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | from typing import Any, Coroutine
3 | import importlib
4 | import os
5 |
6 | from ..utils.model import BotType
7 |
8 | def auto_import(directory_path):
9 | """
10 | 导入指定目录下的所有 .py 文件
11 |
12 | :param directory_path: 目录路径
13 | """
14 | # 获取指定目录中的所有文件
15 | file_list = os.listdir(directory_path)
16 |
17 | # 遍历目录中的所有文件
18 | for filename in file_list:
19 | if filename.endswith('.py') and filename != '__init__.py' and filename != 'init.py':
20 | module_name = filename[:-3] # 去除文件扩展名
21 | importlib.import_module("." + module_name, __package__)
22 |
23 | class AdapterTableDef():
24 |
25 | def __init__(self) -> None:
26 | self._adapter_dict: dict[BotType, Coroutine] = dict()
27 |
28 | def adapter(self, adapter_name: BotType):
29 | def inner(adapter_func):
30 | self._adapter_dict[adapter_name] = adapter_func
31 | return adapter_func
32 | return inner
33 |
34 | def add(self, adapter_name: BotType, adapter_func: Coroutine):
35 | self._adapter_dict[adapter_name] = adapter_func
36 |
37 | def remove(self, adapter_name: BotType):
38 | del self._adapter_dict[adapter_name]
39 |
40 | def get_all_adapters(self) -> list[Coroutine]:
41 | return list(self._adapter_dict.values())
42 |
43 | def get_adapter(self, adapter_name: BotType) -> Coroutine | None:
44 | return self._adapter_dict.get(adapter_name)
45 |
46 | def is_adapter_exist(self, adapter_name: BotType) -> bool:
47 | return adapter_name in self._adapter_dict
48 |
49 | adapters = AdapterTableDef()
50 |
51 | def get_adapter_table():
52 | return adapters
53 |
54 | def get_adapter(adapter_name: BotType):
55 | return adapters.get_adapter(adapter_name)
56 |
57 | def is_adapter_exist(adapter_name: BotType):
58 | return adapters.is_adapter_exist(adapter_name)
59 |
60 | auto_import(os.path.dirname(__file__))
61 |
62 | from . import *
--------------------------------------------------------------------------------
/bot/adapters/utils/dispatch_msg.py:
--------------------------------------------------------------------------------
1 | import traceback
2 |
3 | from ...utils.logger import get_logger
4 | from ...utils.model import Message
5 | from ...commands.init import CommandNotFoundException
6 | from ...commands.init import get_command_table
7 |
8 | logger = get_logger()
9 | commands = get_command_table()
10 |
11 | async def dispatch_msg(msg: Message):
12 | try:
13 | await commands(msg["content"], msg)
14 | except CommandNotFoundException:
15 | logger.error(f"收到无对应命令的消息: {msg['content']}")
16 | except:
17 | logger.error(f"命令处理出错!错误信息:\n{traceback.format_exc()}")
--------------------------------------------------------------------------------
/bot/builders/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cloud-wish/Dynamic-Bot/f78506629658fad416faf21c8840c3af3e688015/bot/builders/__init__.py
--------------------------------------------------------------------------------
/bot/builders/build_push_msg.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | from functools import partial
3 | from typing import Any
4 | import os
5 | import importlib
6 | from datetime import datetime, timezone, timedelta
7 |
8 | from ..utils.model import BotType
9 |
10 | from .pic_builder import PicBuilder
11 |
12 | class BuilderNotFoundException(Exception):
13 | def __init__(self, msg: str = ""):
14 | Exception.__init__(self, msg)
15 |
16 | class BuilderTableDef():
17 |
18 | def __init__(self) -> None:
19 | self._route_dict: dict[str] = {}
20 |
21 | def builder(self, *builder_def_list):
22 | """
23 | 参数顺序:定义时参数+调用时参数
24 | """
25 | def inner(func):
26 | for builder_def in builder_def_list:
27 | typ = builder_def
28 | self._route_dict[typ] = partial(func, builder_def)
29 | return func
30 | return inner
31 |
32 | def __call__(self, typ: str, *args, **kwds) -> Any:
33 | if not typ in self._route_dict:
34 | raise BuilderNotFoundException()
35 | # print(paras)
36 | return self._route_dict[typ](*args, **kwds)
37 |
38 | builders = BuilderTableDef()
39 | _pic_builder = PicBuilder()
40 |
41 | def get_builder_table():
42 | return builders
43 |
44 | def get_pic_builder():
45 | return _pic_builder
46 |
47 | def data_preprocess(data: dict, typ: str) -> dict:
48 | if("created_time" in data and type(data["created_time"]) == int):
49 | data["created_time"] = datetime.fromtimestamp(data["created_time"], tz=timezone(timedelta(hours=+8))).strftime("%Y-%m-%d %H:%M:%S")
50 | if "user" in data and not "name" in data["user"]:
51 | data["user"]["name"] = "[未知用户名]"
52 | if("retweet" in data):
53 | data["retweet"] = data_preprocess(data["retweet"], typ)
54 | if("reply" in data):
55 | data["reply"] = data_preprocess(data["reply"], typ)
56 | return data
57 |
58 | async def build_push_msg(data:dict[str], bot_id: str, bot_type: BotType) -> dict[str]:
59 | data = data_preprocess(data, data["type"])
60 | typ = data["type"]
61 | subtype = data["subtype"]
62 | uid = data["user"]["uid"]
63 | return await builders(typ, data, bot_id, bot_type)
64 |
65 | def auto_import(directory_path):
66 | """
67 | 导入指定目录下的所有 .py 文件
68 |
69 | :param directory_path: 目录路径
70 | """
71 | # 获取指定目录中的所有文件
72 | file_list = os.listdir(directory_path)
73 |
74 | # 遍历目录中的所有文件
75 | for filename in file_list:
76 | if filename.endswith('.py') and filename != '__init__.py' and filename != 'init.py':
77 | module_name = filename[:-3] # 去除文件扩展名
78 | importlib.import_module("." + module_name, __package__)
79 |
80 | auto_import(os.path.dirname(__file__))
81 |
82 | from . import *
--------------------------------------------------------------------------------
/bot/builders/common.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import copy
3 |
4 | from ..utils.pic_process import download_pic
5 | from ..constants.type import type_dict
6 |
7 | from .build_push_msg import get_builder_table
8 | from .wb_builder import get_wb_icqq_builder, get_wb_official_builder
9 | from .dyn_builder import get_dyn_icqq_builder, get_dyn_official_builder
10 |
11 | builders = get_builder_table()
12 |
13 | wb_icqq_builders = get_wb_icqq_builder()
14 | wb_official_builders = get_wb_official_builder()
15 | dyn_icqq_builders = get_dyn_icqq_builder()
16 | dyn_official_builders = get_dyn_official_builder()
17 |
18 | _type_dict = copy.deepcopy(type_dict)
19 | _type_dict["bili_dyn"] = "B站"
20 |
21 | @wb_icqq_builders.builder("avatar")
22 | @dyn_icqq_builders.builder("avatar")
23 | async def avatar_builder(subtype: str, uid: str, data: dict) -> list[str]:
24 | content: str = ""
25 | file_image: bytes = None
26 | content += f"{data['user']['name']}更换了{_type_dict[data['type']]}头像:"
27 | file_image = await download_pic(url=data["now"])
28 | return {
29 | "content": content,
30 | "file_image": file_image
31 | }
32 |
33 | @wb_icqq_builders.builder("desc")
34 | @dyn_icqq_builders.builder("desc")
35 | async def desc_builder(subtype: str, uid: str, data: dict) -> list[str]:
36 | content: str = ""
37 | file_image: bytes = None
38 | content += f"{data['user']['name']}更改了{_type_dict[data['type']]}简介:\n"
39 | content += data["now"]
40 | return {
41 | "content": content,
42 | "file_image": file_image
43 | }
44 |
45 | @wb_icqq_builders.builder("name")
46 | @dyn_icqq_builders.builder("name")
47 | async def name_builder(subtype: str, uid: str, data: dict) -> list[str]:
48 | content: str = ""
49 | file_image: bytes = None
50 | content += f"{data['pre']}更改了{_type_dict[data['type']]}用户名:\n"
51 | content += data["now"]
52 | return {
53 | "content": content,
54 | "file_image": file_image
55 | }
--------------------------------------------------------------------------------
/bot/builders/dyn_builder.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import base64
3 | import traceback
4 | import httpx
5 | import os
6 |
7 | from ..utils.logger import get_logger
8 | from ..utils.model import BotType
9 | from ..utils.config import get_config_value
10 | from ..utils.pic_process import modify_pic, save_pic, print_on_pic, compress_pic, download_pic
11 | from ..constants.type import type_dict
12 |
13 | from .build_push_msg import BuilderTableDef, get_builder_table, get_pic_builder
14 | from .utils.msg_convert import msg_convert
15 |
16 | logger = get_logger()
17 | builders = get_builder_table()
18 |
19 | dyn_builders = BuilderTableDef()
20 | dyn_icqq_builders = BuilderTableDef()
21 | dyn_official_builders = BuilderTableDef()
22 |
23 | def get_dyn_icqq_builder():
24 | return dyn_icqq_builders
25 |
26 | def get_dyn_official_builder():
27 | return dyn_official_builders
28 |
29 | @builders.builder("bili_dyn")
30 | async def dyn_builder(typ: str, data: dict, bot_id: str, bot_type: BotType) -> dict[str]:
31 | return await dyn_builders(bot_type, data)
32 |
33 | @dyn_builders.builder(BotType.ICQQ)
34 | async def icqq_builder(bot_type: BotType, data: dict) -> dict[str]:
35 | subtype = data["subtype"]
36 | return await dyn_icqq_builders(subtype, data)
37 |
38 | @dyn_builders.builder(BotType.OFFICIAL)
39 | async def official_builder(bot_type: BotType, data: dict) -> dict[str]:
40 | raise NotImplementedError("Official bot does not support bili_dyn message")
41 | # return await dyn_builders(bot_type, data)
42 |
43 | @dyn_icqq_builders.builder("dynamic")
44 | async def icqq_dynamic_builder(subtype: str, data: dict) -> dict[str]:
45 | # 目前实现: 直接返回图片
46 | # TODO: 设置图片/文字格式
47 | dyn_content = await dyn_pic_builder(subtype, data, get_config_value("builder", "bili_dyn", "pic_fail_fallback"))
48 | if get_config_value("builder", "bili_dyn", "link"):
49 | footer = "\n"
50 | if data["dyn_type"] == 8:
51 | footer += "视频地址:\n"
52 | elif data["dyn_type"] == 64:
53 | footer += "文章地址:\n"
54 | else:
55 | footer += "动态地址:\n"
56 | footer += f"{data['link']}"
57 | dyn_content.append({
58 | "type": "text",
59 | "data": {"text": footer}
60 | })
61 | return dyn_content
62 |
63 | async def dyn_pic_builder(subtype: str, data: dict, fallback: bool = False) -> list[dict[str]]:
64 | # if not pic_enable:
65 | # return None
66 |
67 | name = data['user']["name"]
68 | content: str = ""
69 | file_image: bytes = None
70 | # uid = data["user"]["uid"]
71 | pic_builder = get_pic_builder()
72 | if not data.get("is_retweet", False):
73 | content += f"{name}在{data['created_time']}"
74 | if data["dyn_type"] == 8:
75 | content += "发了新视频:"
76 | elif data["dyn_type"] == 64:
77 | content += "发了新文章:"
78 | elif data["dyn_type"] == 1:
79 | content += f"转发了{data['retweet']['user']['name']}的"
80 | if data["retweet"]["dyn_type"] == 8:
81 | content += "视频:"
82 | elif data["retweet"]["dyn_type"] == 64:
83 | content += "文章:"
84 | else:
85 | content += "动态:"
86 | else:
87 | content += "发了新动态并说:"
88 | for i in range(3):
89 | try:
90 | pic = await pic_builder.get_bili_dyn_pic(data["id"], data["created_time"], data.get("cookie"))
91 | file_image = modify_pic(pic)
92 | file_image = compress_pic(file_image)
93 | # pic_save_path = os.path.join(os.path.abspath(get_config_value("data", "path")), "pics", "bili_dyn", subtype, uid, f"{data['id']}.jpeg")
94 | # save_pic(file_image, pic_save_path)
95 | # content += '[CQ:image,file=file:///'+pic_save_path+']'
96 | break
97 | except:
98 | if i == 2:
99 | errmsg = traceback.format_exc()
100 | logger.error(f"生成B站动态图片发生错误!错误信息:\n{errmsg}")
101 | if fallback: # 回落到文字
102 | return await dynamic_builder(subtype, data)
103 | else:
104 | content += "[图片无法生成]"
105 | file_image = None
106 | pass
107 | return msg_convert(content, file_image)
108 |
109 | async def dynamic_builder(subtype: str, data: dict) -> list[str]:
110 | name = data["user"]["name"]
111 | content: list[dict[str]] = []
112 | if data["user"].get("avatar"):
113 | content.append({
114 | "type": "image",
115 | "data": {"file": "base64://" + base64.b64encode(await download_pic(data["user"]["avatar"])).decode('utf-8')}
116 | })
117 | if not data.get("is_retweet", False):
118 | title = f"{name}在{data['created_time']}"
119 | if data["dyn_type"] == 8:
120 | title += "发了新视频:\n"
121 | elif data["dyn_type"] == 64:
122 | title += "发了新文章:\n"
123 | elif data["dyn_type"] == 1:
124 | title += f"转发了{data['retweet']['user']['name']}的"
125 | if data["retweet"]["dyn_type"] == 8:
126 | title += "视频:\n"
127 | elif data["retweet"]["dyn_type"] == 64:
128 | title += "文章:\n"
129 | else:
130 | title += "动态:\n"
131 | else:
132 | title += "发了新动态并说:\n"
133 | content.append({
134 | "type": "text",
135 | "data": {"text": title}
136 | })
137 | if data["dyn_type"] == 2 or data["dyn_type"] == 4: # 动态
138 | content.append({
139 | "type": "text",
140 | "data": {"text": data["text"] + "\n"}
141 | })
142 | if("pics" in data):
143 | for pic in data["pics"]:
144 | content.append({
145 | "type": "image",
146 | "data": {"file": "base64://" + base64.b64encode(await download_pic(pic)).decode('utf-8')}
147 | })
148 | elif data["dyn_type"] == 8 or data["dyn_type"] == 64: # 视频 or 文章
149 | content.append({
150 | "type": "text",
151 | "data": {"text": data["title"] + "\n"}
152 | })
153 | content.append({
154 | "type": "image",
155 | "data": {"file": "base64://" + base64.b64encode(await download_pic(data["cover_pic"])).decode('utf-8')}
156 | })
157 | content.append({
158 | "type": "text",
159 | "data": {"text": data["desc"]}
160 | })
161 | elif data["dyn_type"] == 1: # 转发
162 | content.append({
163 | "type": "text",
164 | "data": {"text": data["text"] + "\n"}
165 | })
166 | title = "原"
167 | if data["retweet"]["dyn_type"] == 8:
168 | title += "视频:\n"
169 | elif data["retweet"]["dyn_type"] == 64:
170 | title += "文章:\n"
171 | else:
172 | title += "动态:\n"
173 | content.append({
174 | "type": "text",
175 | "data": {"text": title}
176 | })
177 | content.extend(await dynamic_builder(subtype, data["retweet"]))
178 | else:
179 | content.append({
180 | "type": "text",
181 | "data": {"text": "[无法解析该动态,请点击地址查看]"}
182 | })
183 | return content
184 |
185 | @dyn_icqq_builders.builder("comment")
186 | async def dynamic_comment_builder(subtype: str, data: dict):
187 | content: str = ""
188 | file_image: bytes = None
189 | # uid = data["user"]["uid"]
190 | msg_list: list[dict] = []
191 | pic_builder = get_pic_builder()
192 | if("reply" in data):
193 | content += f"{data['user']['name']}在{data['created_time']}回复了{data['reply']['user']['name']}的动态评论并说:"
194 | else:
195 | content += f"{data['user']['name']}在{data['created_time']}发了新动态评论并说:"
196 | title = content
197 | content += "\n"
198 | content += data['text']
199 | msg_list.append({"type": "text", "content": content})
200 | content = ""
201 | if("reply" in data):
202 | content += f"\n原评论:\n{data['reply']['text']}"
203 | msg_list.append({"type": "text", "content": content})
204 | msg_list.append({"type": "text", "content": "原动态:"})
205 | for i in range(3):
206 | try:
207 | pic = await pic_builder.get_bili_dyn_pic(data["root"]["id"], data["root"]["created_time"], data.get("cookie"))
208 | pic = compress_pic(pic)
209 | msg_list.append({"type": "pic", "content": pic, "para": {"extend_width": True} })
210 | break
211 | except:
212 | if i == 2:
213 | errmsg = traceback.format_exc()
214 | logger.error(f"生成B站动态图片发生错误!错误信息:\n{errmsg}")
215 | msg_list.append({"type": "text", "content": "[图片无法生成]"})
216 | pass
217 | file_image = modify_pic(print_on_pic(msg_list))
218 | # pic_save_path = os.path.join(os.path.abspath(get_config_value("data", "path")), "pics", "bili_dyn", subtype, uid, f"{data['id']}.jpeg")
219 | # save_pic(file_image, pic_save_path)
220 | # title += '[CQ:image,file=file:///'+pic_save_path+']'
221 | return msg_convert(content, file_image)
222 |
--------------------------------------------------------------------------------
/bot/builders/live_builder.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import traceback
3 | import httpx
4 | import os
5 |
6 | from ..utils.logger import get_logger
7 | from ..utils.model import BotType
8 | from ..utils.config import get_config_value
9 | from ..utils.pic_process import modify_pic, save_pic, print_on_pic, compress_pic, download_pic
10 | from ..constants.type import type_dict
11 |
12 | from .build_push_msg import BuilderTableDef, get_builder_table
13 | from .utils.msg_convert import msg_convert
14 |
15 | logger = get_logger()
16 | builders = get_builder_table()
17 |
18 | live_builders = BuilderTableDef()
19 | live_icqq_builders = BuilderTableDef()
20 | live_official_builders = BuilderTableDef()
21 |
22 | @builders.builder("bili_live")
23 | async def live_builder(typ: str, data: dict, bot_id: str, bot_type: BotType) -> dict[str]:
24 | return await live_builders(bot_type, data)
25 |
26 | @live_builders.builder(BotType.ICQQ)
27 | async def icqq_builder(bot_type: BotType, data: dict) -> dict[str]:
28 | subtype = data["subtype"]
29 | return await live_icqq_builders(subtype, data)
30 |
31 | @live_builders.builder(BotType.OFFICIAL)
32 | async def official_builder(bot_type: BotType, data: dict) -> dict[str]:
33 | raise NotImplementedError("Official bot does not support bili_live message")
34 | # return await live_builders(bot_type, data)
35 |
36 | @live_icqq_builders.builder("status")
37 | async def status_builder(subtype: str, data: dict) -> list[str]:
38 | content: str = ""
39 | file_image: bytes = None
40 | uid = data["user"]["uid"]
41 | if(data['now'] == "1"):
42 | content += f"{data['user']['name']}开播啦!"
43 | content += f"标题:\n{data['user']['title']}"
44 | file_image = await download_pic(url=data['user']["cover"])
45 | # pic_save_path = os.path.join(os.path.abspath(get_config_value("data", "path")), "pics", "bili_live", "cover", f"{uid}_cover.jpeg")
46 | # save_pic(file_image, pic_save_path)
47 | # content += '[CQ:image,file=file:///'+pic_save_path+']'
48 | elif(data['pre'] == "1"):
49 | content += f"{data['user']['name']}下播了"
50 | return msg_convert(content, file_image)
51 |
52 | @live_icqq_builders.builder("title")
53 | async def title_builder(subtype: str, data: dict) -> list[str]:
54 | content: str = ""
55 | content += f"{data['user']['name']}更改了直播间标题:\n"
56 | content += data["now"]
57 | return msg_convert(content, None)
58 |
59 | @live_icqq_builders.builder("cover")
60 | async def cover_builder(subtype: str, data: dict) -> list[str]:
61 | content: str = ""
62 | file_image: bytes = None
63 | uid = data["user"]["uid"]
64 | content += f"{data['user']['name']}更改了直播间封面:"
65 | file_image = await download_pic(url=data["now"])
66 | # pic_save_path = os.path.join(os.path.abspath(get_config_value("data", "path")), "pics", "bili_live", "cover", f"{uid}_cover.jpeg")
67 | # save_pic(file_image, pic_save_path)
68 | # content += '[CQ:image,file=file:///'+pic_save_path+']'
69 | return msg_convert(content, file_image)
--------------------------------------------------------------------------------
/bot/builders/pic_builder.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import io
3 | import asyncio
4 | from typing import Optional
5 | from PIL import Image
6 | from playwright.async_api import Browser,BrowserContext, PlaywrightContextManager, Page, async_playwright, TimeoutError, Error
7 |
8 | from ..utils.config import get_config_value
9 | from ..utils.pic_process import join_pic
10 |
11 | class DynamicRemovedException(Exception):
12 | def __init__(self, msg):
13 | Exception.__init__(self, msg)
14 |
15 | def cookie_to_dict_list(cookie: str, domain: str):
16 | if not cookie:
17 | return []
18 | cookie_list = cookie.split(";")
19 | cookies = []
20 | for c in cookie_list:
21 | if not c:
22 | continue
23 | cookie_pair = c.lstrip().rstrip().split("=")
24 | cookies.append({
25 | "name": cookie_pair[0],
26 | "value": cookie_pair[1],
27 | "domain": domain,
28 | "path": "/"
29 | })
30 | return cookies
31 |
32 | class PicBuilder:
33 |
34 | DEFAULT_MOBILE_UA = 'Mozilla/5.0 (Linux; Android 10; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.162 Mobile Safari/537.36'
35 | DEFAULT_PC_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36'
36 |
37 | def __init__(self) -> None:
38 | self._playwright_context: Optional[PlaywrightContextManager] = None
39 | self._browser: Optional[Browser] = None
40 |
41 | async def get_browser(self) -> Browser:
42 | if(self._playwright_context is None):
43 | self._playwright_context = await async_playwright().start()
44 | if(self._browser is None):
45 | self._browser = await self._playwright_context.chromium.launch()
46 | elif(self._browser.is_connected() == False):
47 | await self._browser.close()
48 | self._browser = await self._playwright_context.chromium.launch()
49 | return self._browser
50 |
51 | async def close_browser(self) -> None:
52 | if(not self._browser is None):
53 | await self._browser.close()
54 | self._browser = None
55 | if(not self._playwright_context is None):
56 | await self._playwright_context.stop()
57 | self._playwright_context = None
58 |
59 | async def restart_browser(self) -> Browser:
60 | await self.close_browser()
61 | return await self.get_browser()
62 |
63 | async def get_wb_pic(self, wb_id: str, created_time: str = None, cookie: str = None, ua: str = None) -> bytes:
64 | browser = await self.get_browser()
65 | if ua is None:
66 | ua = self.DEFAULT_MOBILE_UA
67 | context = await browser.new_context(user_agent=ua, device_scale_factor=2)
68 | await context.add_cookies(cookie_to_dict_list(cookie, ".weibo.cn"))
69 | page = await context.new_page()
70 | try:
71 | page.set_default_timeout(10000)
72 | await page.set_viewport_size({'width':1300, 'height':3500})
73 | await page.goto('https://m.weibo.cn/detail/'+wb_id, wait_until="networkidle", timeout=15000)
74 | if not created_time is None:
75 | try:
76 | await page.evaluate("""
77 | var elements = document.getElementsByClassName("time");
78 | for(i=0;i取消
253 | await page.evaluate('document.getElementsByClassName("opus-module-content limit")[0].className = "opus-module-content"')
254 | await page.evaluate('document.getElementsByClassName("opus-read-more")[0].style.display = "none"')
255 | try: # 关注按钮
256 | await page.evaluate('document.getElementsByClassName("opus-module-author__action")[0].style.display = "none"')
257 | except:
258 | pass
259 | try: # 顶部导航栏
260 | await page.evaluate('document.getElementsByClassName("opus-nav")[0].style.display = "none"')
261 | except:
262 | pass
263 | try: # 字体
264 | await self.font_replace_js(page, "opus-modules")
265 | except:
266 | pass
267 | try: # 字体大小
268 | await self.text_resize_js(page, "opus-modules")
269 | except:
270 | pass
271 | try: # 图片大小
272 | await page.evaluate("""
273 | var pic_block = document.getElementsByClassName("bm-pics-block")[0]
274 | for(i=0;i e.style.display = "none")')
336 | except:
337 | pass
338 | try: # 顶栏
339 | await page.evaluate('document.getElementsByClassName("international-header")[0].style.display = "none"')
340 | except:
341 | pass
342 | # await page.wait_for_timeout(600000)
343 | try:
344 | return await page.locator('[class="bili-dyn-item__main"]').screenshot()
345 | except Exception as e:
346 | await page.screenshot(path="bili_dyn_pc_err_pic.png")
347 | raise e
348 |
349 | async def new_context(self, **kwargs) -> BrowserContext:
350 | browser = await self.get_browser()
351 | try:
352 | context = await browser.new_context(**kwargs)
353 | except Error as e:
354 | if "Target page, context or browser has been closed" in e.message:
355 | await self.restart_browser()
356 | context = await browser.new_context(**kwargs)
357 | return context
358 |
359 | async def get_bili_dyn_pic(self, dynamic_id: str, created_time: str = None, cookie: str = None) -> bytes:
360 | if get_config_value("builder", "bili_dyn", "is_pc"):
361 | context = await self.new_context(user_agent=self.DEFAULT_PC_UA, device_scale_factor=2)
362 | else:
363 | context = await self.new_context(user_agent=self.DEFAULT_MOBILE_UA, device_scale_factor=2)
364 | await context.add_cookies(cookie_to_dict_list(cookie, ".bilibili.com"))
365 | page = await context.new_page()
366 | await page.add_init_script("Object.defineProperties(navigator, {webdriver:{get:()=>undefined}});")
367 | try:
368 | await page.set_viewport_size({'width':1300, 'height':3500})
369 | if get_config_value("builder", "bili_dyn", "is_pc"):
370 | await page.goto('https://t.bilibili.com/'+dynamic_id, wait_until="networkidle", timeout=15000)
371 | elif get_config_value("builder", "bili_dyn", "is_new_layout"):
372 | await page.goto('https://m.bilibili.com/opus/'+dynamic_id, wait_until="networkidle", timeout=15000)
373 | try:
374 | await page.locator('[class="error-container"]').text_content(timeout=500)
375 | raise DynamicRemovedException()
376 | except TimeoutError:
377 | pass
378 | else:
379 | await page.goto('https://m.bilibili.com/dynamic/'+dynamic_id, wait_until="networkidle", timeout=15000)
380 | if page.url.startswith("https://m.bilibili.com/404"):
381 | raise DynamicRemovedException()
382 | if get_config_value("builder", "bili_dyn", "is_pc"):
383 | pic = await self.bili_dyn_pc_process(page, created_time)
384 | elif "opus" in page.url:
385 | pic = await self.bili_dyn_opus_process(page, created_time)
386 | else:
387 | pic = await self.bili_dyn_dynamic_process(page, created_time)
388 | except Exception as e:
389 | raise e
390 | finally:
391 | try:
392 | await page.close()
393 | await context.close()
394 | except Exception as e:
395 | await self.restart_browser()
396 | # debug
397 | # debug_save_pic(pic, "dyn", dynamic_id)
398 | return pic
399 |
400 | def debug_save_pic(pic: bytes, typ: str, id: str):
401 | img = Image.open(io.BytesIO(pic))
402 | img.save(f"debug_pic/{typ}/{id}.png")
--------------------------------------------------------------------------------
/bot/builders/utils/msg_convert.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import base64
3 |
4 | def msg_convert(content: str, file_image: bytes | None) -> list[dict[str]]:
5 | msg = []
6 | if(len(content)):
7 | msg.append({
8 | "type": "text",
9 | "data": {"text": content}
10 | })
11 | if type(file_image) is bytes:
12 | msg.append({
13 | "type": "image",
14 | "data": {
15 | "file": "base64://" + base64.b64encode(file_image).decode('utf-8')
16 | }
17 | })
18 | return msg
--------------------------------------------------------------------------------
/bot/builders/wb_builder.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import traceback
3 | import httpx
4 | import os
5 |
6 | from ..utils.logger import get_logger
7 | from ..utils.model import BotType
8 | from ..utils.config import get_config_value
9 | from ..utils.pic_process import modify_pic, save_pic, print_on_pic, compress_pic, download_pic
10 | from ..constants.type import type_dict
11 |
12 | from .build_push_msg import BuilderTableDef, get_builder_table, get_pic_builder
13 | from .utils.msg_convert import msg_convert
14 |
15 | logger = get_logger()
16 | builders = get_builder_table()
17 |
18 | wb_builders = BuilderTableDef()
19 | wb_icqq_builders = BuilderTableDef()
20 | wb_official_builders = BuilderTableDef()
21 |
22 | def get_wb_icqq_builder():
23 | return wb_icqq_builders
24 |
25 | def get_wb_official_builder():
26 | return wb_official_builders
27 |
28 | @builders.builder("weibo")
29 | async def wb_builder(typ: str, data: dict, bot_id: str, bot_type: BotType) -> dict[str]:
30 | return await wb_builders(bot_type, data)
31 |
32 | @wb_builders.builder(BotType.ICQQ)
33 | async def icqq_builder(bot_type: BotType, data: dict) -> dict[str]:
34 | subtype = data["subtype"]
35 | return await wb_icqq_builders(subtype, data)
36 |
37 | @wb_builders.builder(BotType.OFFICIAL)
38 | async def official_builder(bot_type: BotType, data: dict) -> dict[str]:
39 | raise NotImplementedError("Official bot does not support weibo message")
40 | # return await wb_builders(bot_type, data)
41 |
42 | @wb_icqq_builders.builder("weibo")
43 | async def wb_weibo_builder(subtype: str, data: dict) -> dict[str]:
44 | # 目前实现: 直接返回图片
45 | # TODO: 设置图片/文字格式
46 | return await wb_pic_builder(subtype, data)
47 |
48 | async def wb_pic_builder(subtype: str, data: dict) -> list[str]:
49 | # if not pic_enable:
50 | # return None
51 | content: str = ""
52 | file_image: bytes = None
53 | uid = data["user"]["uid"]
54 | pic_builder = get_pic_builder()
55 | if("retweet" in data):
56 | content += f"{data['user']['name']}在{data['created_time']}转发了{data['retweet']['user']['name']}的微博并说:"
57 | else:
58 | content += f"{data['user']['name']}在{data['created_time']}发了新微博并说:"
59 | for i in range(3):
60 | try:
61 | pic = await pic_builder.get_wb_pic(data["id"], data["created_time"], data["cookie"], data["ua"])
62 | file_image = modify_pic(pic)
63 | file_image = compress_pic(file_image)
64 | # pic_save_path = os.path.join(os.path.abspath(get_config_value("data", "path")), "pics", "weibo", subtype, uid, f"{data['id']}.jpeg")
65 | # save_pic(file_image, pic_save_path)
66 | # content += '[CQ:image,file=file:///'+pic_save_path+']'
67 | break
68 | except:
69 | if i == 2:
70 | errmsg = traceback.format_exc()
71 | logger.error(f"生成微博图片发生错误!错误信息:\n{errmsg}")
72 | content += "[图片无法生成]"
73 | file_image = None
74 | pass
75 | return msg_convert(content, file_image)
76 |
77 | @wb_icqq_builders.builder("comment")
78 | async def wb_cmt_builder(subtype: str, data: dict):
79 | content: str = ""
80 | file_image: bytes = None
81 | uid = data["user"]["uid"]
82 | pic_builder = get_pic_builder()
83 | msg_list: list[dict] = []
84 | headers = {
85 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36",
86 | "Referer": "https://m.weibo.cn/"
87 | }
88 | if("reply" in data):
89 | content += f"{data['user']['name']}在{data['created_time']}回复了{data['reply']['user']['name']}的微博评论并说:"
90 | else:
91 | content += f"{data['user']['name']}在{data['created_time']}发了新微博评论并说:"
92 | title = content
93 | content += "\n"
94 | content += data['text']
95 | msg_list.append({"type": "text", "content": content})
96 | for pic_info in data['pics']:
97 | msg_list.append({"type": "pic", "content": (await download_pic(pic_info, headers))})
98 | content = ""
99 | if("reply" in data):
100 | content += f"\n原评论:\n{data['reply']['text']}"
101 | msg_list.append({"type": "text", "content": content})
102 | for pic_info in data['reply']['pics']:
103 | msg_list.append({"type": "pic", "content": (await download_pic(pic_info, headers))})
104 | msg_list.append({"type": "text", "content": "原微博:"})
105 | for i in range(3):
106 | try:
107 | pic = await pic_builder.get_wb_pic(data["root"]["id"], data["root"]["created_time"], data["cookie"], data["ua"])
108 | pic = compress_pic(pic)
109 | msg_list.append({"type": "pic", "content": pic, "para": {"extend_width": True} })
110 | break
111 | except:
112 | if i == 2:
113 | errmsg = traceback.format_exc()
114 | logger.error(f"生成微博图片发生错误!错误信息:\n{errmsg}")
115 | msg_list.append({"type": "text", "content": "[图片无法生成]"})
116 | pass
117 | file_image = modify_pic(print_on_pic(msg_list))
118 | # pic_save_path = os.path.join(os.path.abspath(get_config_value("data", "path")), "pics", "weibo", subtype, uid, f"{data['id']}.jpeg")
119 | # save_pic(file_image, pic_save_path)
120 | # title += '[CQ:image,file=file:///'+pic_save_path+']'
121 | return msg_convert(content, file_image)
--------------------------------------------------------------------------------
/bot/commands/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cloud-wish/Dynamic-Bot/f78506629658fad416faf21c8840c3af3e688015/bot/commands/__init__.py
--------------------------------------------------------------------------------
/bot/commands/add_push.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from ..utils.model import Message
4 | from ..utils import db
5 | from ..constants.command import command_dict
6 | from ..constants.type import type_dict
7 | from ..utils.msg_queue import put_message
8 |
9 | from .init import get_command_table
10 | from .utils.get_user_auth import get_user_auth
11 | from .utils.crawler import add_crawler, remove_crawler
12 | commands = get_command_table()
13 |
14 | @commands.cmd((command_dict["add"]["weibo"], "weibo", "weibo"), (command_dict["add"]["bili_dyn"], "bili_dyn", "dynamic"), (command_dict["add"]["bili_live"], "bili_live", ("status", "title", "cover")))
15 | async def add_push(cmd: str, typ: str, subtypes: str|tuple[str], msg: Message, uid: str):
16 | bot_id = msg["bot_id"]
17 | user = msg["sender"]
18 | guild_id = msg["guild_id"]
19 | channel_id = msg["channel_id"]
20 | if not await get_user_auth(bot_id, guild_id, channel_id, user, typ):
21 | return None
22 | if type(subtypes) == str:
23 | subtypes = (subtypes, )
24 |
25 | for _ in [1]:
26 | if not db.exist_push_user(uid, typ):
27 | resp = await add_crawler(uid, typ)
28 | if not resp["success"]:
29 | if resp and resp["result"]["code"] == 11:
30 | reply_content = f"UID:{uid} 未开通直播间"
31 | else:
32 | reply_content = f"UID:{uid} 的{type_dict[typ]}推送添加失败!"
33 | break
34 | for subtype in subtypes:
35 | db.add_push(typ, subtype, uid, bot_id, guild_id, channel_id)
36 | reply_content = f"UID:{uid} 的{type_dict[typ]}推送添加成功!"
37 |
38 | reply_msg = Message({
39 | "guild_id": msg["guild_id"],
40 | "channel_id": msg["channel_id"],
41 | "msg_type": msg["msg_type"],
42 | "bot_type": msg["bot_type"],
43 | "bot_id": msg["bot_id"],
44 | "data": [{
45 | "type": "text",
46 | "data": {
47 | "text": reply_content
48 | }
49 | }]
50 | })
51 | await put_message(reply_msg)
52 |
--------------------------------------------------------------------------------
/bot/commands/get_push_config.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from ..utils.model import Message
4 | from ..utils import db
5 | from ..constants.command import command_dict
6 | from ..constants.type import type_dict
7 | from ..utils.msg_queue import put_message
8 | from ..utils.model import MessageType
9 |
10 | from .init import get_command_table
11 | from .utils.get_user_auth import get_user_auth
12 | commands = get_command_table()
13 |
14 | @commands.cmd((command_dict["config"]["channel"], ))
15 | async def get_push_config(cmd: str, msg: Message):
16 | bot_id = msg["bot_id"]
17 | user = msg["sender"]
18 | guild_id = msg["guild_id"]
19 | channel_id = msg["channel_id"]
20 | msg_type = msg["msg_type"]
21 | if not await get_user_auth(bot_id, guild_id, channel_id, user):
22 | return None
23 |
24 | if msg_type == MessageType.GUILD:
25 | channel_type = "频道"
26 | else:
27 | channel_type = "群聊"
28 | reply = [f"当前{channel_type}中设置的推送如下:\n"]
29 | for typ in type_dict.keys():
30 | reply.append(f"{type_dict[typ]}推送:")
31 | push_config = db.get_bot_push_config(bot_id, guild_id, channel_id, typ)
32 | if push_config[typ]:
33 | reply.append("、".join(push_config[typ]) + "\n")
34 | else:
35 | reply.append("无\n")
36 |
37 | reply[-1] = reply[-1].strip()
38 | reply_msg = Message({
39 | "guild_id": msg["guild_id"],
40 | "channel_id": msg["channel_id"],
41 | "msg_type": msg["msg_type"],
42 | "bot_type": msg["bot_type"],
43 | "bot_id": msg["bot_id"],
44 | "data": [{
45 | "type": "text",
46 | "data": {
47 | "text": "".join(reply)
48 | }
49 | }]
50 | })
51 | await put_message(reply_msg)
--------------------------------------------------------------------------------
/bot/commands/help.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import re
3 | import random
4 |
5 | from ..utils.model import Message
6 | from ..utils.msg_queue import put_message
7 |
8 | from ..constants.command import command_dict, HELP
9 |
10 | from .init import get_command_table
11 | from .utils.get_user_auth import get_user_auth
12 |
13 | commands = get_command_table()
14 |
15 | @commands.cmd(command_dict["help"])
16 | async def get_help(cmd: str, msg: Message):
17 | bot_id = msg["bot_id"]
18 | user = msg["sender"]
19 | guild_id = msg["guild_id"]
20 | channel_id = msg["channel_id"]
21 | # if not await get_user_auth(bot_id, guild_id, channel_id, user):
22 | # return None
23 | reply_msg = Message({
24 | "guild_id": msg["guild_id"],
25 | "channel_id": msg["channel_id"],
26 | "msg_type": msg["msg_type"],
27 | "bot_type": msg["bot_type"],
28 | "bot_id": msg["bot_id"],
29 | "data": [{
30 | "type": "text",
31 | "data": {
32 | "text": HELP
33 | }
34 | }]
35 | })
36 | await put_message(reply_msg)
37 |
--------------------------------------------------------------------------------
/bot/commands/init.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import re
3 | from typing import Any
4 | import importlib
5 | import os
6 |
7 | class CommandNotFoundException(Exception):
8 | def __init__(self, msg: str = ""):
9 | Exception.__init__(self, msg)
10 |
11 | class CommandMissingParameterException(Exception):
12 | def __init__(self, msg: str = ""):
13 | Exception.__init__(self, msg)
14 |
15 | class CommandTableDef():
16 |
17 | def __init__(self) -> None:
18 | self._str_route_dict: dict[str, dict[str]] = {}
19 | self._regex_route_dict: dict[str, dict[str]] = {}
20 |
21 | def cmd(self, *cmd_def_list):
22 | """
23 | 向函数传递的参数顺序: cmd(str/re.Match) + 定义时参数 + 调用时参数 + 消息提取参数
24 | """
25 | def inner(func):
26 |
27 | def add_cmd_def(cmd_def):
28 | cmd_pattern = cmd_def[0]
29 | if type(cmd_pattern) == str:
30 | self._str_route_dict[cmd_pattern] = {
31 | "func": func,
32 | "paras": cmd_def[1:]
33 | }
34 | elif type(cmd_pattern) == re.Pattern:
35 | self._regex_route_dict[cmd_pattern.pattern] = {
36 | "func": func,
37 | "pattern": cmd_pattern,
38 | "paras": cmd_def[1:]
39 | }
40 |
41 | if type(cmd_def_list[0]) == tuple: # 多个cmd定义
42 | for cmd_def in cmd_def_list:
43 | add_cmd_def(cmd_def)
44 | else: # 单个cmd定义
45 | add_cmd_def(cmd_def_list)
46 | return func
47 | return inner
48 |
49 | def __call__(self, msg: str, *args, **kwds) -> Any:
50 | paras = msg.strip().split(" ") # cmd(str) + 消息提取参数
51 | i = 0
52 | while(i < len(paras)):
53 | if paras[i] == "":
54 | paras.pop(i)
55 | else:
56 | i += 1
57 | cmd_str = paras[0]
58 | paras = tuple(paras[1:])
59 |
60 | if cmd_str in self._str_route_dict:
61 | str_route = self._str_route_dict[cmd_str]
62 | return str_route["func"](cmd_str, *(str_route["paras"] + args + paras), **kwds)
63 | else:
64 | for regex_route in self._regex_route_dict.values():
65 | match_result = regex_route["pattern"].match(cmd_str)
66 | if match_result and match_result.group(0) == cmd_str: # 与正则表达式完全匹配
67 | return regex_route["func"](match_result, *(regex_route["paras"] + args + paras), **kwds)
68 | raise CommandNotFoundException()
69 |
70 | def is_command(self, msg: str) -> bool:
71 | cmd_str = msg.strip().split(" ")[0]
72 | if cmd_str in self._str_route_dict:
73 | return True
74 | for regex_route in self._regex_route_dict.values():
75 | match_result = regex_route["pattern"].match(cmd_str)
76 | if match_result and match_result.group(0) == cmd_str: # 与正则表达式完全匹配
77 | return True
78 | return False
79 |
80 | commands = CommandTableDef()
81 |
82 | def get_command_table():
83 | return commands
84 |
85 | def is_command(msg: str) -> bool:
86 | return commands.is_command(msg)
87 |
88 | def auto_import(directory_path):
89 | """
90 | 导入指定目录下的所有 .py 文件
91 |
92 | :param directory_path: 目录路径
93 | """
94 | # 获取指定目录中的所有文件
95 | file_list = os.listdir(directory_path)
96 |
97 | # 遍历目录中的所有文件
98 | for filename in file_list:
99 | if filename.endswith('.py') and filename != '__init__.py' and filename != 'init.py':
100 | module_name = filename[:-3] # 去除文件扩展名
101 | importlib.import_module("." + module_name, __package__)
102 |
103 | auto_import(os.path.dirname(__file__))
104 |
105 | from . import *
--------------------------------------------------------------------------------
/bot/commands/remove_push.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from ..utils.model import Message
4 | from ..utils import db
5 | from ..constants.command import command_dict
6 | from ..constants.type import type_dict
7 | from ..utils.msg_queue import put_message
8 |
9 | from .init import get_command_table
10 | from .utils.get_user_auth import get_user_auth
11 | from .utils.crawler import add_crawler, remove_crawler
12 | commands = get_command_table()
13 |
14 | @commands.cmd((command_dict["remove"]["weibo"], "weibo", "weibo"), (command_dict["remove"]["bili_dyn"], "bili_dyn", "dynamic"), (command_dict["remove"]["bili_live"], "bili_live", ("status", "title", "cover")))
15 | async def remove_push(cmd: str, typ: str, subtypes: str|tuple[str], msg: Message, uid: str):
16 | bot_id = msg["bot_id"]
17 | user = msg["sender"]
18 | guild_id = msg["guild_id"]
19 | channel_id = msg["channel_id"]
20 | if not await get_user_auth(bot_id, guild_id, channel_id, user, typ):
21 | return None
22 | if type(subtypes) == str:
23 | subtypes = (subtypes, )
24 |
25 | for _ in [1]:
26 | for subtype in subtypes:
27 | db.remove_push(typ, subtype, uid, bot_id, guild_id, channel_id)
28 | if not db.exist_push_user(uid, typ):
29 | resp = await remove_crawler(uid, typ)
30 | if not resp["success"]:
31 | for subtype in subtypes: # revert changes
32 | db.add_push(typ, subtype, uid, bot_id, guild_id, channel_id)
33 | if resp and resp["result"]["code"] == 11:
34 | reply_content = f"UID:{uid} 未开通直播间"
35 | else:
36 | reply_content = f"UID:{uid} 的{type_dict[typ]}推送删除失败!"
37 | break
38 | reply_content = f"UID:{uid} 的{type_dict[typ]}推送删除成功!"
39 |
40 | reply_msg = Message({
41 | "guild_id": msg["guild_id"],
42 | "channel_id": msg["channel_id"],
43 | "msg_type": msg["msg_type"],
44 | "bot_type": msg["bot_type"],
45 | "bot_id": msg["bot_id"],
46 | "data": [{
47 | "type": "text",
48 | "data": {
49 | "text": reply_content
50 | }
51 | }]
52 | })
53 | await put_message(reply_msg)
54 |
--------------------------------------------------------------------------------
/bot/commands/roll_dice.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import re
3 | import random
4 |
5 | from ..utils.model import Message
6 | from ..utils.msg_queue import put_message
7 | from ..constants.command import command_dict
8 |
9 | from .init import get_command_table
10 |
11 | commands = get_command_table()
12 |
13 | @commands.cmd(re.compile(command_dict["roll"]))
14 | async def roll_dice(cmd: re.Match, msg: Message, roll_content: str = "", *args):
15 | count = int(cmd.group(1))
16 | sides = int(cmd.group(2))
17 |
18 | if args:
19 | roll_content += " " + " ".join(args)
20 |
21 | if count < 1 or sides < 1:
22 | reply_content = "参数错误"
23 | else:
24 | result_list: list[int] = []
25 | sum = 0
26 | for _ in range(count):
27 | result = random.randint(1, sides)
28 | result_list.append(str(result))
29 | sum += result
30 | reply_content = f"由于{roll_content},{msg['sender']['nickname']}投掷: {cmd.group(1)}d{cmd.group(2)}="
31 | if len(result_list) == 1:
32 | reply_content += str(sum)
33 | else:
34 | reply_content += "{" + "+".join(result_list) + "}=" + str(sum)
35 |
36 | reply_msg = Message({
37 | "guild_id": msg["guild_id"],
38 | "channel_id": msg["channel_id"],
39 | "msg_type": msg["msg_type"],
40 | "bot_type": msg["bot_type"],
41 | "bot_id": msg["bot_id"],
42 | "data": [{
43 | "type": "text",
44 | "data": {
45 | "text": reply_content
46 | }
47 | }]
48 | })
49 | await put_message(reply_msg)
50 |
--------------------------------------------------------------------------------
/bot/commands/utils/crawler.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import traceback
3 | import httpx
4 |
5 | from...utils.config import get_config_value
6 | from ...utils.logger import get_logger
7 |
8 | logger = get_logger()
9 |
10 |
11 | async def add_crawler(uid: str, typ: str, timeout: int = 10, **kwargs) -> bool:
12 | cmd = {
13 | "type": typ,
14 | "uid": uid,
15 | "client_name": get_config_value("crawler", "client_name")
16 | }
17 | for key in kwargs.keys():
18 | cmd[key] = kwargs[key]
19 | try:
20 | http_api = get_config_value("crawler", "http_url")
21 | async with httpx.AsyncClient() as client:
22 | resp = await client.post(url=http_api+"/add", json=cmd, timeout=timeout)
23 | resp = resp.json()
24 | if(resp["code"] != 0):
25 | logger.error(f"向Crawler添加推送时返回错误!\ncode:{resp['code']} msg:{resp['msg']}")
26 | return {"success":False, "result": resp}
27 | except:
28 | errmsg = traceback.format_exc()
29 | logger.error(f"向Crawler添加推送时出错!错误信息:\n{errmsg}")
30 | return {"success":False, "result": {"code": -1, "msg": errmsg}}
31 | return {"success":True, "result": resp}
32 |
33 | async def remove_crawler(uid: str, typ: str, subtype: str = None) -> bool:
34 | cmd = {
35 | "type": typ,
36 | "uid": uid,
37 | "client_name": get_config_value("crawler", "client_name")
38 | }
39 | if subtype:
40 | cmd["subtype"] = subtype
41 | try:
42 | http_api = get_config_value("crawler", "http_url")
43 | async with httpx.AsyncClient() as client:
44 | resp = await client.post(url=http_api+"/remove", json=cmd, timeout=10)
45 | resp = resp.json()
46 | if(resp["code"] != 0):
47 | logger.error(f"向Crawler删除推送时返回错误!\ncode:{resp['code']} msg:{resp['msg']}")
48 | return {"success":False, "result": resp}
49 | except:
50 | errmsg = traceback.format_exc()
51 | logger.error(f"向Crawler删除推送时出错!错误信息:\n{errmsg}")
52 | return {"success":False, "result": {"code": -1, "msg": errmsg}}
53 | return {"success":True, "result": resp}
--------------------------------------------------------------------------------
/bot/commands/utils/get_user_auth.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import json
3 | import traceback
4 | import httpx
5 | import os
6 |
7 | from ...utils.model import MessageSender
8 | from...utils.config import get_config_value
9 | from ...utils.logger import get_logger
10 |
11 | logger = get_logger()
12 |
13 | async def get_user_auth(bot_id: str, guild_id: str, channel_id: str, user: MessageSender, typ: str = None, subtype: str = None):
14 | user_id = user["user_id"]
15 | http_api = get_config_value("bots", bot_id, "api")
16 | if os.path.exists(os.path.join(get_config_value("data", "path"), "admin.json")):
17 | with open(os.path.join(get_config_value("data", "path"), "admin.json"), "r", encoding="UTF-8") as f:
18 | admins = set(json.load(f))
19 | if user_id in admins:
20 | return True
21 | try:
22 | if guild_id != channel_id:
23 | message = {
24 | "guild_id":guild_id,
25 | "user_id":user_id
26 | }
27 | async with httpx.AsyncClient() as client:
28 | res = await client.post(f"{http_api}/get_guild_member_profile", data = message, headers={'Connection':'close'}, timeout=10)
29 | user_data = res.json()
30 | logger.debug(f"频道{guild_id}子频道{channel_id}用户{user_id}权限查询返回结果:{user_data}")
31 | if(user_data['retcode'] == 0):
32 | roles = user_data['data']['roles']
33 | for role in roles:
34 | if(role['role_id'] in ('2', '4')):
35 | return True
36 | else:
37 | logger.error(f"频道{guild_id}子频道{channel_id}用户{user_id}权限查询返回错误!\ncode:{user_data['retcode']} msg:{user_data['wording']}")
38 | return False
39 | else:
40 | message = {
41 | "group_id":guild_id,
42 | "user_id":user_id
43 | }
44 | async with httpx.AsyncClient() as client:
45 | res = await client.post(f"{http_api}/get_group_member_info", data = message, headers={'Connection':'close'}, timeout=10)
46 | user_data = res.json()
47 | logger.debug(f"群聊{guild_id}用户{user_id}权限查询返回结果:{user_data}")
48 | if(user_data['retcode'] == 0):
49 | role = user_data['data']['role']
50 | if role in ("owner", "admin"):
51 | return True
52 | else:
53 | logger.error(f"群聊{guild_id}用户{user_id}权限查询返回错误!\ncode:{user_data['retcode']} msg:{user_data['wording']}")
54 | return False
55 | except:
56 | errmsg = traceback.format_exc()
57 | logger.error(f"查询用户权限时出错! 错误信息:\n{errmsg}")
--------------------------------------------------------------------------------
/bot/constants/command.py:
--------------------------------------------------------------------------------
1 | command_dict = {
2 | "add": {
3 | "weibo": "添加微博推送",
4 | "bili_dyn": "添加动态推送",
5 | "bili_live": "添加直播推送",
6 | },
7 | "remove": {
8 | "weibo": "删除微博推送",
9 | "bili_dyn": "删除动态推送",
10 | "bili_live": "删除直播推送",
11 | },
12 | "config": {
13 | "channel": "查询配置",
14 | },
15 | "help": "帮助",
16 | "roll": r"^[/\.]?r([0-9]+)d([0-9]+)" # ^指定只从字符串开头进行匹配
17 | }
18 |
19 | HELP = """1 .rxdy 原因
20 | 掷y次x点的骰子
21 | 2 帮助
22 | 显示帮助信息
23 | 3 添加微博/动态/直播推送 UID
24 | 添加指定UID用户的微博/B站动态/直播推送
25 | 例如:添加直播推送 434334701
26 | 4 删除微博/动态/直播推送 UID
27 | 删除指定UID用户的微博/B站动态/直播推送
28 | 例如:删除直播推送 434334701
29 | 5 查询配置
30 | 查询本群聊中配置的所有推送"""
--------------------------------------------------------------------------------
/bot/constants/type.py:
--------------------------------------------------------------------------------
1 | type_dict = {
2 | "weibo": "微博",
3 | "bili_dyn": "动态",
4 | "bili_live": "直播",
5 | "tweet": "推特"
6 | }
7 |
8 | sub_type_dict = {
9 | "weibo": {
10 | "comment": "微博评论"
11 | },
12 | "bili_dyn": {
13 | "comment": "动态评论"
14 | }
15 | }
16 |
17 |
--------------------------------------------------------------------------------
/bot/handlers/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cloud-wish/Dynamic-Bot/f78506629658fad416faf21c8840c3af3e688015/bot/handlers/__init__.py
--------------------------------------------------------------------------------
/bot/handlers/bili_dyn.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import traceback
3 | import json
4 | import copy
5 |
6 | from ..utils.logger import get_logger
7 | from ..utils.config import get_config_value
8 | from ..utils.model import Message, MessageType, BotType
9 | from ..utils.msg_queue import put_message
10 | from ..adapters.init import is_adapter_exist
11 | from ..constants.type import type_dict
12 | from ..handlers.init import HandlerTableDef, HandlerNotFoundException
13 | from .utils.msg_preprocess import msg_preprocess
14 |
15 | from ..builders.build_push_msg import build_push_msg
16 |
17 | from .common import common_push_handler
18 | from .init import get_handler_table
19 |
20 | logger = get_logger()
21 | handlers = get_handler_table()
22 |
23 | dyn_handlers = HandlerTableDef()
24 |
25 | @handlers.handler("bili_dyn")
26 | async def dispatch_dyn_push(typ: str, msg: dict):
27 | msg = msg_preprocess(msg, msg["type"])
28 | try:
29 | await dyn_handlers(msg["subtype"], msg)
30 | except HandlerNotFoundException:
31 | logger.error(f"接收到无法解析的{type_dict.get(typ, '未知类型')}消息!消息内容:\n{json.dumps(msg, ensure_ascii=False)}")
32 | except:
33 | logger.error(f"解析{type_dict.get(typ, '未知类型')}消息时发生错误!错误消息:\n{traceback.format_exc()}")
34 |
35 | @dyn_handlers.handler("dynamic")
36 | async def dyn_handler(subtype: str, data: dict):
37 | await common_push_handler(subtype, data)
38 |
39 | @dyn_handlers.handler("comment")
40 | async def dyn_cmt_handler(subtype: str, data: dict):
41 | pass
--------------------------------------------------------------------------------
/bot/handlers/bili_live.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import traceback
3 | import json
4 | import os
5 |
6 | from ..utils.logger import get_logger
7 | from ..utils.config import get_config_value
8 | from ..utils.model import Message, MessageType, BotType
9 | from ..utils.msg_queue import put_message
10 | from ..adapters.init import is_adapter_exist
11 | from ..constants.type import type_dict
12 | from ..handlers.init import HandlerTableDef, HandlerNotFoundException
13 | from .utils.msg_preprocess import msg_preprocess
14 |
15 | from ..builders.build_push_msg import build_push_msg
16 |
17 | from .common import common_push_handler
18 | from .init import get_handler_table
19 |
20 | logger = get_logger()
21 | handlers = get_handler_table()
22 |
23 | live_handlers = HandlerTableDef()
24 |
25 | @handlers.handler("bili_live")
26 | async def dispatch_live_push(typ: str, msg: dict):
27 | msg = msg_preprocess(msg, msg["type"])
28 | try:
29 | await live_handlers(msg["subtype"], msg)
30 | except HandlerNotFoundException:
31 | logger.error(f"接收到无法解析的{type_dict.get(typ, '未知类型')}消息!消息内容:\n{json.dumps(msg, ensure_ascii=False)}")
32 | except:
33 | logger.error(f"解析{type_dict.get(typ, '未知类型')}消息时发生错误!错误消息:\n{traceback.format_exc()}")
34 |
35 | @live_handlers.handler("cover", "title")
36 | async def live_handler(subtype: str, data: dict):
37 | await common_push_handler(subtype, data)
38 |
39 | @live_handlers.handler("status")
40 | async def live_status_handler(subtype: str, data: dict):
41 | # TODO 替换为统一的db
42 | async def postproc(push_msg: dict, push_ch: tuple[str], push_conf: dict[str]):
43 | if data["now"] == "1" and os.path.exists(os.path.join(get_config_value("data", "path"), "at_all_config.json")):
44 | with open(os.path.join(get_config_value("data", "path"), "at_all_config.json"), "r") as f:
45 | at_all_config = json.load(f)
46 | uid = data["user"]["uid"]
47 | if uid in at_all_config and push_ch in set(tuple(ch) for ch in at_all_config[uid]):
48 | push_msg["data"].insert(0, {"type": "text", "data": {"text": "\n"}})
49 | push_msg["data"].insert(0, {"type": "at","data": {"qq": "all"}})
50 |
51 | await common_push_handler(subtype, data, msg_postproc=postproc)
--------------------------------------------------------------------------------
/bot/handlers/common.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | from typing_extensions import Awaitable
3 |
4 | from ..utils.logger import get_logger
5 | from ..utils.config import get_config_value
6 | from ..utils.model import MessageType, BotType
7 | from ..utils.msg_queue import put_message
8 | from ..utils.db import get_user_push_config
9 | from ..adapters.init import is_adapter_exist
10 | from ..constants.type import type_dict
11 |
12 | from ..builders.build_push_msg import build_push_msg
13 |
14 | logger = get_logger()
15 |
16 | async def common_push_handler(subtype: str, data: dict, msg_postproc: Awaitable = None):
17 | typ = data["type"]
18 | uid = data["user"]["uid"]
19 | if not uid:
20 | logger.error(f"接收到无UID的{type_dict.get(typ, '未知类型')}推送消息!消息内容:\n{data}")
21 | return None
22 | push_configs = get_user_push_config(typ, subtype, uid)
23 | push_data_cache = {}
24 | for push_conf in push_configs:
25 | bot_id = push_conf["bot_id"]
26 | bot_conf = get_config_value("bots", bot_id)
27 | bot_type = BotType(bot_conf["bot_type"])
28 | if not is_adapter_exist(bot_type):
29 | logger.error(f"要推送消息的Bot不存在! Bot信息:{bot_conf}")
30 | continue
31 | for push_ch in push_conf["channels"]:
32 | guild_id, channel_id = push_ch[0], push_ch[1]
33 | if guild_id == channel_id:
34 | msg_type = MessageType.GROUP
35 | else:
36 | msg_type = MessageType.GUILD
37 | if bot_type in push_data_cache and msg_type in push_data_cache[bot_type]:
38 | push_data = push_data_cache[bot_type][msg_type]
39 | else:
40 | push_data = await build_push_msg(data, bot_id, bot_type)
41 | if not bot_type in push_data_cache:
42 | push_data_cache[bot_type] = {}
43 | push_data_cache[bot_type][msg_type] = push_data
44 | push_msg = {
45 | "bot_id": push_conf["bot_id"],
46 | "bot_type": bot_type,
47 | "msg_type": msg_type,
48 | "data": push_data,
49 | "guild_id": guild_id,
50 | "channel_id": channel_id
51 | }
52 | if not msg_postproc is None:
53 | await msg_postproc(push_msg, push_ch, push_conf)
54 | await put_message(push_msg)
--------------------------------------------------------------------------------
/bot/handlers/init.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | from functools import partial
3 | from typing import Any
4 | import os
5 | import importlib
6 |
7 | class HandlerNotFoundException(Exception):
8 | def __init__(self, msg: str = ""):
9 | Exception.__init__(self, msg)
10 |
11 | class HandlerTableDef():
12 |
13 | def __init__(self) -> None:
14 | self._route_dict: dict[str] = {}
15 |
16 | def handler(self, *handler_def_list):
17 | """
18 | 参数顺序:定义时参数+调用时参数
19 | """
20 | def inner(func):
21 | for handler_def in handler_def_list:
22 | typ = handler_def
23 | self._route_dict[typ] = partial(func, handler_def)
24 | return func
25 | return inner
26 |
27 | def __call__(self, typ: str, *args, **kwds) -> Any:
28 | if not typ in self._route_dict:
29 | raise HandlerNotFoundException()
30 | # print(paras)
31 | return self._route_dict[typ](*args, **kwds)
32 |
33 | handlers = HandlerTableDef()
34 |
35 | def get_handler_table():
36 | return handlers
37 |
38 | def auto_import(directory_path):
39 | """
40 | 导入指定目录下的所有 .py 文件
41 |
42 | :param directory_path: 目录路径
43 | """
44 | # 获取指定目录中的所有文件
45 | file_list = os.listdir(directory_path)
46 |
47 | # 遍历目录中的所有文件
48 | for filename in file_list:
49 | if filename.endswith('.py') and filename != '__init__.py' and filename != 'init.py':
50 | module_name = filename[:-3] # 去除文件扩展名
51 | importlib.import_module("." + module_name, __package__)
52 |
53 | auto_import(os.path.dirname(__file__))
54 |
55 | from . import *
--------------------------------------------------------------------------------
/bot/handlers/utils/msg_preprocess.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime, timedelta, timezone
2 |
3 | def msg_preprocess(data: dict, typ: str) -> dict:
4 | if("created_time" in data and type(data["created_time"]) == int):
5 | data["created_time"] = datetime.fromtimestamp(data["created_time"], tz=timezone(timedelta(hours=+8))).strftime("%Y-%m-%d %H:%M:%S")
6 | user = data.get("user", {})
7 | if not "name" in user:
8 | data["user"]["name"] = "[未知用户名]"
9 | if("retweet" in data):
10 | data["retweet"] = msg_preprocess(data["retweet"], typ)
11 | if("reply" in data):
12 | data["reply"] = msg_preprocess(data["reply"], typ)
13 | if("root" in data):
14 | data["root"] = msg_preprocess(data["root"], typ)
15 | return data
--------------------------------------------------------------------------------
/bot/handlers/weibo.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import traceback
3 | import json
4 | import copy
5 |
6 | from ..utils.logger import get_logger
7 | from ..utils.config import get_config_value
8 | from ..utils.model import Message, MessageType, BotType
9 | from ..utils.msg_queue import put_message
10 | from ..adapters.init import is_adapter_exist
11 | from ..constants.type import type_dict
12 | from ..handlers.init import HandlerTableDef, HandlerNotFoundException
13 | from .utils.msg_preprocess import msg_preprocess
14 |
15 | from ..builders.build_push_msg import build_push_msg
16 |
17 | from .common import common_push_handler
18 | from .init import get_handler_table
19 |
20 | logger = get_logger()
21 | handlers = get_handler_table()
22 |
23 | wb_handlers = HandlerTableDef()
24 |
25 | @handlers.handler("weibo")
26 | async def dispatch_wb_push(typ: str, msg: dict):
27 | msg = msg_preprocess(msg, msg["type"])
28 | try:
29 | await wb_handlers(msg["subtype"], msg)
30 | except HandlerNotFoundException:
31 | logger.error(f"接收到无法解析的{type_dict.get(typ, '未知类型')}消息!消息内容:\n{json.dumps(msg, ensure_ascii=False)}")
32 | except:
33 | logger.error(f"解析{type_dict.get(typ, '未知类型')}消息时发生错误!错误消息:\n{traceback.format_exc()}")
34 |
35 | @wb_handlers.handler("weibo")
36 | async def wb_handler(subtype: str, data: dict):
37 | await common_push_handler(subtype, data)
38 |
39 | @wb_handlers.handler("comment")
40 | async def wb_cmt_handler(subtype: str, data: dict):
41 | pass
--------------------------------------------------------------------------------
/bot/tasks/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cloud-wish/Dynamic-Bot/f78506629658fad416faf21c8840c3af3e688015/bot/tasks/__init__.py
--------------------------------------------------------------------------------
/bot/tasks/init.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | from typing import Any, Coroutine
3 | import importlib
4 | import os
5 |
6 | def auto_import(directory_path):
7 | """
8 | 导入指定目录下的所有 .py 文件
9 |
10 | :param directory_path: 目录路径
11 | """
12 | # 获取指定目录中的所有文件
13 | file_list = os.listdir(directory_path)
14 |
15 | # 遍历目录中的所有文件
16 | for filename in file_list:
17 | if filename.endswith('.py') and filename != '__init__.py' and filename != 'init.py':
18 | module_name = filename[:-3] # 去除文件扩展名
19 | importlib.import_module("." + module_name, __package__)
20 |
21 | class TaskTableDef():
22 |
23 | def __init__(self) -> None:
24 | self._task_set: set[Coroutine] = set()
25 |
26 | def task(self):
27 | def inner(task_func):
28 | self._task_set.add(task_func)
29 | return task_func
30 | return inner
31 |
32 | def add(self, task_func: Coroutine):
33 | self._task_set.add(task_func)
34 |
35 | def remove(self, task_func: Coroutine):
36 | self._task_set.remove(task_func)
37 |
38 | def get_all_tasks(self) -> list[Coroutine]:
39 | return list(self._task_set)
40 |
41 | tasks = TaskTableDef()
42 |
43 | def get_task_table():
44 | return tasks
45 |
46 | def get_all_tasks():
47 | return tasks.get_all_tasks()
48 |
49 | auto_import(os.path.dirname(__file__))
50 |
51 | from . import *
--------------------------------------------------------------------------------
/bot/tasks/receive_push.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import json
3 | import asyncio
4 | import websockets
5 | import traceback
6 |
7 | from ..utils.logger import get_logger
8 | from ..utils.config import get_config_value
9 | from ..handlers.init import HandlerNotFoundException
10 | from ..handlers.init import get_handler_table
11 | from .init import get_task_table
12 |
13 | from ..constants.type import type_dict
14 | logger = get_logger()
15 | tasks = get_task_table()
16 | handlers = get_handler_table()
17 |
18 | @tasks.task()
19 | async def receive_push():
20 | while True: # 断线重连
21 | try:
22 | async with websockets.connect(get_config_value("crawler", "ws_url")) as websocket:
23 | logger.info(f"成功建立与Crawler的Websocket连接")
24 | msg = {"type": "init", "client_name": get_config_value("crawler", "client_name")} # 初始化Websocket连接
25 | await websocket.send(json.dumps(msg))
26 | resp = json.loads(await websocket.recv())
27 | if(resp["code"] != 0):
28 | logger.error(f"与Crawler的Websocket连接返回错误!\ncode:{resp['code']} msg:{resp['msg']}")
29 | break
30 |
31 | while True:
32 | # 接收消息
33 | msg = json.loads(await websocket.recv())
34 | logger.debug(f"与Crawler的Websocket连接接收到推送消息, 内容如下:\n{json.dumps(msg, ensure_ascii=False)}")
35 | await dispatch_push(msg)
36 | except Exception as e:
37 | logger.error(f"与Crawler的Websocket连接出错!错误信息:\n{traceback.format_exc()}\n尝试重连...")
38 | await asyncio.sleep(1)
39 |
40 | async def dispatch_push(msg: dict[str]):
41 | try:
42 | typ = msg["type"]
43 | await handlers(typ, msg)
44 | except HandlerNotFoundException:
45 | logger.error(f"接收到无法解析的{type_dict.get(typ, '未知类型')}消息!消息内容:\n{json.dumps(msg, ensure_ascii=False)}")
46 | except:
47 | logger.error(f"解析{type_dict.get(typ, '未知类型')}消息时发生错误!错误消息:\n{traceback.format_exc()}")
48 |
49 | # msg_type = msg["type"]
50 | # subtype = msg["subtype"]
51 | # uid = msg["user"]["uid"]
52 | # if not uid:
53 | # logger.error(f"接收到无UID的{type_dict.get(msg_type, '未知类型')}消息!消息内容:\n{msg}")
54 | # return
55 | # msg_push_config = get_push_config_value(msg_type)
56 | # if(msg_push_config):
57 | # if(subtype in msg_push_config and type(msg_push_config[subtype]) == dict):
58 | # push_channel_list = msg_push_config[subtype].get(uid)
59 | # else:
60 | # push_channel_list = msg_push_config.get(uid)
61 | # if(push_channel_list):
62 | # blocked_channel_list = get_push_config_value("blocked")
63 | # if blocked_channel_list:
64 | # push_channel_list = list(set(push_channel_list) - set(blocked_channel_list))
65 | # push_msg_list = await build_notify_msg(msg, push_channel_list)
66 | # if(push_msg_list):
67 | # logger.info(f"接收到消息:\n{push_msg_list[0]['content']}\n推送频道列表:{push_channel_list}")
68 | # for push_msg in push_msg_list:
69 | # await put_message(push_msg)
--------------------------------------------------------------------------------
/bot/tasks/send_msg.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import asyncio
3 | import traceback
4 | import httpx
5 |
6 | from ..utils.model import Message, MessageType, BotType
7 | from ..utils.logger import get_logger
8 | from ..utils.msg_queue import get_msg_queue
9 | from ..utils.config import get_config_value
10 | from .init import get_task_table
11 |
12 | # from ..adapters.official_bot_client import official_send_guild_msg
13 | from ..adapters.icqq_bot_client import icqq_send_group_msg
14 |
15 | # from botpy.errors import ServerError, AuthenticationFailedError
16 |
17 | logger = get_logger()
18 | tasks = get_task_table()
19 |
20 | msg_queue = get_msg_queue()
21 |
22 | @tasks.task()
23 | async def msg_sender():
24 | while True:
25 | try:
26 | msg: Message = await msg_queue.get()
27 | msg_queue.task_done()
28 | if msg["bot_type"] == BotType.ICQQ:
29 | if msg["msg_type"] == MessageType.GUILD:
30 | raise NotImplementedError("暂未实现ICQQ发送频道消息!")
31 | elif msg["msg_type"] == MessageType.GROUP:
32 | await icqq_send_group_msg(msg)
33 | elif msg["bot_type"] == BotType.OFFICIAL:
34 | raise NotImplementedError("暂未实现官方Bot发送消息!")
35 | except:
36 | logger.error(f"消息发送出错!错误信息:\n{traceback.format_exc()}")
--------------------------------------------------------------------------------
/bot/utils/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cloud-wish/Dynamic-Bot/f78506629658fad416faf21c8840c3af3e688015/bot/utils/__init__.py
--------------------------------------------------------------------------------
/bot/utils/config.py:
--------------------------------------------------------------------------------
1 | import os
2 | import traceback
3 | import httpx
4 | import ruamel.yaml
5 | import copy
6 | from typing import Any
7 |
8 | yaml = ruamel.yaml.YAML()
9 | CONFIG_PATH = os.path.join(os.getcwd(), "config.yaml")
10 | config_dict = None
11 |
12 | # TODO 需要加载文件的配置项处理
13 | # 交给调用代码自己加载
14 | file_value_dict = {}
15 |
16 | class ConfigKeyError(Exception):
17 | def __init__(self, msg: str = ""):
18 | Exception.__init__(self, msg)
19 |
20 | class ConfigFileNotFoundError(Exception):
21 | def __init__(self, msg: str = ""):
22 | Exception.__init__(self, msg)
23 |
24 | class ConfigLoadError(Exception):
25 | def __init__(self, msg: str = ""):
26 | Exception.__init__(self, msg)
27 |
28 | from .logger import get_logger
29 | logger = get_logger()
30 |
31 | def load_config():
32 | global config_dict
33 | try:
34 | with open(CONFIG_PATH, "r", encoding="UTF-8") as f:
35 | config_dict = yaml.load(f)
36 | except FileNotFoundError:
37 | raise ConfigFileNotFoundError
38 |
39 | def save_config():
40 | with open(CONFIG_PATH, "w", encoding="UTF-8") as f:
41 | yaml.dump(config_dict, f)
42 |
43 | def get_config_value(*args) -> Any:
44 | value = config_dict
45 | for key in args:
46 | if not type(value) == ruamel.yaml.CommentedMap:
47 | raise ConfigKeyError(f"\nkey列表:{args}\n出错的key:{key}\n当前的value:{value}")
48 | value = value[key]
49 | if type(value) == dict():
50 | return copy.deepcopy(value)
51 | else:
52 | return value
53 |
54 | def set_config_value(value_new: Any, *args) -> Any:
55 | value = config_dict
56 | if not args:
57 | raise ConfigKeyError("需要至少1个key值")
58 | for key in args[:-1]:
59 | if not type(value) == ruamel.yaml.CommentedMap:
60 | raise ConfigKeyError(f"\nkey列表:{args}\n出错的key:{key}\n当前的value:{value}")
61 | value = value[key]
62 | value[args[-1]] = value_new
63 |
64 | def init_config():
65 | load_config()
--------------------------------------------------------------------------------
/bot/utils/db.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import os
3 | from .config import get_config_value
4 |
5 | from tinydb import TinyDB, where
6 |
7 | def init_db():
8 | os.makedirs(get_config_value("data", "path"), exist_ok=True)
9 | pass
10 |
11 | def add_push(typ: str, subtype: str, uid: str, bot_id: str, guild_id: str, channel_id: str):
12 | push_db = TinyDB(os.path.join(get_config_value("data", "path"), "push_config.json"))
13 | try:
14 | if not push_db.contains(where("bot_id") == bot_id):
15 | push_config = {
16 | "bot_id": bot_id
17 | }
18 | else:
19 | push_config = push_db.get(where("bot_id") == bot_id)
20 | if not typ in push_config:
21 | push_config[typ] = {}
22 | if not subtype in push_config[typ]:
23 | push_config[typ][subtype] = {}
24 | if not uid in push_config[typ][subtype]:
25 | push_config[typ][subtype][uid] = []
26 | push_channels: set[tuple[str]] = set(tuple(ch) for ch in push_config[typ][subtype][uid])
27 | if not (guild_id, channel_id) in push_channels:
28 | push_channels.add((guild_id, channel_id))
29 | push_config[typ][subtype][uid] = list(push_channels)
30 | push_db.upsert(push_config, where("bot_id") == bot_id)
31 | except Exception as e:
32 | raise e
33 | finally:
34 | push_db.close()
35 |
36 | def remove_push(typ: str, subtype: str, uid: str, bot_id: str, guild_id: str, channel_id: str):
37 | push_db = TinyDB(os.path.join(get_config_value("data", "path"), "push_config.json"))
38 | try:
39 | if not push_db.contains(where("bot_id") == bot_id):
40 | return
41 | push_config = push_db.get(where("bot_id") == bot_id)
42 | if not typ in push_config or \
43 | not subtype in push_config[typ] or \
44 | not uid in push_config[typ][subtype]:
45 | return
46 | push_channels: set[tuple[str]] = set(tuple(ch) for ch in push_config[typ][subtype][uid])
47 | if (guild_id, channel_id) in push_channels:
48 | push_channels.remove((guild_id, channel_id))
49 | if len(push_channels) == 0:
50 | del push_config[typ][subtype][uid]
51 | else:
52 | push_config[typ][subtype][uid] = list(push_channels)
53 | push_db.upsert(push_config, where("bot_id") == bot_id)
54 | except Exception as e:
55 | raise e
56 | finally:
57 | push_db.close()
58 |
59 | def exist_push(typ: str, subtype: str, uid: str, bot_id: str, guild_id: str, channel_id: str):
60 | push_db = TinyDB(os.path.join(get_config_value("data", "path"), "push_config.json"))
61 | try:
62 | if not push_db.contains(where("bot_id") == bot_id):
63 | return False
64 | push_config = push_db.get(where("bot_id") == bot_id)
65 | if not typ in push_config or \
66 | not subtype in push_config[typ] or \
67 | not uid in push_config[typ][subtype]:
68 | return False
69 | push_channels: set[tuple[str]] = set(tuple(ch) for ch in push_config[typ][subtype][uid])
70 | return (guild_id, channel_id) in push_channels
71 | except Exception as e:
72 | raise e
73 | finally:
74 | push_db.close()
75 |
76 | def exist_push_user(uid: str, typ: str, subtype: str = None):
77 | push_db = TinyDB(os.path.join(get_config_value("data", "path"), "push_config.json"))
78 | try:
79 | push_configs = push_db.all()
80 | for push_config in push_configs:
81 | if not typ in push_config:
82 | continue
83 | if subtype is None:
84 | for subtype in push_config[typ].keys():
85 | if uid in push_config[typ][subtype]:
86 | return True
87 | else:
88 | if subtype in push_config[typ] and uid in push_config[typ][subtype]:
89 | return True
90 | return False
91 | except Exception as e:
92 | raise e
93 | finally:
94 | push_db.close()
95 |
96 | def get_user_push_config(typ: str, subtype: str, uid: str):
97 | results: list[dict[str,list[tuple[str]]]] = []
98 | push_db = TinyDB(os.path.join(get_config_value("data", "path"), "push_config.json"))
99 | try:
100 | push_configs = push_db.all()
101 | for push_config in push_configs:
102 | if not typ in push_config or \
103 | not subtype in push_config[typ] or \
104 | not uid in push_config[typ][subtype]:
105 | continue
106 | results.append({"bot_id": push_config["bot_id"], "channels": [tuple(ch) for ch in push_config[typ][subtype][uid]]})
107 | return results
108 | except Exception as e:
109 | raise e
110 | finally:
111 | push_db.close()
112 |
113 | def get_bot_push_config(bot_id: str, guild_id: str, channel_id: str, typ: str, subtype: str = None) -> dict[str, dict]:
114 | push_db = TinyDB(os.path.join(get_config_value("data", "path"), "push_config.json"))
115 | result: dict[str] = {}
116 | result[typ] = []
117 | try:
118 | if push_db.contains(where("bot_id") == bot_id):
119 | push_config = push_db.get(where("bot_id") == bot_id)
120 | if typ in push_config:
121 | if subtype is None:
122 | uids = set()
123 | for subtype in push_config[typ].keys():
124 | for uid in push_config[typ][subtype].keys():
125 | push_channels: set[tuple[str]] = set(tuple(ch) for ch in push_config[typ][subtype][uid])
126 | if (guild_id, channel_id) in push_channels:
127 | uids.add(uid)
128 | result[typ] = list(uids)
129 | elif subtype in push_config[typ]:
130 | result[typ] = {}
131 | result[typ][subtype] = []
132 | for uid in push_config[typ][subtype].keys():
133 | push_channels: set[tuple[str]] = set(tuple(ch) for ch in push_config[typ][subtype][uid])
134 | if (guild_id, channel_id) in push_channels:
135 | result[typ][subtype].append(uid)
136 | return result
137 | except Exception as e:
138 | raise e
139 | finally:
140 | push_db.close()
--------------------------------------------------------------------------------
/bot/utils/logger.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 | import ruamel.yaml
4 | from logging.handlers import TimedRotatingFileHandler
5 |
6 | from .config import CONFIG_PATH, ConfigFileNotFoundError
7 |
8 | LOGGER_PRINT_FORMAT = "\033[1;33m%(asctime)s [%(levelname)s] (%(filename)s:%(lineno)s) %(funcName)s:\033[0m\n%(message)s"
9 | LOGGER_FILE_FORMAT = "%(asctime)s [%(levelname)s] (%(filename)s:%(lineno)s) %(funcName)s:\n%(message)s"
10 | logging.basicConfig(format=LOGGER_PRINT_FORMAT)
11 |
12 | logger = None
13 | logger_name = None
14 |
15 | def get_logger() -> logging.Logger:
16 | global logger
17 | if logger is None:
18 | init_logger()
19 | return logger
20 |
21 | def get_logger_name() -> str:
22 | global logger_name
23 | if logger_name is None:
24 | init_logger()
25 | return logger_name
26 |
27 | def init_logger() -> logging.Logger:
28 | global logger
29 | yaml = ruamel.yaml.YAML()
30 | try:
31 | with open(CONFIG_PATH, "r", encoding="UTF-8") as f:
32 | config_dict = yaml.load(f)
33 | except FileNotFoundError:
34 | raise ConfigFileNotFoundError
35 | logger_config = config_dict["logger"]
36 | is_debug = logger_config["debug"]
37 | logger_name = logger_config["name"]
38 | logger_path = os.path.join(os.getcwd(), "logs", f"{logger_name}.log")
39 | os.makedirs(os.path.dirname(logger_path), exist_ok=True)
40 | logger = logging.getLogger(logger_name)
41 | handler = TimedRotatingFileHandler(logger_path, when="midnight", interval=1, encoding="UTF-8")
42 | handler.setFormatter(logging.Formatter(LOGGER_FILE_FORMAT))
43 | # 从配置文件接收是否打印debug日志
44 | if is_debug:
45 | logger.setLevel(level=logging.DEBUG)
46 | handler.level = logging.DEBUG
47 | else:
48 | logger.setLevel(logging.INFO)
49 | handler.level = logging.INFO
50 | logger.addHandler(handler)
--------------------------------------------------------------------------------
/bot/utils/model.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | from typing import TypedDict
3 | from enum import Enum
4 |
5 | class MessageType(str, Enum):
6 | GUILD = "guild"
7 | GROUP = "group"
8 |
9 | class BotType(str, Enum):
10 | OFFICIAL = "official"
11 | ICQQ = "icqq"
12 |
13 | class Message(TypedDict):
14 | content: str
15 | guild_id: str
16 | channel_id: str
17 | msg_type: MessageType
18 | bot_type: BotType
19 | bot_id: str
20 | sender: MessageSender
21 | raw: dict[str]
22 |
23 | class MessageSender(TypedDict):
24 | user_id: str
25 | nickname: str
26 | raw: dict[str]
--------------------------------------------------------------------------------
/bot/utils/msg_queue.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import asyncio
3 | import json
4 |
5 | from .model import Message, MessageType
6 | from .logger import get_logger
7 |
8 | logger = get_logger()
9 |
10 | msg_queue = asyncio.Queue()
11 |
12 | def get_msg_queue():
13 | return msg_queue
14 |
15 | async def put_message(msg: dict):
16 | if(len(msg["data"]) == 0):
17 | if msg["msg_type"] == MessageType.GUILD:
18 | logger.error(f"发送至频道{msg['guild_id']}的子频道{msg['channel_id']}的消息为空!")
19 | elif msg["msg_type"] == MessageType.GROUP:
20 | logger.error(f"发送至群聊{msg['guild_id']}的消息为空!")
21 | return
22 | if not "bot_id" in msg:
23 | logger.error(f"消息未指定发送的Bot! 消息内容:{json.dumps(msg, ensure_ascii=False)}")
24 | return
25 | await msg_queue.put(msg)
--------------------------------------------------------------------------------
/bot/utils/pic_process.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import io
3 | import os
4 | import httpx
5 | from math import ceil
6 | from PIL import Image, ImageFont, ImageDraw
7 | from fontTools.ttLib import TTFont
8 |
9 | font_path_list = [
10 | "msyh.ttc",
11 | "notocoloremoji.ttf",
12 | "unifont.ttf"
13 | ]
14 | font_path_list = [os.path.join(os.getcwd(), "fonts", font_path) for font_path in font_path_list]
15 | judge_font_list = [TTFont(font_path, fontNumber=0) for font_path in font_path_list]
16 | draw_font_list: list[ImageFont.FreeTypeFont] = []
17 | for font_path in font_path_list:
18 | draw_font_list.append(ImageFont.truetype(font_path, size=109))
19 |
20 | def join_pic(img_1: bytes, img_2: bytes, flag: str = 'y', fmt: str = "jpeg"):
21 | img1 = Image.open(io.BytesIO(img_1))
22 | img2 = Image.open(io.BytesIO(img_2))
23 | size1, size2 = img1.size, img2.size
24 | if flag == 'x':
25 | joint = Image.new("RGB", (size1[0] + size2[0], max(size1[1], size2[1])), (255,255,255))
26 | loc1, loc2 = (0, 0), (size1[0], 0)
27 | else:
28 | joint = Image.new("RGB", (max(size1[0], size2[0]), size2[1]+size1[1]), (255,255,255))
29 | loc1, loc2 = (0, 0), (0, size1[1])
30 | joint.paste(img1, loc1)
31 | joint.paste(img2, loc2)
32 | output = io.BytesIO()
33 | joint.save(output, format=fmt)
34 | return output.getvalue()
35 |
36 | def modify_pic(pic: bytes) -> bytes:
37 | image = Image.open(io.BytesIO(pic))
38 | width = image.size[0]
39 | height = image.size[1]
40 | scale = 2
41 | if((width/height) > scale):
42 | res = Image.new(mode = 'RGB', size=(width, int(width/scale)+1), color = (255, 255, 255))
43 | res.paste(image, (0,int((res.height - height)/2)))
44 | # if(res.size[0] > 1000):
45 | # scale = 1000/res.size[0]
46 | # res = res.resize((int(res.size[0]*scale), int(res.size[1]*scale)), Image.Resampling.LANCZOS)
47 | output = io.BytesIO()
48 | res.save(output, format="jpeg")
49 | return output.getvalue()
50 | else:
51 | return pic
52 |
53 | def compress_pic(pic: bytes, compress_cnt: int = 1) -> bytes:
54 | image = Image.open(io.BytesIO(pic))
55 | image = image.convert('RGB')
56 | while(compress_cnt > 0):
57 | image = image.resize((ceil(image.size[0] / 1.5), ceil(image.size[1] / 1.5)), Image.Resampling.LANCZOS)
58 | compress_cnt -= 1
59 | output = io.BytesIO()
60 | image.save(output, format="jpeg")
61 | return output.getvalue()
62 |
63 | def save_pic(pic: bytes, pic_path: str) -> bool:
64 | os.makedirs(os.path.dirname(pic_path), exist_ok=True)
65 | with open(pic_path, "wb") as f:
66 | f.write(pic)
67 | return True
68 |
69 | def has_glyph_font(glyph) -> int:
70 | for i in range(len(judge_font_list)):
71 | for table in judge_font_list[i]['cmap'].tables:
72 | if ord(glyph) in table.cmap.keys():
73 | return i
74 | return len(judge_font_list) - 1
75 |
76 | def extend_pic(img_upper: Image.Image, extend_height: int, bg_color: str) -> Image.Image:
77 | img_new = Image.new("RGB", (img_upper.size[0], img_upper.size[1] + extend_height), bg_color)
78 | img_new.paste(img_upper, box=(0,0))
79 | # img_new.show()
80 | return img_new
81 |
82 | def input_pic_textbox(img: Image.Image, draw: ImageDraw.ImageDraw, xy: list[tuple[int]], raw_text: str, color: str, font_list: list[ImageFont.FreeTypeFont], bg_color: str, mode: str = "left"):
83 | raw_text = raw_text.replace("\r", "")
84 | box_width = xy[1][0] - xy[0][0]
85 | box_height = xy[1][1] - xy[0][1]
86 | line_text: list[str] = [""]
87 | line_height: list[int] = [0]
88 | line_width: list[int] = [0]
89 | total_height: int = 0
90 | cur_line = 0
91 | # print(raw_text, box_width)
92 | for i, c in enumerate(raw_text):
93 | font = font_list[has_glyph_font(c)]
94 | c_size = draw.textbbox((0,0), c, font)[2:4:]
95 | if(line_width[cur_line] + c_size[0] >= box_width or c == "\n"):
96 | # print(line_text[cur_line], line_height[cur_line], line_width[cur_line])
97 | cur_line += 1
98 | line_width.append(0)
99 | line_height.append(0)
100 | line_text.append("")
101 | line_width[cur_line] += c_size[0]
102 | if not (c == "\n" and i+1 < len(raw_text) and raw_text[i+1] != "\n"):
103 | line_height[cur_line] = max(line_height[cur_line], c_size[1])
104 | line_text[cur_line] += c
105 | for height in line_height:
106 | total_height += height
107 | if(total_height > box_height):
108 | img = extend_pic(img, total_height - box_height, bg_color)
109 | draw = ImageDraw.Draw(img)
110 | cur_y = 0
111 | for i in range(len(line_text)):
112 | if(mode == "left"):
113 | cur_x = 0
114 | elif(mode == "center"):
115 | cur_x = (box_width - line_width[i])/2
116 | elif(mode == "right"):
117 | cur_x = (box_width - line_width[i])
118 | for c in line_text[i]:
119 | font = font_list[has_glyph_font(c)]
120 | c_size = draw.textbbox((0,0), c, font)[2:4:]
121 | draw.text((xy[0][0] + cur_x, xy[0][1] + cur_y), c, font=font, fill=color, embedded_color=True)
122 | cur_x += c_size[0]
123 | cur_y += line_height[i]
124 | return (img, draw, total_height + xy[0][1])
125 |
126 | def print_text_on_pic(img: Image.Image, height: int, text: str) -> tuple[Image.Image, int]:
127 | draw = ImageDraw.Draw(img)
128 | upper_left = (0, height)
129 | lower_right = img.size
130 | img, draw, res_height = input_pic_textbox(img, draw, [upper_left, lower_right], text, "black", draw_font_list, "white")
131 | return (img, res_height)
132 |
133 | def print_img_on_pic(img: Image.Image, height: int, input_img: bytes) -> tuple[Image.Image, int]:
134 | input_img: Image.Image = Image.open(io.BytesIO(input_img))
135 | added_height = input_img.size[1]
136 | if height + added_height > img.size[1]:
137 | img = extend_pic(img, height+added_height-img.size[1], "white")
138 | img.paste(input_img, box=(0,height))
139 | return (img, height+added_height)
140 |
141 | def resize_pic(img: Image.Image, width: int = None, height: int = None, scale: float = None) -> Image.Image:
142 | if width:
143 | scale = width / img.size[0]
144 | elif height:
145 | scale = height / img.size[1]
146 | img = img.resize((ceil(img.size[0] * scale), ceil(img.size[1] * scale)), Image.Resampling.LANCZOS)
147 | return img
148 |
149 | def img_to_bytes(img: Image.Image, fmt: str = "jpeg") -> bytes:
150 | output = io.BytesIO()
151 | if fmt != "png" and img.mode == "RGBA":
152 | img = img.convert("RGB")
153 | img.save(output, format=fmt)
154 | return output.getvalue()
155 |
156 | def horizontal_padding_pic(img: Image.Image, extend_width: int, bg_color: str, center: bool = True) -> Image.Image:
157 | width = img.size[0]
158 | height = img.size[1]
159 | res = Image.new(mode = 'RGB', size=(width + extend_width, height), color = bg_color)
160 | res.paste(img, (int(extend_width/2),0))
161 | return res
162 |
163 | def print_on_pic(msg_list: list[dict]) -> bytes:
164 | width = 3000
165 | final_width = 1000
166 | scale = final_width/width
167 | img = Image.new("RGB", (width, 500), "white")
168 | height = 0
169 | pic_list: list[dict] = []
170 | for msg in msg_list:
171 | if msg["type"] == "text":
172 | img, height = print_text_on_pic(img, height, msg["content"])
173 | else:
174 | try:
175 | msg["content"] = Image.open(io.BytesIO(msg["content"]))
176 | except:
177 | img, height = print_text_on_pic(img, height, "[图片加载失败]")
178 | continue
179 | if msg.get("para", {}).get("extend_width", False):
180 | msg["content"] = resize_pic(msg["content"], width=final_width)
181 | else:
182 | msg["content"] = resize_pic(msg["content"], width=ceil(final_width*0.7))
183 | pic_list.append({
184 | "pic": msg["content"],
185 | "height": ceil(height*scale) + 20
186 | })
187 | height += ceil(msg["content"].size[1]/scale) + 20 + 20
188 | img = resize_pic(img, width=final_width)
189 | for pic in pic_list:
190 | if(pic["pic"].size[1] + pic["height"] > img.size[1]):
191 | img = extend_pic(img, pic["pic"].size[1]+pic["height"]-img.size[1], "white")
192 | img.paste(pic["pic"], (0,pic["height"]))
193 | img = horizontal_padding_pic(img, 60, "white")
194 | return img_to_bytes(img)
195 |
196 | async def download_pic(url: str, headers: dict = None) -> bytes:
197 | if not headers:
198 | headers = {
199 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36"
200 | }
201 | async with httpx.AsyncClient() as client:
202 | resp = await client.get(url=url, headers=headers)
203 | return resp.content
--------------------------------------------------------------------------------
/config_sample.yaml:
--------------------------------------------------------------------------------
1 | logger: # 日志记录配置
2 | name: dynamic-bot # logger名称
3 | debug: false # 开启debug日志
4 | sender: # 消息发送配置
5 | debug: false # 开启后, Bot将不会实际发送消息, 用于测试
6 | timeout: 15 # 消息发送请求超时时间(s)
7 | data: # 数据配置
8 | path: data # 数据目录
9 | builder: # 动态推送消息配置
10 | bili_dyn: # bilibili动态
11 | is_pc: false # 使用PC端网页样式
12 | is_new_layout: true # 尝试使用新版移动端网页样式 (bilibili会根据动态内容判断是否允许使用新版)
13 | pic_fail_fallback: true # 动态图片生成失败时, 回退到文字+图片格式发送消息
14 | link: true # 消息是否包括原动态网页链接
15 | weibo: # 微博
16 | link: true # 消息是否包括原动态网页链接
17 | crawler: # Crawler连接配置
18 | ws_url: ws://localhost:30773 # websocket地址
19 | http_url: http://localhost:31773 # http地址
20 | client_name: dynamic-bot # bot名称
21 | bots: # bot配置, 支持多个bot, 不同平台
22 | bot1: # bot名称
23 | bot_type: icqq # bot类型, 对应adapters中的适配器注册类型, 目前提供对icqq/go-cqhttp在群聊中的完整支持
24 | api: http://127.0.0.1:5800 # http api地址
25 | websocket: # websocket配置
26 | host: 127.0.0.1
27 | port: 30001
28 | relay: # 转发在此bot接收的所有websocket消息
29 | enable: true
30 | url: ws://localhost:30002
31 | retry_count: 1 # 转发失败时的重试次数
32 |
--------------------------------------------------------------------------------
/docs/design.md:
--------------------------------------------------------------------------------
1 | # 整体框架
2 | - [adapters](https://github.com/Cloud-wish/Dynamic-Bot/blob/main/bot/adapters): 对接各种Bot框架,实现消息收发
3 | - [commands](https://github.com/Cloud-wish/Dynamic-Bot/blob/main/bot/commands):处理接收到的指令并返回结果
4 | - [tasks](https://github.com/Cloud-wish/Dynamic-Bot/blob/main/bot/tasks):需要持续运行的协程(例如:从Crawler接收消息推送)
5 | - [handlers](https://github.com/Cloud-wish/Dynamic-Bot/blob/main/bot/handlers):需要推送的消息处理器
6 | - [builders](https://github.com/Cloud-wish/Dynamic-Bot/blob/main/bot/builders):将收到的推送消息构建为实际的推送消息
7 | # 功能扩展
8 | - 除了[builders](https://github.com/Cloud-wish/Dynamic-Bot/blob/main/bot/builders)以外的以上4类,只需要在对应文件夹下新建代码文件,导入`init.py`中的`get_{type}_table`函数获取到`{type}_tables`,并在实际处理的协程前添加注解`@{type}_tables.{type}()`即可自动导入,并在满足设定的条件时执行。不同类型的注解中可使用的参数不同,可参考各类型下`init.py`中的具体实现。
9 |
10 | 示例:
11 | ```python
12 | from ..utils.model import Message
13 | from .init import get_command_table
14 |
15 | command_table = get_command_table()
16 |
17 | @command_table.cmd("my_custom_command") # command支持多指令, 正则表达式匹配, 预设参数
18 | async def my_custom_command(cmd: str, msg: Message):
19 | ...
20 | ```
21 | - [builders](https://github.com/Cloud-wish/Dynamic-Bot/blob/main/bot/builders)不会被自动导入,需要[handlers](https://github.com/Cloud-wish/Dynamic-Bot/blob/main/bot/handlers)中的消息处理器手动调用。
22 |
23 | # 数据流
24 | - 指令处理数据流:
25 | - adapter接收到消息,转换为Message格式
26 | - dispatch_msg将指令分发给对应的command
27 | - command进行命令处理,并将结果消息发送到消息队列
28 | - send_msg从消息队列中取出消息,进行消息发送
29 | - 推送消息数据流:
30 | - receive_push从crawler接收,转交给dispatch_push
31 | - dispatch_push将消息分发给对应推送类型的handler
32 | - handler调用builder构建实际的推送消息,并发送到消息队列
33 | - send_msg从消息队列中取出消息,进行消息发送
--------------------------------------------------------------------------------
/fonts/msyh.ttc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cloud-wish/Dynamic-Bot/f78506629658fad416faf21c8840c3af3e688015/fonts/msyh.ttc
--------------------------------------------------------------------------------
/fonts/notocoloremoji.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cloud-wish/Dynamic-Bot/f78506629658fad416faf21c8840c3af3e688015/fonts/notocoloremoji.ttf
--------------------------------------------------------------------------------
/fonts/unifont.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cloud-wish/Dynamic-Bot/f78506629658fad416faf21c8840c3af3e688015/fonts/unifont.ttf
--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import asyncio
3 |
4 | from bot.utils.config import init_config, get_config_value
5 | init_config()
6 |
7 | from bot.utils.db import init_db
8 | from bot.utils.model import BotType
9 | from bot.tasks.init import get_all_tasks
10 | from bot.adapters.init import get_adapter, get_adapter_table
11 | from bot.utils.logger import get_logger
12 |
13 | logger = get_logger()
14 |
15 | def main():
16 | init_db()
17 | async_tasks = [task() for task in get_all_tasks()]
18 | bots = get_config_value("bots")
19 |
20 | logger.debug("adapter list:"+repr(get_adapter_table()._adapter_dict))
21 |
22 | for bot_id, bot_conf in bots.items():
23 | bot_type = BotType(bot_conf["bot_type"])
24 | bot_adapter = get_adapter(bot_type)
25 | if bot_adapter is None:
26 | logger.error(f"未找到适配器: {bot_type}")
27 | continue
28 | async_tasks.append(bot_adapter(bot_conf, bot_id))
29 | try:
30 | asyncio.get_event_loop().run_until_complete(asyncio.gather(
31 | *async_tasks
32 | ))
33 | except KeyboardInterrupt:
34 | logger.info("收到键盘中断, Bot退出")
35 | exit(0)
36 |
37 | if __name__ == '__main__':
38 | main()
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | fonttools~=4.40.0
2 | httpx~=0.27.0
3 | Pillow~=9.5.0
4 | playwright~=1.19.0
5 | ruamel.yaml~=0.17.32
6 | tinydb~=4.8.0
7 | websockets~=10.1
8 |
--------------------------------------------------------------------------------