├── .github
└── workflows
│ └── build.yaml
├── .gitignore
├── CHANGELOG.md
├── LICENSE
├── README.md
├── build.gradle.kts
├── gradle.properties
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── local.properties
├── settings.gradle.kts
└── src
├── main
├── kotlin
│ └── xyz
│ │ └── cssxsh
│ │ ├── mirai
│ │ └── weibo
│ │ │ ├── WeiboFilter.kt
│ │ │ ├── WeiboHelperPlugin.kt
│ │ │ ├── WeiboHistoryDelegate.kt
│ │ │ ├── WeiboListener.kt
│ │ │ ├── WeiboPicture.kt
│ │ │ ├── WeiboSubscriber.kt
│ │ │ ├── WeiboUtils.kt
│ │ │ ├── command
│ │ │ ├── WeiboCacheCommand.kt
│ │ │ ├── WeiboContext.kt
│ │ │ ├── WeiboDetailCommand.kt
│ │ │ ├── WeiboFollowCommand.kt
│ │ │ ├── WeiboGroupCommand.kt
│ │ │ ├── WeiboHotCommand.kt
│ │ │ ├── WeiboLoginCommand.kt
│ │ │ ├── WeiboSuperChatCommand.kt
│ │ │ └── WeiboUserCommand.kt
│ │ │ └── data
│ │ │ ├── OffsetDateTimeSerializer.kt
│ │ │ ├── RegexSerializer.kt
│ │ │ ├── WeiboEmoticonData.kt
│ │ │ ├── WeiboHelperSettings.kt
│ │ │ ├── WeiboStatusData.kt
│ │ │ ├── WeiboTaskData.kt
│ │ │ └── WeiboTaskInfo.kt
│ │ └── weibo
│ │ ├── AcceptAllCookiesStorage.kt
│ │ ├── Load.kt
│ │ ├── WeiboClient.kt
│ │ ├── api
│ │ ├── Api.kt
│ │ ├── Feed.kt
│ │ ├── Login.kt
│ │ ├── Profile.kt
│ │ ├── Statuses.kt
│ │ └── SuperChat.kt
│ │ └── data
│ │ ├── Login.kt
│ │ ├── MicroBlog.kt
│ │ ├── PageInfo.kt
│ │ ├── Serializer.kt
│ │ ├── SuperChat.kt
│ │ ├── UserDetailData.kt
│ │ └── UserGroupData.kt
└── resources
│ ├── META-INF
│ └── services
│ │ ├── net.mamoe.mirai.console.command.Command
│ │ ├── net.mamoe.mirai.console.data.PluginConfig
│ │ ├── net.mamoe.mirai.console.data.PluginData
│ │ └── net.mamoe.mirai.console.plugin.jvm.JvmPlugin
│ └── xyz
│ └── cssxsh
│ └── mirai
│ └── weibo
│ └── data
│ └── Emoticons.json
└── test
└── kotlin
└── xyz
└── cssxsh
├── mirai
└── weibo
│ └── data
│ └── WeiboEmoticonDataTest.kt
└── weibo
├── FeedKtTest.kt
├── LoginKtTest.kt
├── ProfileKtTest.kt
└── WeiboClientTest.kt
/.github/workflows/build.yaml:
--------------------------------------------------------------------------------
1 | name: "build"
2 | on:
3 | push:
4 | paths-ignore:
5 | - '**/*.md'
6 | pull_request:
7 | paths-ignore:
8 | - '**/*.md'
9 |
10 | jobs:
11 | release:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - name: Checkout
15 | uses: actions/checkout@v3
16 |
17 | - name: Setup JDK 11
18 | uses: actions/setup-java@v3
19 | with:
20 | distribution: 'adopt'
21 | java-version: '11'
22 |
23 | - name: chmod -R 777 *
24 | run: chmod -R 777 *
25 |
26 | - name: BuildPlugin
27 | run: ./gradlew buildPlugin
28 |
29 | - name: Upload
30 | uses: actions/upload-artifact@v3
31 | with:
32 | name: build-${{ github.run_id }}
33 | path: build/mirai/*
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Project exclude paths
2 | **/.gradle/
3 | **/build/
4 |
5 | # idea
6 | /.idea/
7 |
8 | # mirai
9 | /data/
10 | /plugins/
11 | /logs/
12 | /test/
13 | /run/
14 |
15 | # temp
16 | /temp/
17 |
18 | debug-sandbox
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## 1.6.2 (23/04/17)
2 |
3 | * feat: 屏蔽点赞转发设置
4 |
5 | ## 1.6.1 (23/02/20)
6 |
7 | * fix: restore
8 |
9 | 修复刷新 `cookie` 的等待时间
10 |
11 | ## 1.6.0 (23/02/12)
12 |
13 | * style: explicitApi
14 | * feat: 超话指令
15 | * fix: logger level
16 | * fix: auto.parser
17 |
18 | 原 `xyz.cssxsh.mirai.plugin.weibo-helper:quiet.group` 取消
19 | 改为 `xyz.cssxsh.mirai.plugin.weibo-helper:auto.parser`
20 | 持有权限的用户(群员)才会触发自动解析
21 |
22 | MCL 更新通道推荐改为 `maven-stable`
23 | 原 `stable` 今后不再积极更新
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU AFFERO GENERAL PUBLIC LICENSE
2 | Version 3, 19 November 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
5 | Everyone is permitted to copy and distribute verbatim copies
6 | of this license document, but changing it is not allowed.
7 |
8 | Preamble
9 |
10 | The GNU Affero General Public License is a free, copyleft license for
11 | software and other kinds of works, specifically designed to ensure
12 | cooperation with the community in the case of network server software.
13 |
14 | The licenses for most software and other practical works are designed
15 | to take away your freedom to share and change the works. By contrast,
16 | our General Public Licenses are intended to guarantee your freedom to
17 | share and change all versions of a program--to make sure it remains free
18 | software for all its users.
19 |
20 | When we speak of free software, we are referring to freedom, not
21 | price. Our General Public Licenses are designed to make sure that you
22 | have the freedom to distribute copies of free software (and charge for
23 | them if you wish), that you receive source code or can get it if you
24 | want it, that you can change the software or use pieces of it in new
25 | free programs, and that you know you can do these things.
26 |
27 | Developers that use our General Public Licenses protect your rights
28 | with two steps: (1) assert copyright on the software, and (2) offer
29 | you this License which gives you legal permission to copy, distribute
30 | and/or modify the software.
31 |
32 | A secondary benefit of defending all users' freedom is that
33 | improvements made in alternate versions of the program, if they
34 | receive widespread use, become available for other developers to
35 | incorporate. Many developers of free software are heartened and
36 | encouraged by the resulting cooperation. However, in the case of
37 | software used on network servers, this result may fail to come about.
38 | The GNU General Public License permits making a modified version and
39 | letting the public access it on a server without ever releasing its
40 | source code to the public.
41 |
42 | The GNU Affero General Public License is designed specifically to
43 | ensure that, in such cases, the modified source code becomes available
44 | to the community. It requires the operator of a network server to
45 | provide the source code of the modified version running there to the
46 | users of that server. Therefore, public use of a modified version, on
47 | a publicly accessible server, gives the public access to the source
48 | code of the modified version.
49 |
50 | An older license, called the Affero General Public License and
51 | published by Affero, was designed to accomplish similar goals. This is
52 | a different license, not a version of the Affero GPL, but Affero has
53 | released a new version of the Affero GPL which permits relicensing under
54 | this license.
55 |
56 | The precise terms and conditions for copying, distribution and
57 | modification follow.
58 |
59 | TERMS AND CONDITIONS
60 |
61 | 0. Definitions.
62 |
63 | "This License" refers to version 3 of the GNU Affero General Public License.
64 |
65 | "Copyright" also means copyright-like laws that apply to other kinds of
66 | works, such as semiconductor masks.
67 |
68 | "The Program" refers to any copyrightable work licensed under this
69 | License. Each licensee is addressed as "you". "Licensees" and
70 | "recipients" may be individuals or organizations.
71 |
72 | To "modify" a work means to copy from or adapt all or part of the work
73 | in a fashion requiring copyright permission, other than the making of an
74 | exact copy. The resulting work is called a "modified version" of the
75 | earlier work or a work "based on" the earlier work.
76 |
77 | A "covered work" means either the unmodified Program or a work based
78 | on the Program.
79 |
80 | To "propagate" a work means to do anything with it that, without
81 | permission, would make you directly or secondarily liable for
82 | infringement under applicable copyright law, except executing it on a
83 | computer or modifying a private copy. Propagation includes copying,
84 | distribution (with or without modification), making available to the
85 | public, and in some countries other activities as well.
86 |
87 | To "convey" a work means any kind of propagation that enables other
88 | parties to make or receive copies. Mere interaction with a user through
89 | a computer network, with no transfer of a copy, is not conveying.
90 |
91 | An interactive user interface displays "Appropriate Legal Notices"
92 | to the extent that it includes a convenient and prominently visible
93 | feature that (1) displays an appropriate copyright notice, and (2)
94 | tells the user that there is no warranty for the work (except to the
95 | extent that warranties are provided), that licensees may convey the
96 | work under this License, and how to view a copy of this License. If
97 | the interface presents a list of user commands or options, such as a
98 | menu, a prominent item in the list meets this criterion.
99 |
100 | 1. Source Code.
101 |
102 | The "source code" for a work means the preferred form of the work
103 | for making modifications to it. "Object code" means any non-source
104 | form of a work.
105 |
106 | A "Standard Interface" means an interface that either is an official
107 | standard defined by a recognized standards body, or, in the case of
108 | interfaces specified for a particular programming language, one that
109 | is widely used among developers working in that language.
110 |
111 | The "System Libraries" of an executable work include anything, other
112 | than the work as a whole, that (a) is included in the normal form of
113 | packaging a Major Component, but which is not part of that Major
114 | Component, and (b) serves only to enable use of the work with that
115 | Major Component, or to implement a Standard Interface for which an
116 | implementation is available to the public in source code form. A
117 | "Major Component", in this context, means a major essential component
118 | (kernel, window system, and so on) of the specific operating system
119 | (if any) on which the executable work runs, or a compiler used to
120 | produce the work, or an object code interpreter used to run it.
121 |
122 | The "Corresponding Source" for a work in object code form means all
123 | the source code needed to generate, install, and (for an executable
124 | work) run the object code and to modify the work, including scripts to
125 | control those activities. However, it does not include the work's
126 | System Libraries, or general-purpose tools or generally available free
127 | programs which are used unmodified in performing those activities but
128 | which are not part of the work. For example, Corresponding Source
129 | includes interface definition files associated with source files for
130 | the work, and the source code for shared libraries and dynamically
131 | linked subprograms that the work is specifically designed to require,
132 | such as by intimate data communication or control flow between those
133 | subprograms and other parts of the work.
134 |
135 | The Corresponding Source need not include anything that users
136 | can regenerate automatically from other parts of the Corresponding
137 | Source.
138 |
139 | The Corresponding Source for a work in source code form is that
140 | same work.
141 |
142 | 2. Basic Permissions.
143 |
144 | All rights granted under this License are granted for the term of
145 | copyright on the Program, and are irrevocable provided the stated
146 | conditions are met. This License explicitly affirms your unlimited
147 | permission to run the unmodified Program. The output from running a
148 | covered work is covered by this License only if the output, given its
149 | content, constitutes a covered work. This License acknowledges your
150 | rights of fair use or other equivalent, as provided by copyright law.
151 |
152 | You may make, run and propagate covered works that you do not
153 | convey, without conditions so long as your license otherwise remains
154 | in force. You may convey covered works to others for the sole purpose
155 | of having them make modifications exclusively for you, or provide you
156 | with facilities for running those works, provided that you comply with
157 | the terms of this License in conveying all material for which you do
158 | not control copyright. Those thus making or running the covered works
159 | for you must do so exclusively on your behalf, under your direction
160 | and control, on terms that prohibit them from making any copies of
161 | your copyrighted material outside their relationship with you.
162 |
163 | Conveying under any other circumstances is permitted solely under
164 | the conditions stated below. Sublicensing is not allowed; section 10
165 | makes it unnecessary.
166 |
167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
168 |
169 | No covered work shall be deemed part of an effective technological
170 | measure under any applicable law fulfilling obligations under article
171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
172 | similar laws prohibiting or restricting circumvention of such
173 | measures.
174 |
175 | When you convey a covered work, you waive any legal power to forbid
176 | circumvention of technological measures to the extent such circumvention
177 | is effected by exercising rights under this License with respect to
178 | the covered work, and you disclaim any intention to limit operation or
179 | modification of the work as a means of enforcing, against the work's
180 | users, your or third parties' legal rights to forbid circumvention of
181 | technological measures.
182 |
183 | 4. Conveying Verbatim Copies.
184 |
185 | You may convey verbatim copies of the Program's source code as you
186 | receive it, in any medium, provided that you conspicuously and
187 | appropriately publish on each copy an appropriate copyright notice;
188 | keep intact all notices stating that this License and any
189 | non-permissive terms added in accord with section 7 apply to the code;
190 | keep intact all notices of the absence of any warranty; and give all
191 | recipients a copy of this License along with the Program.
192 |
193 | You may charge any price or no price for each copy that you convey,
194 | and you may offer support or warranty protection for a fee.
195 |
196 | 5. Conveying Modified Source Versions.
197 |
198 | You may convey a work based on the Program, or the modifications to
199 | produce it from the Program, in the form of source code under the
200 | terms of section 4, provided that you also meet all of these conditions:
201 |
202 | a) The work must carry prominent notices stating that you modified
203 | it, and giving a relevant date.
204 |
205 | b) The work must carry prominent notices stating that it is
206 | released under this License and any conditions added under section
207 | 7. This requirement modifies the requirement in section 4 to
208 | "keep intact all notices".
209 |
210 | c) You must license the entire work, as a whole, under this
211 | License to anyone who comes into possession of a copy. This
212 | License will therefore apply, along with any applicable section 7
213 | additional terms, to the whole of the work, and all its parts,
214 | regardless of how they are packaged. This License gives no
215 | permission to license the work in any other way, but it does not
216 | invalidate such permission if you have separately received it.
217 |
218 | d) If the work has interactive user interfaces, each must display
219 | Appropriate Legal Notices; however, if the Program has interactive
220 | interfaces that do not display Appropriate Legal Notices, your
221 | work need not make them do so.
222 |
223 | A compilation of a covered work with other separate and independent
224 | works, which are not by their nature extensions of the covered work,
225 | and which are not combined with it such as to form a larger program,
226 | in or on a volume of a storage or distribution medium, is called an
227 | "aggregate" if the compilation and its resulting copyright are not
228 | used to limit the access or legal rights of the compilation's users
229 | beyond what the individual works permit. Inclusion of a covered work
230 | in an aggregate does not cause this License to apply to the other
231 | parts of the aggregate.
232 |
233 | 6. Conveying Non-Source Forms.
234 |
235 | You may convey a covered work in object code form under the terms
236 | of sections 4 and 5, provided that you also convey the
237 | machine-readable Corresponding Source under the terms of this License,
238 | in one of these ways:
239 |
240 | a) Convey the object code in, or embodied in, a physical product
241 | (including a physical distribution medium), accompanied by the
242 | Corresponding Source fixed on a durable physical medium
243 | customarily used for software interchange.
244 |
245 | b) Convey the object code in, or embodied in, a physical product
246 | (including a physical distribution medium), accompanied by a
247 | written offer, valid for at least three years and valid for as
248 | long as you offer spare parts or customer support for that product
249 | model, to give anyone who possesses the object code either (1) a
250 | copy of the Corresponding Source for all the software in the
251 | product that is covered by this License, on a durable physical
252 | medium customarily used for software interchange, for a price no
253 | more than your reasonable cost of physically performing this
254 | conveying of source, or (2) access to copy the
255 | Corresponding Source from a network server at no charge.
256 |
257 | c) Convey individual copies of the object code with a copy of the
258 | written offer to provide the Corresponding Source. This
259 | alternative is allowed only occasionally and noncommercially, and
260 | only if you received the object code with such an offer, in accord
261 | with subsection 6b.
262 |
263 | d) Convey the object code by offering access from a designated
264 | place (gratis or for a charge), and offer equivalent access to the
265 | Corresponding Source in the same way through the same place at no
266 | further charge. You need not require recipients to copy the
267 | Corresponding Source along with the object code. If the place to
268 | copy the object code is a network server, the Corresponding Source
269 | may be on a different server (operated by you or a third party)
270 | that supports equivalent copying facilities, provided you maintain
271 | clear directions next to the object code saying where to find the
272 | Corresponding Source. Regardless of what server hosts the
273 | Corresponding Source, you remain obligated to ensure that it is
274 | available for as long as needed to satisfy these requirements.
275 |
276 | e) Convey the object code using peer-to-peer transmission, provided
277 | you inform other peers where the object code and Corresponding
278 | Source of the work are being offered to the general public at no
279 | charge under subsection 6d.
280 |
281 | A separable portion of the object code, whose source code is excluded
282 | from the Corresponding Source as a System Library, need not be
283 | included in conveying the object code work.
284 |
285 | A "User Product" is either (1) a "consumer product", which means any
286 | tangible personal property which is normally used for personal, family,
287 | or household purposes, or (2) anything designed or sold for incorporation
288 | into a dwelling. In determining whether a product is a consumer product,
289 | doubtful cases shall be resolved in favor of coverage. For a particular
290 | product received by a particular user, "normally used" refers to a
291 | typical or common use of that class of product, regardless of the status
292 | of the particular user or of the way in which the particular user
293 | actually uses, or expects or is expected to use, the product. A product
294 | is a consumer product regardless of whether the product has substantial
295 | commercial, industrial or non-consumer uses, unless such uses represent
296 | the only significant mode of use of the product.
297 |
298 | "Installation Information" for a User Product means any methods,
299 | procedures, authorization keys, or other information required to install
300 | and execute modified versions of a covered work in that User Product from
301 | a modified version of its Corresponding Source. The information must
302 | suffice to ensure that the continued functioning of the modified object
303 | code is in no case prevented or interfered with solely because
304 | modification has been made.
305 |
306 | If you convey an object code work under this section in, or with, or
307 | specifically for use in, a User Product, and the conveying occurs as
308 | part of a transaction in which the right of possession and use of the
309 | User Product is transferred to the recipient in perpetuity or for a
310 | fixed term (regardless of how the transaction is characterized), the
311 | Corresponding Source conveyed under this section must be accompanied
312 | by the Installation Information. But this requirement does not apply
313 | if neither you nor any third party retains the ability to install
314 | modified object code on the User Product (for example, the work has
315 | been installed in ROM).
316 |
317 | The requirement to provide Installation Information does not include a
318 | requirement to continue to provide support service, warranty, or updates
319 | for a work that has been modified or installed by the recipient, or for
320 | the User Product in which it has been modified or installed. Access to a
321 | network may be denied when the modification itself materially and
322 | adversely affects the operation of the network or violates the rules and
323 | protocols for communication across the network.
324 |
325 | Corresponding Source conveyed, and Installation Information provided,
326 | in accord with this section must be in a format that is publicly
327 | documented (and with an implementation available to the public in
328 | source code form), and must require no special password or key for
329 | unpacking, reading or copying.
330 |
331 | 7. Additional Terms.
332 |
333 | "Additional permissions" are terms that supplement the terms of this
334 | License by making exceptions from one or more of its conditions.
335 | Additional permissions that are applicable to the entire Program shall
336 | be treated as though they were included in this License, to the extent
337 | that they are valid under applicable law. If additional permissions
338 | apply only to part of the Program, that part may be used separately
339 | under those permissions, but the entire Program remains governed by
340 | this License without regard to the additional permissions.
341 |
342 | When you convey a copy of a covered work, you may at your option
343 | remove any additional permissions from that copy, or from any part of
344 | it. (Additional permissions may be written to require their own
345 | removal in certain cases when you modify the work.) You may place
346 | additional permissions on material, added by you to a covered work,
347 | for which you have or can give appropriate copyright permission.
348 |
349 | Notwithstanding any other provision of this License, for material you
350 | add to a covered work, you may (if authorized by the copyright holders of
351 | that material) supplement the terms of this License with terms:
352 |
353 | a) Disclaiming warranty or limiting liability differently from the
354 | terms of sections 15 and 16 of this License; or
355 |
356 | b) Requiring preservation of specified reasonable legal notices or
357 | author attributions in that material or in the Appropriate Legal
358 | Notices displayed by works containing it; or
359 |
360 | c) Prohibiting misrepresentation of the origin of that material, or
361 | requiring that modified versions of such material be marked in
362 | reasonable ways as different from the original version; or
363 |
364 | d) Limiting the use for publicity purposes of names of licensors or
365 | authors of the material; or
366 |
367 | e) Declining to grant rights under trademark law for use of some
368 | trade names, trademarks, or service marks; or
369 |
370 | f) Requiring indemnification of licensors and authors of that
371 | material by anyone who conveys the material (or modified versions of
372 | it) with contractual assumptions of liability to the recipient, for
373 | any liability that these contractual assumptions directly impose on
374 | those licensors and authors.
375 |
376 | All other non-permissive additional terms are considered "further
377 | restrictions" within the meaning of section 10. If the Program as you
378 | received it, or any part of it, contains a notice stating that it is
379 | governed by this License along with a term that is a further
380 | restriction, you may remove that term. If a license document contains
381 | a further restriction but permits relicensing or conveying under this
382 | License, you may add to a covered work material governed by the terms
383 | of that license document, provided that the further restriction does
384 | not survive such relicensing or conveying.
385 |
386 | If you add terms to a covered work in accord with this section, you
387 | must place, in the relevant source files, a statement of the
388 | additional terms that apply to those files, or a notice indicating
389 | where to find the applicable terms.
390 |
391 | Additional terms, permissive or non-permissive, may be stated in the
392 | form of a separately written license, or stated as exceptions;
393 | the above requirements apply either way.
394 |
395 | 8. Termination.
396 |
397 | You may not propagate or modify a covered work except as expressly
398 | provided under this License. Any attempt otherwise to propagate or
399 | modify it is void, and will automatically terminate your rights under
400 | this License (including any patent licenses granted under the third
401 | paragraph of section 11).
402 |
403 | However, if you cease all violation of this License, then your
404 | license from a particular copyright holder is reinstated (a)
405 | provisionally, unless and until the copyright holder explicitly and
406 | finally terminates your license, and (b) permanently, if the copyright
407 | holder fails to notify you of the violation by some reasonable means
408 | prior to 60 days after the cessation.
409 |
410 | Moreover, your license from a particular copyright holder is
411 | reinstated permanently if the copyright holder notifies you of the
412 | violation by some reasonable means, this is the first time you have
413 | received notice of violation of this License (for any work) from that
414 | copyright holder, and you cure the violation prior to 30 days after
415 | your receipt of the notice.
416 |
417 | Termination of your rights under this section does not terminate the
418 | licenses of parties who have received copies or rights from you under
419 | this License. If your rights have been terminated and not permanently
420 | reinstated, you do not qualify to receive new licenses for the same
421 | material under section 10.
422 |
423 | 9. Acceptance Not Required for Having Copies.
424 |
425 | You are not required to accept this License in order to receive or
426 | run a copy of the Program. Ancillary propagation of a covered work
427 | occurring solely as a consequence of using peer-to-peer transmission
428 | to receive a copy likewise does not require acceptance. However,
429 | nothing other than this License grants you permission to propagate or
430 | modify any covered work. These actions infringe copyright if you do
431 | not accept this License. Therefore, by modifying or propagating a
432 | covered work, you indicate your acceptance of this License to do so.
433 |
434 | 10. Automatic Licensing of Downstream Recipients.
435 |
436 | Each time you convey a covered work, the recipient automatically
437 | receives a license from the original licensors, to run, modify and
438 | propagate that work, subject to this License. You are not responsible
439 | for enforcing compliance by third parties with this License.
440 |
441 | An "entity transaction" is a transaction transferring control of an
442 | organization, or substantially all assets of one, or subdividing an
443 | organization, or merging organizations. If propagation of a covered
444 | work results from an entity transaction, each party to that
445 | transaction who receives a copy of the work also receives whatever
446 | licenses to the work the party's predecessor in interest had or could
447 | give under the previous paragraph, plus a right to possession of the
448 | Corresponding Source of the work from the predecessor in interest, if
449 | the predecessor has it or can get it with reasonable efforts.
450 |
451 | You may not impose any further restrictions on the exercise of the
452 | rights granted or affirmed under this License. For example, you may
453 | not impose a license fee, royalty, or other charge for exercise of
454 | rights granted under this License, and you may not initiate litigation
455 | (including a cross-claim or counterclaim in a lawsuit) alleging that
456 | any patent claim is infringed by making, using, selling, offering for
457 | sale, or importing the Program or any portion of it.
458 |
459 | 11. Patents.
460 |
461 | A "contributor" is a copyright holder who authorizes use under this
462 | License of the Program or a work on which the Program is based. The
463 | work thus licensed is called the contributor's "contributor version".
464 |
465 | A contributor's "essential patent claims" are all patent claims
466 | owned or controlled by the contributor, whether already acquired or
467 | hereafter acquired, that would be infringed by some manner, permitted
468 | by this License, of making, using, or selling its contributor version,
469 | but do not include claims that would be infringed only as a
470 | consequence of further modification of the contributor version. For
471 | purposes of this definition, "control" includes the right to grant
472 | patent sublicenses in a manner consistent with the requirements of
473 | this License.
474 |
475 | Each contributor grants you a non-exclusive, worldwide, royalty-free
476 | patent license under the contributor's essential patent claims, to
477 | make, use, sell, offer for sale, import and otherwise run, modify and
478 | propagate the contents of its contributor version.
479 |
480 | In the following three paragraphs, a "patent license" is any express
481 | agreement or commitment, however denominated, not to enforce a patent
482 | (such as an express permission to practice a patent or covenant not to
483 | sue for patent infringement). To "grant" such a patent license to a
484 | party means to make such an agreement or commitment not to enforce a
485 | patent against the party.
486 |
487 | If you convey a covered work, knowingly relying on a patent license,
488 | and the Corresponding Source of the work is not available for anyone
489 | to copy, free of charge and under the terms of this License, through a
490 | publicly available network server or other readily accessible means,
491 | then you must either (1) cause the Corresponding Source to be so
492 | available, or (2) arrange to deprive yourself of the benefit of the
493 | patent license for this particular work, or (3) arrange, in a manner
494 | consistent with the requirements of this License, to extend the patent
495 | license to downstream recipients. "Knowingly relying" means you have
496 | actual knowledge that, but for the patent license, your conveying the
497 | covered work in a country, or your recipient's use of the covered work
498 | in a country, would infringe one or more identifiable patents in that
499 | country that you have reason to believe are valid.
500 |
501 | If, pursuant to or in connection with a single transaction or
502 | arrangement, you convey, or propagate by procuring conveyance of, a
503 | covered work, and grant a patent license to some of the parties
504 | receiving the covered work authorizing them to use, propagate, modify
505 | or convey a specific copy of the covered work, then the patent license
506 | you grant is automatically extended to all recipients of the covered
507 | work and works based on it.
508 |
509 | A patent license is "discriminatory" if it does not include within
510 | the scope of its coverage, prohibits the exercise of, or is
511 | conditioned on the non-exercise of one or more of the rights that are
512 | specifically granted under this License. You may not convey a covered
513 | work if you are a party to an arrangement with a third party that is
514 | in the business of distributing software, under which you make payment
515 | to the third party based on the extent of your activity of conveying
516 | the work, and under which the third party grants, to any of the
517 | parties who would receive the covered work from you, a discriminatory
518 | patent license (a) in connection with copies of the covered work
519 | conveyed by you (or copies made from those copies), or (b) primarily
520 | for and in connection with specific products or compilations that
521 | contain the covered work, unless you entered into that arrangement,
522 | or that patent license was granted, prior to 28 March 2007.
523 |
524 | Nothing in this License shall be construed as excluding or limiting
525 | any implied license or other defenses to infringement that may
526 | otherwise be available to you under applicable patent law.
527 |
528 | 12. No Surrender of Others' Freedom.
529 |
530 | If conditions are imposed on you (whether by court order, agreement or
531 | otherwise) that contradict the conditions of this License, they do not
532 | excuse you from the conditions of this License. If you cannot convey a
533 | covered work so as to satisfy simultaneously your obligations under this
534 | License and any other pertinent obligations, then as a consequence you may
535 | not convey it at all. For example, if you agree to terms that obligate you
536 | to collect a royalty for further conveying from those to whom you convey
537 | the Program, the only way you could satisfy both those terms and this
538 | License would be to refrain entirely from conveying the Program.
539 |
540 | 13. Remote Network Interaction; Use with the GNU General Public License.
541 |
542 | Notwithstanding any other provision of this License, if you modify the
543 | Program, your modified version must prominently offer all users
544 | interacting with it remotely through a computer network (if your version
545 | supports such interaction) an opportunity to receive the Corresponding
546 | Source of your version by providing access to the Corresponding Source
547 | from a network server at no charge, through some standard or customary
548 | means of facilitating copying of software. This Corresponding Source
549 | shall include the Corresponding Source for any work covered by version 3
550 | of the GNU General Public License that is incorporated pursuant to the
551 | following paragraph.
552 |
553 | Notwithstanding any other provision of this License, you have
554 | permission to link or combine any covered work with a work licensed
555 | under version 3 of the GNU General Public License into a single
556 | combined work, and to convey the resulting work. The terms of this
557 | License will continue to apply to the part which is the covered work,
558 | but the work with which it is combined will remain governed by version
559 | 3 of the GNU General Public License.
560 |
561 | 14. Revised Versions of this License.
562 |
563 | The Free Software Foundation may publish revised and/or new versions of
564 | the GNU Affero General Public License from time to time. Such new versions
565 | will be similar in spirit to the present version, but may differ in detail to
566 | address new problems or concerns.
567 |
568 | Each version is given a distinguishing version number. If the
569 | Program specifies that a certain numbered version of the GNU Affero General
570 | Public License "or any later version" applies to it, you have the
571 | option of following the terms and conditions either of that numbered
572 | version or of any later version published by the Free Software
573 | Foundation. If the Program does not specify a version number of the
574 | GNU Affero General Public License, you may choose any version ever published
575 | by the Free Software Foundation.
576 |
577 | If the Program specifies that a proxy can decide which future
578 | versions of the GNU Affero General Public License can be used, that proxy's
579 | public statement of acceptance of a version permanently authorizes you
580 | to choose that version for the Program.
581 |
582 | Later license versions may give you additional or different
583 | permissions. However, no additional obligations are imposed on any
584 | author or copyright holder as a result of your choosing to follow a
585 | later version.
586 |
587 | 15. Disclaimer of Warranty.
588 |
589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
597 |
598 | 16. Limitation of Liability.
599 |
600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
608 | SUCH DAMAGES.
609 |
610 | 17. Interpretation of Sections 15 and 16.
611 |
612 | If the disclaimer of warranty and limitation of liability provided
613 | above cannot be given local legal effect according to their terms,
614 | reviewing courts shall apply local law that most closely approximates
615 | an absolute waiver of all civil liability in connection with the
616 | Program, unless a warranty or assumption of liability accompanies a
617 | copy of the Program in return for a fee.
618 |
619 | END OF TERMS AND CONDITIONS
620 |
621 | How to Apply These Terms to Your New Programs
622 |
623 | If you develop a new program, and you want it to be of the greatest
624 | possible use to the public, the best way to achieve this is to make it
625 | free software which everyone can redistribute and change under these terms.
626 |
627 | To do so, attach the following notices to the program. It is safest
628 | to attach them to the start of each source file to most effectively
629 | state the exclusion of warranty; and each file should have at least
630 | the "copyright" line and a pointer to where the full notice is found.
631 |
632 |
633 | Copyright (C)
634 |
635 | This program is free software: you can redistribute it and/or modify
636 | it under the terms of the GNU Affero General Public License as published
637 | by the Free Software Foundation, either version 3 of the License, or
638 | (at your option) any later version.
639 |
640 | This program is distributed in the hope that it will be useful,
641 | but WITHOUT ANY WARRANTY; without even the implied warranty of
642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
643 | GNU Affero General Public License for more details.
644 |
645 | You should have received a copy of the GNU Affero General Public License
646 | along with this program. If not, see .
647 |
648 | Also add information on how to contact you by electronic and paper mail.
649 |
650 | If your software can interact with users remotely through a computer
651 | network, you should also make sure that it provides a way for users to
652 | get its source. For example, if your program is a web application, its
653 | interface could display a "Source" link that leads users to an archive
654 | of the code. There are many ways you could offer source, and different
655 | solutions will be better for different programs; see section 13 for the
656 | specific requirements.
657 |
658 | You should also get your employer (if you work as a programmer) or school,
659 | if any, to sign a "copyright disclaimer" for the program, if necessary.
660 | For more information on this, and how to apply and follow the GNU AGPL, see
661 | .
662 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # [Weibo Helper](https://github.com/cssxsh/weibo-helper)
2 |
3 | > 基于 [Mirai Console](https://github.com/mamoe/mirai-console) 的 [微博](https://weibo.com/) 转发插件
4 |
5 | [](https://github.com/cssxsh/weibo-helper/releases)
6 | [](https://shields.io/category/downloads)
7 | [](https://mirai.mamoe.net/topic/212)
8 |
9 | **使用前应该查阅的相关文档或项目**
10 |
11 | * [User Manual](https://github.com/mamoe/mirai/blob/dev/docs/UserManual.md)
12 | * [Permission Command](https://github.com/mamoe/mirai/blob/dev/mirai-console/docs/BuiltInCommands.md#permissioncommand)
13 | * [Chat Command](https://github.com/project-mirai/chat-command)
14 |
15 | 插件基于PC网页版微博API,使用插件需要[登录](#登录指令)一个微博账号
16 | 插件初始化时,如果恢复登录状态失败,则会尝试模拟游客
17 |
18 | ## 指令
19 |
20 | 注意: 使用前请确保可以 [在聊天环境执行指令](https://github.com/project-mirai/chat-command)
21 | 带括号的 `/` 前缀是可选的
22 | `<...>` 中的是指令名,由空格隔开表示或,选择其中任一名称都可执行例如 `/微博用户 订阅`
23 | `[...]` 表示参数,当 `[...]` 后面带 `?` 时表示参数可选
24 | `{...}` 表示连续的多个参数
25 |
26 | 本插件指令权限ID 格式为 `xyz.cssxsh.mirai.plugin.weibo-helper:command.*`, `*` 是指令的第一指令名
27 | 例如 `/微博用户 订阅` 的权限ID为 `xyz.cssxsh.mirai.plugin.weibo-helper:command.wuser`
28 |
29 | `[subject]?`订阅的接收对象,可选参数, 默认填充当前聊天环境
30 |
31 | ### 登录指令
32 |
33 | | 指令 | 描述 |
34 | |:-----------------|:---------|
35 | | `/` | 登录一个微博账号 |
36 |
37 | 使用指令后,机器人会发送网页登录的二维码
38 | 使用手机微博APP扫描确认登录后
39 | 如果成功登录,则会回复 `@用户名#ID 登陆成功` 的消息
40 |
41 | ### 用户订阅指令
42 |
43 | | 指令 | 描述 |
44 | |:------------------------------------------------|:-----------|
45 | | `/ [uid] [subject]?` | 订阅一个微博账号 |
46 | | `/ [uid] [subject]?` | 取消订阅一个微博账号 |
47 | | `/ [subject]?` | 查看订阅详情 |
48 |
49 | `uid` 是用户的ID,可以在用户的主页获得,
50 | 例如 的 `1111681197`
51 | 使用订阅指令后,如果成功找到指定用户,则会回复
52 | `对@用户名#ID 的监听任务, 添加完成`
53 |
54 | ### 分组订阅指令
55 |
56 | | 指令 | 描述 |
57 | |:-----------------------------------------------|:------------|
58 | | `/ ` | 列出当前账号的微博分组 |
59 | | `/ [id] [subject]?` | 订阅一个微博分组 |
60 | | `/ [id] [subject]?` | 取消订阅一个微博分组 |
61 | | `/ [subject]?` | 查看订阅详情 |
62 |
63 | `id` 是分组的GID或者TITLE,GID可以在分组的页面获得,
64 | 例如 的 `3893924734832698`
65 | 也可以通过列表指令获得,使用列表指令之后会按行回复 `title -> gid`
66 | 使用订阅指令后,如果成功找到指定分组,则会回复
67 | `对分组标题#ID的监听任务, 添加完成`
68 |
69 | ### 热搜订阅指令
70 |
71 | | 指令 | 描述 |
72 | |:-----------------------------------------------|:-----------|
73 | | `/ [word] [subject]?` | 订阅一个微博热搜 |
74 | | `/ [word] [subject]?` | 取消订阅一个微博热搜 |
75 | | `/ [subject]?` | 查看订阅详情 |
76 |
77 | ### 博文查看指令
78 |
79 | | 指令 | 描述 |
80 | |:-----------------------------|:---------|
81 | | `/ [mid]` | 查看指定微博内容 |
82 |
83 | ### 关注指令
84 |
85 | | 指令 | 描述 |
86 | |:------------------------|:-------|
87 | | `/ [uid]` | 关注指定用户 |
88 |
89 | ## 解析微博链接
90 |
91 | 机器人会将群里中的微博链接捕获,并将微博内容回复给发送微博链接的人
92 | 这个功能默认开启,通过权限 `xyz.cssxsh.mirai.plugin.weibo-helper:quiet.group` 设置不开启的群聊
93 |
94 | ## 配置
95 |
96 | 位于 `Mirai-Console` 运行目录下的 `config/xyz.cssxsh.mirai.plugin.weibo-helper` 文件夹下的 `WeiboHelperSettings` 文件
97 |
98 | * `cache` 图片缓存位置
99 | * `expire` 图片缓存过期时间,单位小时,默认3天,为0时不会过期
100 | * `following` 是否清理收藏的用户的缓存,默认 true
101 | * `fast` 快速轮询间隔,单位分钟
102 | * `slow` 慢速轮询间隔,单位分钟
103 | * `contact` 登录状态失效联系人,当微博的登录状态失效时会向这个QQ号发送消息
104 | * `repost` 微博订阅器,最少转发数过滤器,只对列表订阅生效,默认16
105 | * `users` 微博订阅器,屏蔽用户
106 | * `regexes` 微博订阅器,屏蔽的关键词正则表达式
107 | * `urls` 微博订阅器,屏蔽的URL类型, 屏蔽视频可以尝试填入`39`
108 | * `original` 只接受原创内容,屏蔽转发
109 | * `video` 发送微博视频文件
110 | * `emoticon` 处理微博表情
111 | * `picture` 图片设置
112 | * `cover` 封面设置
113 | * `history` 历史记录保留时间,单位天,默认 7d
114 | * `timeout` Http 超时时间,单位毫秒,默认 60_000 ms
115 | * `forward` 以转发消息的方式发送订阅微博
116 | * `show_url` 是否显示url
117 | * `interval` 自动解析同样内容的间隔,单位毫秒,默认 600_000 ms
118 |
119 | ## 图片设置
120 |
121 | 有四种设置方案
122 |
123 | * 一张也不显示
124 | ```yaml
125 | picture:
126 | type: none
127 | value: {}
128 | ```
129 |
130 | * 全部显示
131 | ```yaml
132 | picture:
133 | type: all
134 | value: {}
135 | ```
136 |
137 | * 最多显示total张
138 | ```yaml
139 | picture:
140 | type: limit
141 | value:
142 | total: 3
143 | ```
144 |
145 | * 超过total张一张也不显示
146 | ```yaml
147 | picture:
148 | type: top
149 | value:
150 | total: 3
151 | ```
152 |
153 | ### 自动解析
154 |
155 | ~~安静群聊, 不解析URL链接, 通过权限系统配置~~
156 | `/perm add g12345 xyz.cssxsh.mirai.plugin.weibo-helper:quiet.group`
157 | 改为 `xyz.cssxsh.mirai.plugin.weibo-helper:auto.parser`
158 | 持有权限的用户(群员)才会触发自动解析
159 |
160 | ## 安装
161 |
162 | ### MCL 指令安装
163 |
164 | **请确认 mcl.jar 的版本是 2.1.0+**
165 | `./mcl --update-package xyz.cssxsh:weibo-helper --channel maven-stable --type plugins`
166 |
167 | ### 手动安装
168 |
169 | 1. 从 [Releases](https://github.com/cssxsh/weibo-helper/releases) 或者 [Maven](https://repo1.maven.org/maven2/xyz/cssxsh/weibo-helper/) 下载 `mirai2.jar`
170 | 2. 将其放入 `plugins` 文件夹中
171 |
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | kotlin("jvm") version "1.8.22"
3 | kotlin("plugin.serialization") version "1.8.22"
4 |
5 | id("net.mamoe.mirai-console") version "2.16.0-RC"
6 | id("me.him188.maven-central-publish") version "1.0.0-dev-3"
7 | }
8 |
9 | group = "xyz.cssxsh"
10 | version = "1.6.2"
11 |
12 | mavenCentralPublish {
13 | useCentralS01()
14 | singleDevGithubProject("cssxsh", "weibo-helper")
15 | licenseFromGitHubProject("AGPL-3.0")
16 | workingDir = System.getenv("PUBLICATION_TEMP")?.let { file(it).resolve(projectName) }
17 | ?: buildDir.resolve("publishing-tmp")
18 | publication {
19 | artifact(tasks["buildPlugin"])
20 | }
21 | }
22 |
23 | repositories {
24 | mavenLocal()
25 | mavenCentral()
26 | }
27 |
28 | dependencies {
29 | implementation("org.jclarion:image4j:0.7")
30 | implementation("org.apache.commons:commons-text:1.10.0")
31 | implementation("org.jsoup:jsoup:1.16.1")
32 | testImplementation(kotlin("test"))
33 | testImplementation("net.mamoe.yamlkt:yamlkt-jvm:0.10.2")
34 | //
35 | implementation(platform("net.mamoe:mirai-bom:2.16.0-RC"))
36 | compileOnly("net.mamoe:mirai-console-compiler-common")
37 | testImplementation("net.mamoe:mirai-logging-slf4j")
38 | //
39 | implementation(platform("io.ktor:ktor-bom:2.3.4"))
40 | implementation("io.ktor:ktor-client-okhttp")
41 | implementation("io.ktor:ktor-client-encoding")
42 | //
43 | implementation(platform("org.slf4j:slf4j-parent:2.0.7"))
44 | testImplementation("org.slf4j:slf4j-simple")
45 | }
46 |
47 | kotlin {
48 | explicitApi()
49 | }
50 |
51 | tasks {
52 | test {
53 | useJUnitPlatform()
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | kotlin.code.style=official
2 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cssxsh/weibo-helper/27c9066b67facf42f4b3cd79624fcd73cb78b941/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip
4 | zipStoreBase=GRADLE_USER_HOME
5 | zipStorePath=wrapper/dists
6 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/local.properties:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cssxsh/weibo-helper/27c9066b67facf42f4b3cd79624fcd73cb78b941/local.properties
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | mavenLocal()
4 | mavenCentral()
5 | gradlePluginPortal()
6 | }
7 | }
8 | rootProject.name = "weibo-helper"
9 |
10 |
--------------------------------------------------------------------------------
/src/main/kotlin/xyz/cssxsh/mirai/weibo/WeiboFilter.kt:
--------------------------------------------------------------------------------
1 | package xyz.cssxsh.mirai.weibo
2 |
3 | public interface WeiboFilter {
4 | public val repost: Long
5 | public val users: Set
6 | public val regexes: List
7 | public val urls: Set
8 | public val original: Boolean
9 | public val likes: Boolean
10 | }
--------------------------------------------------------------------------------
/src/main/kotlin/xyz/cssxsh/mirai/weibo/WeiboHelperPlugin.kt:
--------------------------------------------------------------------------------
1 | package xyz.cssxsh.mirai.weibo
2 |
3 | import kotlinx.coroutines.*
4 | import net.mamoe.mirai.console.*
5 | import net.mamoe.mirai.console.command.*
6 | import net.mamoe.mirai.console.command.CommandManager.INSTANCE.register
7 | import net.mamoe.mirai.console.command.CommandManager.INSTANCE.unregister
8 | import net.mamoe.mirai.console.data.*
9 | import net.mamoe.mirai.console.extension.*
10 | import net.mamoe.mirai.console.plugin.jvm.*
11 | import net.mamoe.mirai.console.plugin.*
12 | import net.mamoe.mirai.console.util.*
13 | import net.mamoe.mirai.utils.*
14 | import xyz.cssxsh.mirai.weibo.data.*
15 | import xyz.cssxsh.weibo.*
16 |
17 | @PublishedApi
18 | internal object WeiboHelperPlugin : KotlinPlugin(
19 | JvmPluginDescription("xyz.cssxsh.mirai.plugin.weibo-helper", "1.6.2") {
20 | name("weibo-helper")
21 | author("cssxsh")
22 | }
23 | ) {
24 | private var clear: Job? = null
25 |
26 | private var restore: Job? = null
27 |
28 | override fun PluginComponentStorage.onLoad() {
29 | System.setProperty(SERIALIZATION_EXCEPTION_SAVE, dataFolderPath.toString())
30 | runAfterStartup {
31 | launch {
32 | client.init()
33 |
34 | clear = this@WeiboHelperPlugin.launch(Dispatchers.IO) {
35 | clear()
36 | }
37 | restore = this@WeiboHelperPlugin.launch(Dispatchers.IO) {
38 | restore()
39 | }
40 |
41 | WeiboListener.start()
42 | WeiboSubscriber.start()
43 | }
44 | }
45 | }
46 |
47 | @Suppress("INVISIBLE_MEMBER")
48 | private inline fun spi(): Lazy> = lazy {
49 | with(net.mamoe.mirai.console.internal.util.PluginServiceHelper) {
50 | jvmPluginClasspath.pluginClassLoader
51 | .findServices()
52 | .loadAllServices()
53 | }
54 | }
55 |
56 | private val commands: List by spi()
57 | private val data: List by spi()
58 | private val config: List by spi()
59 |
60 | override fun onEnable() {
61 | // XXX: mirai console version check
62 | check(SemVersion.parseRangeRequirement(">= 2.12.0-RC").test(MiraiConsole.version)) {
63 | "$name $version 需要 Mirai-Console 版本 >= 2.12.0,目前版本是 ${MiraiConsole.version}"
64 | }
65 |
66 | for (command in commands) command.register()
67 | for (data in data) data.reload()
68 | for (config in config) config.reload()
69 |
70 | logger.info { "图片缓存位置 ${ImageCache.absolutePath}" }
71 | }
72 |
73 | override fun onDisable() {
74 | for (command in commands) command.unregister()
75 |
76 | WeiboSubscriber.stop()
77 |
78 | WeiboListener.stop()
79 |
80 | clear?.cancel()
81 |
82 | restore?.cancel()
83 | }
84 | }
--------------------------------------------------------------------------------
/src/main/kotlin/xyz/cssxsh/mirai/weibo/WeiboHistoryDelegate.kt:
--------------------------------------------------------------------------------
1 | package xyz.cssxsh.mirai.weibo
2 |
3 | import kotlinx.coroutines.*
4 | import kotlinx.serialization.*
5 | import net.mamoe.mirai.utils.*
6 | import xyz.cssxsh.weibo.*
7 | import xyz.cssxsh.weibo.data.*
8 | import java.time.*
9 | import kotlin.properties.*
10 | import kotlin.reflect.*
11 |
12 | public class WeiboHistoryDelegate>(id: K, subscriber: WeiboSubscriber) :
13 | ReadOnlyProperty> {
14 | private val file = DataFolder.resolve(subscriber.type).resolve("$id.json")
15 |
16 | private val cache: MutableMap = HashMap()
17 |
18 | init {
19 | try {
20 | if (file.exists()) {
21 | cache.putAll(WeiboClient.Json.decodeFromString(file.readText().ifBlank { """{}""" }))
22 | } else {
23 | file.parentFile.mkdirs()
24 | file.writeText("{}")
25 | }
26 | } catch (cause: Exception) {
27 | logger.warning({ "${file.absolutePath} 读取失败" }, cause)
28 | }
29 | subscriber.launch(SupervisorJob()) {
30 | while (isActive) {
31 | delay(IntervalSlow.toMillis())
32 | save()
33 | }
34 | }.invokeOnCompletion {
35 | logger.info { "WeiboHistory ${file.absolutePath} 已保存 " }
36 | save()
37 | }
38 | }
39 |
40 | private fun save() {
41 | try {
42 | val write = if (cache.size > 8196) {
43 | val expire = OffsetDateTime.now().minusDays(HistoryExpire)
44 | cache.filterValues { blog -> blog.created > expire }
45 | } else {
46 | cache
47 | }
48 | file.writeText(WeiboClient.Json.encodeToString(write))
49 | } catch (cause: Exception) {
50 | logger.warning({ "WeiboHistory ${file.absolutePath} 保存失败" }, cause)
51 | }
52 | }
53 |
54 | override fun getValue(thisRef: Any?, property: KProperty<*>): MutableMap = cache
55 | }
--------------------------------------------------------------------------------
/src/main/kotlin/xyz/cssxsh/mirai/weibo/WeiboListener.kt:
--------------------------------------------------------------------------------
1 | package xyz.cssxsh.mirai.weibo
2 |
3 | import kotlinx.coroutines.*
4 | import kotlinx.serialization.*
5 | import net.mamoe.mirai.console.permission.PermissionService.Companion.testPermission
6 | import net.mamoe.mirai.console.permission.PermitteeId.Companion.permitteeId
7 | import net.mamoe.mirai.console.util.ContactUtils.render
8 | import net.mamoe.mirai.contact.*
9 | import net.mamoe.mirai.event.*
10 | import net.mamoe.mirai.message.data.MessageSource.Key.quote
11 | import net.mamoe.mirai.utils.*
12 | import xyz.cssxsh.mirai.weibo.data.*
13 | import xyz.cssxsh.weibo.api.*
14 | import kotlin.coroutines.*
15 |
16 | internal object WeiboListener : CoroutineScope {
17 |
18 | override val coroutineContext: CoroutineContext =
19 | CoroutineName(name = "WeiboSubscriber") + SupervisorJob() + CoroutineExceptionHandler { context, throwable ->
20 | logger.warning({ "$throwable in $context" }, throwable)
21 | }
22 |
23 | /**
24 | * * [https://m.weibo.cn/status/JFzsgd0CX]
25 | * * [https://m.weibo.cn/status/4585001998353993]
26 | * * [https://weibo.com/5594511989/JzFhZz3fP]
27 | * * [https://weibo.com/detail/JzFhZz3fP]
28 | * * [https://weibo.com/detail/4585001998353993]
29 | * * [https://m.weibo.cn/detail/4585001998353993]
30 | */
31 | private val WEIBO_REGEX = """(?<=(weibo\.(cn|com)/(\d{1,32}|detail|status)/))[0-9A-z]+""".toRegex()
32 |
33 | private val Parser = WeiboHelperPlugin.registerPermission("auto.parser", "自动解析微博链接")
34 |
35 | private val interval get() = WeiboHelperSettings.interval
36 |
37 | private val cache: MutableMap> = HashMap()
38 |
39 | private fun cache(subject: Contact, match: MatchResult): Boolean {
40 | val history = cache.getOrPut(subject.id) { HashMap() }
41 | val current = System.currentTimeMillis()
42 | return current != history.merge(match.value, current) { old, new -> if (new - old > interval) new else old }
43 | }
44 |
45 | fun start() {
46 | globalEventChannel().subscribeMessages {
47 | WEIBO_REGEX findingReply replier@{ result ->
48 | if (subject is Group) {
49 | val permitteeId = (sender as? NormalMember ?: return@replier null).permitteeId
50 | if (Parser.testPermission(permitteeId).not()) return@replier null
51 | }
52 |
53 | if (cache(subject, result)) return@replier null
54 |
55 | logger.info { "${sender.render()} 匹配WEIBO(${result.value})" }
56 | try {
57 | message.quote() + client.getMicroBlog(mid = result.value).toMessage(contact = subject)
58 | } catch (exception: SerializationException) {
59 | logger.warning({ "构建WEIBO(${result.value})序列化时失败" }, exception)
60 | sendLoginMessage("构建WEIBO(${result.value})任务序列化时失败")
61 | exception.message
62 | } catch (cause: Exception) {
63 | logger.warning({ "构建WEIBO(${result.value})信息失败,尝试重新刷新" }, cause)
64 | try {
65 | client.restore()
66 | null
67 | } catch (cause: Exception) {
68 | logger.warning({ "WEIBO登陆状态失效,需要重新登陆" }, cause)
69 | sendLoginMessage("WEIBO登陆状态失效,需要重新登陆 /wlogin")
70 | cause.message
71 | } ?: cause.message
72 | }
73 | }
74 | }
75 | }
76 |
77 | fun stop() {
78 | coroutineContext.cancelChildren()
79 | }
80 | }
--------------------------------------------------------------------------------
/src/main/kotlin/xyz/cssxsh/mirai/weibo/WeiboPicture.kt:
--------------------------------------------------------------------------------
1 | package xyz.cssxsh.mirai.weibo
2 |
3 | import kotlinx.serialization.*
4 |
5 | @Serializable
6 | public sealed class WeiboPicture {
7 |
8 | @SerialName("none")
9 | @Serializable
10 | public class None : WeiboPicture()
11 |
12 |
13 | @SerialName("all")
14 | @Serializable
15 | public class All : WeiboPicture()
16 |
17 | /**
18 | * 超过limit图片,其余已省略
19 | */
20 | @SerialName("limit")
21 | @Serializable
22 | public class Limit(public val total: Int = 3) : WeiboPicture()
23 |
24 | /**
25 | * "超过三张图片,全部省略"
26 | */
27 | @SerialName("top")
28 | @Serializable
29 | public class Top(public val total: Int = 3) : WeiboPicture()
30 | }
31 |
--------------------------------------------------------------------------------
/src/main/kotlin/xyz/cssxsh/mirai/weibo/WeiboSubscriber.kt:
--------------------------------------------------------------------------------
1 | package xyz.cssxsh.mirai.weibo
2 |
3 | import kotlinx.coroutines.*
4 | import kotlinx.serialization.*
5 | import net.mamoe.mirai.contact.*
6 | import net.mamoe.mirai.message.data.*
7 | import net.mamoe.mirai.utils.*
8 | import xyz.cssxsh.mirai.weibo.data.*
9 | import xyz.cssxsh.weibo.*
10 | import xyz.cssxsh.weibo.data.*
11 | import xyz.cssxsh.weibo.api.*
12 | import java.net.*
13 | import java.time.*
14 | import kotlin.coroutines.*
15 |
16 | public abstract class WeiboSubscriber>(public val type: String) : CoroutineScope {
17 |
18 | override val coroutineContext: CoroutineContext =
19 | CoroutineName(name = "WeiboListener-$type") + SupervisorJob() + CoroutineExceptionHandler { context, throwable ->
20 | logger.warning({ "$throwable in $context" }, throwable)
21 | }
22 |
23 | public companion object {
24 | private val all = mutableListOf>()
25 |
26 | public fun start() {
27 | for (subscriber in all) {
28 | subscriber.start()
29 | }
30 | }
31 |
32 | public fun stop() {
33 | for (subscriber in all) {
34 | subscriber.stop()
35 | }
36 | }
37 | }
38 |
39 | init {
40 | let(all::add)
41 | }
42 |
43 | public abstract val load: suspend (id: K) -> List
44 |
45 | protected open val filter: WeiboFilter get() = WeiboHelperSettings
46 |
47 | private val forward get() = UseForwardMessage
48 |
49 | protected abstract val tasks: MutableMap
50 |
51 | private val taskJobs: MutableMap = HashMap()
52 |
53 | private fun infos(id: K) = tasks[id]?.contacts.orEmpty()
54 |
55 | public fun start(): Unit = synchronized(taskJobs) {
56 | for ((id, _) in tasks) {
57 | taskJobs[id] = listen(id)
58 | }
59 | }
60 |
61 | public fun stop(): Unit = synchronized(taskJobs) {
62 | coroutineContext.cancelChildren()
63 | taskJobs.clear()
64 | }
65 |
66 | private suspend fun sendMessageToTaskContacts(id: K, build: suspend (contact: Contact) -> Message) {
67 | for (delegate in infos(id)) {
68 | try {
69 | requireNotNull(findContact(delegate)) { "找不到用户" }.let { contact ->
70 | contact.sendMessage(build(contact))
71 | }
72 | } catch (cause: Exception) {
73 | logger.warning({ "对[${delegate}]构建消息失败" }, cause)
74 | }
75 | }
76 | }
77 |
78 | private operator fun LocalTime.minus(other: LocalTime): Duration =
79 | Duration.ofSeconds((toSecondOfDay() - other.toSecondOfDay()).toLong())
80 |
81 | private fun Map.near(time: LocalTime = LocalTime.now()): Boolean {
82 | return values.map { it.created.toLocalTime() - time }.any { it.abs() < IntervalSlow }
83 | }
84 |
85 | protected open val reposts: Boolean = true
86 |
87 | protected open val predicate: (MicroBlog, K) -> Boolean = filter@{ blog, id ->
88 | if (filter.original && blog.retweeted != null) {
89 | logger.debug { "${type}(${id}) 转发屏蔽" }
90 | return@filter false
91 | }
92 | val source = blog.retweeted ?: blog
93 | if (reposts && source.reposts < filter.repost) {
94 | logger.debug { "${type}(${id}) 转发数屏蔽,跳过 ${source.id} ${source.reposts}" }
95 | return@filter false
96 | }
97 | if (source.uid in filter.users) {
98 | logger.info { "${type}(${id}) 用户屏蔽,跳过 ${source.id} ${source.username}" }
99 | return@filter false
100 | }
101 | for (regex in filter.regexes) {
102 | if (regex in source.raw.orEmpty()) {
103 | logger.info { "${type}(${id}) 正则屏蔽,跳过 ${source.id} $regex" }
104 | return@filter false
105 | }
106 | }
107 | if (blog.urls.any { it.type.toIntOrNull() in filter.urls }) {
108 | logger.debug { "${type}(${id}) Url屏蔽,跳过 ${source.id} ${blog.urls}" }
109 | return@filter false
110 | }
111 | if (blog.title != null && "赞过的微博" in blog.title.text && filter.likes.not()) {
112 | logger.info { "${type}(${id}) 赞过的微博屏蔽,跳过 ${source.id} ${source.created}" }
113 | return@filter false
114 | }
115 | true
116 | }
117 |
118 | private fun listen(id: K): Job = launch {
119 | logger.info { "添加对$type(${tasks.getValue(id).name}#${id})的监听任务" }
120 | val history by WeiboHistoryDelegate(id, this@WeiboSubscriber)
121 | val cache: MutableSet = HashSet(history.keys)
122 | for ((_, blog) in history) cache.add(blog.retweeted?.id ?: continue)
123 | logger.debug { "$type(${tasks.getValue(id).name}#${id})的 cache: $cache" }
124 | var init = true
125 | logger.debug { "$type(${tasks.getValue(id).name}#${id})的 target: ${infos(id)}" }
126 | while (isActive && infos(id).isNotEmpty()) {
127 | delay((if (history.near() || init) IntervalFast else IntervalSlow).toMillis())
128 | try {
129 | if (init) {
130 | // XXX: 加载一次
131 | val list = load(id)
132 | for (blog in list) {
133 | history[blog.id] = blog
134 | cache.add(blog.id)
135 | cache.add(blog.retweeted?.id ?: continue)
136 | }
137 | init = false
138 | logger.debug { "$type(${tasks.getValue(id).name}#${id})的 init: $cache" }
139 | continue
140 | }
141 | val task = tasks.getValue(id)
142 | val list = load(id).asSequence()
143 | .filter { predicate(it, id) }
144 | .filterNot { (it.retweeted?.id ?: it.id) in cache }
145 | .toList()
146 | if (list.isEmpty()) continue
147 |
148 | if (forward) {
149 | val strategy = object : ForwardMessage.DisplayStrategy {
150 | override fun generateTitle(forward: RawForwardMessage): String = "${task.name} 有新微博"
151 | override fun generateSummary(forward: RawForwardMessage): String = "查看${list.size}条微博转发"
152 | }
153 | sendMessageToTaskContacts(id) { contact ->
154 | buildForwardMessage(contact) {
155 | displayStrategy = strategy
156 | for (blog in list) {
157 | contact.bot at blog.created.toEpochSecond().toInt() says blog.toMessage(contact)
158 | }
159 | }
160 | }
161 | } else {
162 | for (blog in list) {
163 | sendMessageToTaskContacts(id) { contact ->
164 | blog.toMessage(contact)
165 | }
166 | }
167 | }
168 |
169 | var last = task.last
170 | for (blog in list) {
171 | history[blog.id] = blog
172 | last = maxOf(blog.created, last)
173 | cache.add(blog.id)
174 | cache.add(blog.retweeted?.id ?: continue)
175 | }
176 |
177 | tasks[id] = task.copy(last = last)
178 | } catch (exception: SerializationException) {
179 | logger.warning({ "$type(${id})监听任务序列化时失败" }, exception)
180 | sendLoginMessage("$type(${id})监听任务序列化时失败")
181 | } catch (exception: UnknownHostException) {
182 | logger.warning({ "$type(${id})监听任务, 网络异常" }, exception)
183 | } catch (exception: SocketException) {
184 | logger.warning({ "$type(${id})监听任务, 网络异常" }, exception)
185 | } catch (exception: Exception) {
186 | logger.warning({ "WEIBO登陆状态将刷新" }, exception)
187 | try {
188 | client.restore()
189 | } catch (_: Exception) {
190 | //
191 | }
192 | } finally {
193 | logger.debug { "$type(${id}): ${tasks[id]}监听任务完成一次, 即将进入延时" }
194 | }
195 | }
196 | }
197 |
198 | public fun add(id: K, name: String, subject: Contact): Unit = synchronized(tasks) {
199 | tasks.compute(id) { _, info ->
200 | with(info ?: WeiboTaskInfo()) {
201 | copy(contacts = contacts + subject.delegate, name = name)
202 | }
203 | }
204 | taskJobs.compute(id) { _, job ->
205 | job?.takeIf { it.isActive } ?: listen(id)
206 | }
207 | }
208 |
209 | public fun remove(id: K, subject: Contact): Unit = synchronized(tasks) {
210 | tasks.compute(id) { _, info ->
211 | info?.run {
212 | copy(contacts = contacts - subject.delegate)
213 | }
214 | }
215 | if (infos(id).isEmpty()) {
216 | tasks.remove(id)
217 | taskJobs.remove(id)?.cancel()
218 | }
219 | }
220 |
221 | public fun detail(subject: Contact): String = buildString {
222 | appendLine("# 订阅列表")
223 | appendLine("| NAME | ID | LAST | ACTIVE |")
224 | appendLine("|------|----|------|--------|")
225 | for ((id, info) in tasks) {
226 | if (subject.delegate !in info.contacts) continue
227 | appendLine("| ${info.name} | $id | ${info.last} | ${taskJobs[id]?.isActive} |")
228 | }
229 | }
230 | }
--------------------------------------------------------------------------------
/src/main/kotlin/xyz/cssxsh/mirai/weibo/WeiboUtils.kt:
--------------------------------------------------------------------------------
1 | package xyz.cssxsh.mirai.weibo
2 |
3 | import io.ktor.client.*
4 | import io.ktor.client.plugins.cookies.*
5 | import io.ktor.http.*
6 | import kotlinx.coroutines.*
7 | import kotlinx.serialization.*
8 | import net.mamoe.mirai.*
9 | import net.mamoe.mirai.console.command.*
10 | import net.mamoe.mirai.console.permission.*
11 | import net.mamoe.mirai.console.plugin.jvm.*
12 | import net.mamoe.mirai.console.util.*
13 | import net.mamoe.mirai.console.util.ContactUtils.getContactOrNull
14 | import net.mamoe.mirai.console.util.ContactUtils.render
15 | import net.mamoe.mirai.contact.*
16 | import net.mamoe.mirai.message.*
17 | import net.mamoe.mirai.message.data.*
18 | import net.mamoe.mirai.message.data.MessageSource.Key.quote
19 | import net.mamoe.mirai.utils.*
20 | import net.mamoe.mirai.utils.ExternalResource.Companion.toExternalResource
21 | import net.mamoe.mirai.utils.ExternalResource.Companion.uploadAsImage
22 | import net.sf.image4j.codec.ico.*
23 | import org.apache.commons.text.*
24 | import xyz.cssxsh.mirai.weibo.data.*
25 | import xyz.cssxsh.weibo.*
26 | import xyz.cssxsh.weibo.api.*
27 | import xyz.cssxsh.weibo.data.*
28 | import java.io.*
29 | import java.net.*
30 | import java.time.*
31 | import javax.imageio.*
32 |
33 | internal const val WEIBO_CACHE_PROPERTY = "xyz.cssxsh.mirai.plugin.weibo.cache"
34 |
35 | internal const val WEIBO_EXPIRE_IMAGE_PROPERTY = "xyz.cssxsh.mirai.plugin.weibo.expire.image"
36 |
37 | internal const val WEIBO_EXPIRE_HISTORY_PROPERTY = "xyz.cssxsh.mirai.plugin.weibo.expire.history"
38 |
39 | internal const val WEIBO_CLEAN_FOLLOWING_PROPERTY = "xyz.cssxsh.mirai.plugin.clean.following"
40 |
41 | internal const val WEIBO_INTERVAL_FAST_PROPERTY = "xyz.cssxsh.mirai.plugin.weibo.interval.fast"
42 |
43 | internal const val WEIBO_INTERVAL_SLOW_PROPERTY = "xyz.cssxsh.mirai.plugin.weibo.interval.slow"
44 |
45 | internal const val WEIBO_CONTACT_PROPERTY = "xyz.cssxsh.mirai.plugin.weibo.contact"
46 |
47 | internal const val WEIBO_FORWARD_PROPERTY = "xyz.cssxsh.mirai.plugin.weibo.forward"
48 |
49 | internal const val WEIBO_URL_PROPERTY = "xyz.cssxsh.mirai.plugin.weibo.url"
50 |
51 | /**
52 | * @see [WeiboHelperPlugin.logger]
53 | */
54 | internal val logger by lazy {
55 | try {
56 | WeiboHelperPlugin.logger
57 | } catch (_: ExceptionInInitializerError) {
58 | MiraiLogger.Factory.create(WeiboClient::class)
59 | }
60 | }
61 |
62 | internal val client: WeiboClient by lazy {
63 | object : WeiboClient(ignore = ClientIgnore) {
64 | override var info: LoginUserInfo
65 | get() = super.info
66 | set(value) {
67 | WeiboStatusData.status = LoginStatus(value, cookies)
68 | super.info = value
69 | }
70 |
71 | override val timeout: Long get() = WeiboHelperSettings.timeout
72 |
73 | override val client: HttpClient = super.client.config {
74 | install(HttpCookies) {
75 | val delegate = super.storage
76 | storage = object : CookiesStorage by delegate {
77 | override suspend fun addCookie(requestUrl: Url, cookie: Cookie) {
78 | delegate.addCookie(requestUrl, cookie)
79 | WeiboStatusData.status = status()
80 | }
81 | }
82 | }
83 | }
84 |
85 | init {
86 | load(WeiboStatusData.status)
87 | }
88 | }
89 | }
90 |
91 | internal fun AbstractJvmPlugin.registerPermission(name: String, description: String): Permission {
92 | return PermissionService.INSTANCE.register(permissionId(name), description, parentPermission)
93 | }
94 |
95 | internal val DataFolder by WeiboHelperPlugin::dataFolder
96 |
97 | /**
98 | * @see [WEIBO_CACHE_PROPERTY]
99 | * @see [WeiboHelperSettings.cache]
100 | */
101 | internal val ImageCache: File by lazy {
102 | File(System.getProperty(WEIBO_CACHE_PROPERTY, WeiboHelperSettings.cache))
103 | }
104 |
105 | /**
106 | * @see [WEIBO_EXPIRE_IMAGE_PROPERTY]
107 | * @see [WeiboHelperSettings.expire]
108 | */
109 | internal val ImageExpire: Duration by lazy {
110 | Duration.ofHours(System.getProperty(WEIBO_EXPIRE_IMAGE_PROPERTY)?.toLong() ?: WeiboHelperSettings.expire.toLong())
111 | }
112 |
113 | /**
114 | * @see [WEIBO_CLEAN_FOLLOWING_PROPERTY]
115 | * @see [WeiboHelperSettings.following]
116 | */
117 | internal val ImageClearFollowing: Boolean by lazy {
118 | System.getProperty(WEIBO_CLEAN_FOLLOWING_PROPERTY)?.toBoolean() ?: WeiboHelperSettings.following
119 | }
120 |
121 | /**
122 | * @see [WEIBO_INTERVAL_FAST_PROPERTY]
123 | * @see [WeiboHelperSettings.fast]
124 | */
125 | internal val IntervalFast: Duration by lazy {
126 | Duration.ofMinutes(System.getProperty(WEIBO_INTERVAL_FAST_PROPERTY)?.toLong() ?: WeiboHelperSettings.fast.toLong())
127 | }
128 |
129 | /**
130 | * @see [WEIBO_INTERVAL_SLOW_PROPERTY]
131 | * @see [WeiboHelperSettings.slow]
132 | */
133 | internal val IntervalSlow: Duration by lazy {
134 | Duration.ofMinutes(System.getProperty(WEIBO_INTERVAL_SLOW_PROPERTY)?.toLong() ?: WeiboHelperSettings.slow.toLong())
135 | }
136 |
137 | /**
138 | * @see [WEIBO_CONTACT_PROPERTY]
139 | * @see [WeiboHelperSettings.contact]
140 | */
141 | @OptIn(ConsoleExperimentalApi::class)
142 | internal val LoginContact by lazy {
143 | val id = System.getProperty(WEIBO_CONTACT_PROPERTY)?.toLong() ?: WeiboHelperSettings.contact
144 | if (id == 12345L) throw IllegalArgumentException("没有设置登录失效联系人")
145 | for (bot in Bot.instances) {
146 | return@lazy bot.getContactOrNull(id) ?: continue
147 | }
148 | throw NoSuchElementException("无法联系 $id")
149 | }
150 |
151 | internal fun sendLoginMessage(message: String) {
152 | try {
153 | WeiboHelperPlugin
154 | } catch (_: Throwable) {
155 | CoroutineScope(Dispatchers.IO) + SupervisorJob()
156 | }.launch {
157 | while (isActive) {
158 | try {
159 | LoginContact.sendMessage(message)
160 | break
161 | } catch (cause: Exception) {
162 | logger.warning({ "向 ${LoginContact.render()} 发送消息失败" }, cause)
163 | }
164 | delay(60_000L)
165 | }
166 | }
167 | }
168 |
169 | internal val Emoticons get() = WeiboEmoticonData.emoticons
170 |
171 | internal val EmoticonCache get() = ImageCache.resolve("emoticon")
172 |
173 | internal val VideoCache get() = ImageCache.resolve("video")
174 |
175 | internal val CoverCache get() = ImageCache.resolve("cover")
176 |
177 | /**
178 | * @see [WEIBO_EXPIRE_HISTORY_PROPERTY]
179 | * @see [WeiboHelperSettings.history]
180 | */
181 | internal val HistoryExpire: Long by lazy {
182 | System.getProperty(WEIBO_EXPIRE_HISTORY_PROPERTY)?.toLong() ?: WeiboHelperSettings.history
183 | }
184 |
185 | /**
186 | * @see [WEIBO_FORWARD_PROPERTY]
187 | * @see [WeiboHelperSettings.forward]
188 | */
189 | internal val UseForwardMessage: Boolean by lazy {
190 | System.getProperty(WEIBO_FORWARD_PROPERTY)?.toBoolean() ?: WeiboHelperSettings.forward
191 | }
192 |
193 | /**
194 | * @see [WEIBO_URL_PROPERTY]
195 | * @see [WeiboHelperSettings.showUrl]
196 | */
197 | internal val ShowUrl: Boolean by lazy {
198 | System.getProperty(WEIBO_URL_PROPERTY)?.toBoolean() ?: WeiboHelperSettings.showUrl
199 | }
200 |
201 | internal fun UserBaseInfo.desktop(flush: Boolean = false, dir: File = ImageCache.resolve("$id")): File {
202 | dir.mkdirs()
203 | if (!flush
204 | || dir.resolve("desktop.ini").exists()
205 | || (following && dir.resolve("avatar.ico").exists())
206 | ) return dir
207 |
208 | dir.resolve("desktop.ini").apply { if (isHidden) dir.deleteRecursively() }.writeText(buildString {
209 | appendLine("[.ShellClassInfo]")
210 | appendLine("LocalizedResourceName=${if (following) '$' else '#'}${id}@${screen}")
211 | if (following) {
212 | try {
213 | ICOEncoder.write(ImageIO.read(URL(avatarLarge)), dir.resolve("avatar.ico"))
214 | } catch (cause: Exception) {
215 | logger.warning({ "头像下载失败" }, cause)
216 | }
217 | appendLine("IconResource=avatar.ico")
218 | }
219 | appendLine("[ViewState]")
220 | appendLine("Mode=")
221 | appendLine("Vid=")
222 | appendLine("FolderType=Pictures")
223 | }, Charsets.GBK)
224 |
225 | if (System.getProperty("os.name").lowercase().startsWith("windows")) {
226 | Runtime.getRuntime().exec("attrib ${dir.absolutePath} +s")
227 | }
228 |
229 | return dir
230 | }
231 |
232 | internal suspend fun Emoticon.file(): File {
233 | return EmoticonCache.resolve(category.ifBlank { "默认" }).resolve("$phrase.${url.substringAfterLast('.')}").apply {
234 | if (exists().not()) {
235 | parentFile.mkdirs()
236 | writeBytes(client.download(url))
237 | }
238 | }
239 | }
240 |
241 | internal suspend fun MicroBlog.getContent(showUrl: Boolean = true): String = supervisorScope {
242 | var content = raw
243 | var links = urls
244 | if (isLongText) {
245 | try {
246 | val data = client.getLongText(mid)
247 | content = requireNotNull(data.content) { "长文本为空 mid: $mid" }
248 | links = data.urls
249 | } catch (cause: Exception) {
250 | logger.warning({ "获取微博[${id}]长文本失败" }, cause)
251 | }
252 | }
253 | buildString {
254 | append(StringEscapeUtils.unescapeHtml4(content))
255 |
256 | for (struct in links) {
257 | if (struct.type.isEmpty()) continue
258 | val url = struct.h5 ?: struct.long ?: struct.short
259 | val target = if (showUrl) {
260 | "[${struct.title}]<${struct.type}>(${url})"
261 | } else {
262 | "[${struct.title}]"
263 | }
264 |
265 | var index = 0
266 | while (index < length) {
267 | index = indexOf(struct.short, index)
268 | if (index < 0) break
269 | replace(index, index + struct.short.length, target)
270 | index += struct.short.length.coerceAtLeast(1)
271 | }
272 | }
273 | }
274 | }
275 |
276 | internal suspend fun MicroBlog.getImages(flush: Boolean = false) = supervisorScope {
277 | if (pictures.isEmpty()) return@supervisorScope emptyList()
278 | val user = requireNotNull(user) { "没有用户信息" }
279 | val cache = user.desktop()
280 | val last = created.toEpochSecond() * 1_000
281 |
282 | pictures.mapIndexed { index, pid ->
283 | async {
284 | cache.resolve("${id}-${index}-${pid}.${extension(pid)}").apply {
285 | if (flush || exists().not()) {
286 | writeBytes(runCatching {
287 | // 下载速度更快
288 | client.download(picture(pid, index))
289 | }.recoverCatching {
290 | client.download(pid, index)
291 | }.onSuccess {
292 | logger.verbose { "[${name}]下载完成, 大小${it.size / 1024}KB" }
293 | }.getOrThrow())
294 | setLastModified(last)
295 | }
296 | }
297 | }
298 | }
299 | }
300 |
301 | internal suspend fun MicroBlog.getVideo(flush: Boolean = false) = supervisorScope {
302 | val media = requireNotNull(page?.media) { "MicroBlog(${mid}) Not Found Video" }
303 | val title = media.titles.firstOrNull()?.title ?: media.name
304 | val video = media.playbacks.maxOf { it.info }
305 | // TODO: safe file name
306 | VideoCache.resolve("${id}-${title}.mp4").apply {
307 | if (flush || exists().not()) {
308 | parentFile.mkdirs()
309 | client.download(video = video).collect(::appendBytes)
310 | }
311 | }
312 | }
313 |
314 | internal suspend fun MicroBlog.getCover(flush: Boolean = false) = supervisorScope {
315 | val url = requireNotNull(page?.picture) { "MicroBlog(${mid}) Not Found Cover" }
316 |
317 | CoverCache.resolve(url.substringAfterLast("/")).apply {
318 | if (flush || exists().not()) {
319 | parentFile.mkdirs()
320 | writeBytes(client.download(url = url))
321 | }
322 | }
323 | }
324 |
325 | private suspend fun emoticon(content: String, contact: Contact) = buildMessageChain {
326 | var pos = 0
327 | while (pos < content.length) {
328 | val start = content.indexOf('[', pos)
329 | if (start < 0) break
330 | val emoticon = Emoticons.values.find { content.startsWith(it.phrase, start) }
331 |
332 | if (emoticon == null) {
333 | add(content.substring(pos, start + 1))
334 | pos = start + 1
335 | continue
336 | }
337 |
338 | runCatching {
339 | emoticon.file().uploadAsImage(contact)
340 | }.onSuccess {
341 | add(content.substring(pos, start))
342 | add(it)
343 | }.onFailure {
344 | logger.warning({ "获取微博表情${emoticon.phrase}图片失败" }, it)
345 | add(content.substring(pos, start + emoticon.phrase.length))
346 | }
347 | pos = start + emoticon.phrase.length
348 | }
349 | appendLine(content.substring(pos))
350 | }
351 |
352 | internal suspend fun MicroBlog.toMessage(contact: Contact): MessageChain = buildMessageChain {
353 | title?.run { appendLine(text) }
354 | appendLine("@${username}#${uid}")
355 | appendLine("时间: $created")
356 | appendLine(if (ShowUrl) "链接: $link" else "MID: $mid")
357 | suffix?.run { appendLine(joinToString(" ") { it.content }) }
358 | appendLine("\uD83D\uDCAC: $comments \uD83D\uDD01: $reposts \uD83D\uDC4D\uD83C\uDFFB: $attitudes")
359 |
360 | // FIXME: Send Video
361 | if (WeiboHelperSettings.video && hasVideo) {
362 | supervisorScope {
363 | launch {
364 | val video = try {
365 | getVideo()
366 | } catch (cause: Throwable) {
367 | logger.warning({ "下载视频失败, $link" }, cause)
368 | return@launch
369 | }
370 |
371 | val cover = getCover().toExternalResource()
372 | try {
373 | val message = video.toExternalResource().use {
374 | contact.uploadShortVideo(thumbnail = cover, video = it, fileName = mid)
375 | }
376 | contact.sendMessage(message)
377 | return@launch
378 | } catch (cause: Throwable) {
379 | logger.warning({ "$contact 无法发送视频" }, cause)
380 | } finally {
381 | cover.close()
382 | }
383 |
384 | try {
385 | contact as FileSupported
386 | video.toExternalResource().use { contact.files.uploadNewFile(video.name, it) }
387 | } catch (cause: Throwable) {
388 | logger.warning({ "$contact 无法发送文件" }, cause)
389 | }
390 | }
391 | }
392 | }
393 |
394 | val content = getContent(showUrl = ShowUrl)
395 |
396 | if (WeiboHelperSettings.emoticon && Emoticons.isNotEmpty()) {
397 | add(emoticon(content, contact))
398 | } else {
399 | appendLine(content)
400 | }
401 |
402 | when (val picture = WeiboHelperSettings.picture) {
403 | is WeiboPicture.None -> Unit
404 | is WeiboPicture.All -> {
405 | for ((index, deferred) in getImages().withIndex()) {
406 | try {
407 | add(deferred.await().uploadAsImage(contact))
408 | } catch (cause: Exception) {
409 | logger.warning({ "获取微博[${id}]图片[${pictures[index]}]失败" }, cause)
410 | appendLine("获取微博[${id}]图片[${pictures[index]}]失败")
411 | }
412 | }
413 | }
414 | is WeiboPicture.Limit -> {
415 | for ((index, deferred) in getImages().withIndex()) {
416 | if (picture.total <= index) {
417 | appendLine("超过${picture.total}, 剩余图片省略")
418 | break
419 | }
420 | try {
421 | add(deferred.await().uploadAsImage(contact))
422 | } catch (cause: Exception) {
423 | logger.warning({ "获取微博[${id}]图片[${pictures[index]}]失败" }, cause)
424 | appendLine("获取微博[${id}]图片[${pictures[index]}]失败")
425 | }
426 | }
427 | }
428 | is WeiboPicture.Top -> {
429 | if (picture.total < pictures.size) {
430 | for ((index, deferred) in getImages().withIndex()) {
431 | try {
432 | add(deferred.await().uploadAsImage(contact))
433 | } catch (cause: Exception) {
434 | logger.warning({ "获取微博[${id}]图片[${pictures[index]}]失败" }, cause)
435 | appendLine("获取微博[${id}]图片[${pictures[index]}]失败")
436 | }
437 | }
438 | } else {
439 | appendLine("超过${picture.total}, 图片省略")
440 | }
441 | }
442 | }
443 |
444 | if (WeiboHelperSettings.cover && hasPage) {
445 | try {
446 | add(getCover().uploadAsImage(contact))
447 | } catch (cause: Exception) {
448 | logger.warning({ "获取微博[${id}]封面失败" }, cause)
449 | appendLine("获取微博[${id}]封面失败")
450 | }
451 | }
452 |
453 | retweeted?.let { blog ->
454 | appendLine("======================")
455 | add(blog.copy(urls = urls).toMessage(contact))
456 | }
457 | }
458 |
459 | private val NoDefault = { group: UserGroup -> group.type != UserGroupType.SYSTEM }
460 |
461 | internal fun UserGroupData.toMessage(predicate: (UserGroup) -> Boolean = NoDefault) = buildMessageChain {
462 | for (group in groups) {
463 | val list = group.list.filter(predicate)
464 | if (list.isNotEmpty()) {
465 | appendLine("===${group.title}===")
466 | }
467 | for (item in list) {
468 | appendLine("${item.title} -> ${item.gid}")
469 | }
470 | }
471 | }
472 |
473 | internal suspend fun UserInfo.toMessage(contact: Contact) = buildMessageChain {
474 | append(client.download(avatarLarge).toExternalResource().use { it.uploadAsImage(contact) })
475 | appendLine("已关注 @${screen}#${id}")
476 | }
477 |
478 | internal fun File.clean(following: Boolean, num: Int = 0) {
479 | logger.info { "微博图片清理开始" }
480 | val last = System.currentTimeMillis() - ImageExpire.toMillis()
481 | for (dir in listFiles() ?: return) {
482 | val avatar = dir.resolve("avatar.ico").exists()
483 | if (following.not() && avatar) continue
484 | val images = dir.listFiles { file -> file.extension in ImageExtensions.values } ?: continue
485 | if (num > 0 && images.size > num) continue
486 | images.all { file -> file.lastModified() < last && file.delete() }
487 | && dir.apply { for (file in listFiles().orEmpty()) file.delete() }.delete()
488 | }
489 | }
490 |
491 | internal suspend fun clear(interval: Long = 3600_000) = supervisorScope {
492 | if (ImageExpire.isNegative) return@supervisorScope
493 | while (isActive) {
494 | ImageCache.clean(following = ImageClearFollowing)
495 | delay(interval)
496 | }
497 | }
498 |
499 | internal suspend fun restore(interval: Long = 600_000) = supervisorScope {
500 | while (isActive) {
501 | val timestamp = client.wbpsess?.expires?.timestamp
502 | val current = System.currentTimeMillis()
503 | if (timestamp != null && current < timestamp) {
504 | delay((timestamp - current).coerceAtMost(interval))
505 | continue
506 | }
507 | try {
508 | val result = client.restore()
509 | logger.info { "WEIBO登陆状态已刷新 $result" }
510 | } catch (exception: SerializationException) {
511 | logger.warning({ "WEIBO RESTORE 任务序列化时失败" }, exception)
512 | sendLoginMessage("WEIBO RESTORE 任务序列化时失败")
513 | } catch (cause: Exception) {
514 | logger.warning({ "WEIBO登陆状态失效,需要重新登陆" }, cause)
515 | sendLoginMessage("WEIBO登陆状态失效,需要重新登陆 /wlogin")
516 | } finally {
517 | delay(interval)
518 | }
519 | }
520 | }
521 |
522 | internal suspend fun UserBaseInfo.getRecord(month: YearMonth, interval: Long) = supervisorScope {
523 | with(desktop(true).resolve("$month.json")) {
524 | if (exists() && month != YearMonth.now()) {
525 | WeiboClient.Json.decodeFromString(readText())
526 | } else {
527 | val blogs = try {
528 | WeiboClient.Json.decodeFromString>(readText())
529 | .associateByTo(HashMap()) { it.id }
530 | } catch (cause: Exception) {
531 | hashMapOf()
532 | }
533 | var page = 1
534 | var run = true
535 | while (isActive && run) {
536 | delay(interval)
537 | run = (runCatching {
538 | client.getUserMicroBlogs(uid = id, page = page, month = month).list
539 | }.onSuccess { list ->
540 | blogs.putAll(list.associateBy { it.id })
541 | logger.info { "@${screen}#${id}的${month}第${page}页加载成功" }
542 | page++
543 | }.onFailure {
544 | logger.warning({ "@${screen}#${id}的${month}第${page}页加载失败" }, it)
545 | }.getOrNull()?.size ?: Int.MAX_VALUE) >= 16
546 | }
547 | val list = blogs.values.toList()
548 | if (list.isNotEmpty()) {
549 | writeText(WeiboClient.Json.encodeToString(list))
550 | }
551 | list
552 | }
553 | }
554 | }
555 |
556 | internal val ClientIgnore: suspend (Throwable) -> Boolean = { throwable ->
557 | when (throwable) {
558 | is UnknownHostException,
559 | is NoRouteToHostException -> false
560 | is okhttp3.internal.http2.StreamResetException -> true
561 | is SocketException -> {
562 | logger.warning { "Weibo Client Ignore $throwable" }
563 | true
564 | }
565 | else -> false
566 | }
567 | }
568 |
569 | internal suspend fun WeiboClient.init() = supervisorScope {
570 | runCatching {
571 | restore()
572 | }.onSuccess {
573 | logger.info { "登陆成功, $it" }
574 | }.onFailure {
575 | logger.warning { "登陆失败, ${it.message}, 请尝试使用 /wlogin 指令登录" }
576 | runCatching {
577 | incarnate()
578 | }.onSuccess {
579 | logger.info { "模拟游客成功,置信度${it}" }
580 | }.onFailure {
581 | logger.warning { "模拟游客失败, ${it.message}" }
582 | }
583 | }.isSuccess && runCatching {
584 | val data = getEmoticon()
585 | for ((_, map) in data.emoticon) {
586 | for ((category, emoticons) in map) {
587 | for (emoticon in emoticons) {
588 | Emoticons[emoticon.phrase] = emoticon.copy(category = category)
589 | }
590 | }
591 | }
592 | }.onSuccess {
593 | logger.info { "加载表情成功" }
594 | }.onFailure {
595 | logger.warning { "加载表情失败, $it" }
596 | }.isSuccess
597 | }
598 |
599 | internal suspend fun > T.quote(block: suspend T.(Contact) -> Message): Boolean {
600 | return try {
601 | quoteReply(block(fromEvent.subject))
602 | true
603 | } catch (cause: Exception) {
604 | logger.warning({ "发送消息失败" }, cause)
605 | quoteReply("发送消息失败, ${cause.message}")
606 | false
607 | }
608 | }
609 |
610 | public suspend fun CommandSenderOnMessage<*>.quoteReply(message: Message): MessageReceipt? {
611 | return sendMessage(fromEvent.message.quote() + message)
612 | }
613 |
614 | public suspend fun CommandSenderOnMessage<*>.quoteReply(message: String): MessageReceipt? {
615 | return quoteReply(message.toPlainText())
616 | }
617 |
618 | /**
619 | * 通过正负号区分群和用户
620 | */
621 | public val Contact.delegate: Long get() = if (this is Group) id * -1 else id
622 |
623 | /**
624 | * 查找Contact
625 | */
626 | public fun findContact(delegate: Long): Contact? {
627 | for (bot in Bot.instances) {
628 | if (delegate < 0) {
629 | for (group in bot.groups) {
630 | if (group.id == delegate * -1) return group
631 | }
632 | } else {
633 | for (friend in bot.friends) {
634 | if (friend.id == delegate) return friend
635 | }
636 | for (stranger in bot.strangers) {
637 | if (stranger.id == delegate) return stranger
638 | }
639 | for (friend in bot.friends) {
640 | if (friend.id == delegate) return friend
641 | }
642 | for (group in bot.groups) {
643 | for (member in group.members) {
644 | if (member.id == delegate) return member
645 | }
646 | }
647 | }
648 | }
649 | return null
650 | }
--------------------------------------------------------------------------------
/src/main/kotlin/xyz/cssxsh/mirai/weibo/command/WeiboCacheCommand.kt:
--------------------------------------------------------------------------------
1 | package xyz.cssxsh.mirai.weibo.command
2 |
3 | import kotlinx.coroutines.*
4 | import kotlinx.coroutines.flow.*
5 | import net.mamoe.mirai.console.command.*
6 | import net.mamoe.mirai.message.data.*
7 | import net.mamoe.mirai.utils.*
8 | import xyz.cssxsh.mirai.weibo.*
9 | import xyz.cssxsh.weibo.api.*
10 | import java.time.*
11 |
12 | @PublishedApi
13 | internal object WeiboCacheCommand : CompositeCommand(
14 | owner = WeiboHelperPlugin,
15 | "wcache", "微博缓存",
16 | description = "微博缓存指令",
17 | ) {
18 |
19 | @SubCommand
20 | suspend fun CommandSenderOnMessage<*>.user(uid: Long, second: Int = 10, reposts: Int = 100) = quote {
21 | val interval = second * 1000L
22 | val info = client.getUserInfo(uid).user
23 | val history = client.getUserHistory(uid)
24 | val months = history.flatMap { (year, months) -> months.map { YearMonth.of(year, it)!! } }.sortedDescending()
25 | launch {
26 | var count = 0
27 | for (month in months) {
28 | runCatching {
29 | info.getRecord(month, interval).onEach { blog ->
30 | if (blog.reposts >= reposts) blog.getImages(flush = false).awaitAll()
31 | }
32 | }.onSuccess { record ->
33 | count += record.size
34 | }.onFailure {
35 | logger.warning({ "对@${info.screen}的${month}缓存下载失败" }, it)
36 | }
37 | }
38 | sendMessage("对@${info.screen}的缓存下载完成, ${count}/${info.statuses}")
39 | }
40 | "对@${info.screen}的{${months.first()}~${months.last()}}缓存任务开始".toPlainText()
41 | }
42 |
43 | @SubCommand
44 | suspend fun CommandSenderOnMessage<*>.group(gid: Long, second: Int = 10, reposts: Int = 100) = quote {
45 | val interval = second * 1000L
46 | val members = flow {
47 | var page = 1
48 | while (currentCoroutineContext().isActive) {
49 | runCatching {
50 | client.getGroupMembers(gid = gid, page = page++)
51 | }.onSuccess {
52 | if (it.users.isEmpty()) return@flow
53 | emitAll(it.users.asFlow())
54 | }.onFailure {
55 | logger.info(it.message)
56 | }.getOrNull() ?: break
57 | }
58 | }
59 | members.collect { info ->
60 | val history = client.getUserHistory(info.id)
61 | val months = history.flatMap { (year, months) ->
62 | months.map { YearMonth.of(year, it)!! }
63 | }.sortedDescending()
64 | var count = 0
65 | sendMessage("对@${info.screen}的{${months.first()}~${months.last()}}缓存任务开始")
66 | for (month in months) {
67 | runCatching {
68 | info.getRecord(month, interval).onEach { blog ->
69 | if (blog.reposts >= reposts) blog.getImages(flush = false).awaitAll()
70 | }
71 | }.onSuccess { record ->
72 | count += record.size
73 | }.onFailure {
74 | logger.warning({ "对@${info.screen}的${month}缓存下载失败" }, it)
75 | }
76 | }
77 | sendMessage("对@${info.screen}的缓存下载完成, ${count}/${info.statuses}")
78 | }
79 | "对Group($gid)的缓存文件夹图标已设置".toPlainText()
80 | }
81 |
82 | @SubCommand
83 | suspend fun CommandSenderOnMessage<*>.clean(following: Boolean, num: Int) = quote {
84 | ImageCache.clean(following, num)
85 | "清理完成".toPlainText()
86 | }
87 |
88 | @SubCommand
89 | suspend fun CommandSenderOnMessage<*>.emoticon() = quote {
90 | Emoticons.values.onEach { emoticon ->
91 | try {
92 | emoticon.file()
93 | } catch (cause: Exception) {
94 | logger.warning({ "表情${emoticon.phrase} 下载失败 ${emoticon.url}" }, cause)
95 | }
96 | }.joinTo(MessageChainBuilder()) { info ->
97 | "${info.category.ifBlank { "默认" }}/${info.phrase}"
98 | }.build()
99 | }
100 | }
--------------------------------------------------------------------------------
/src/main/kotlin/xyz/cssxsh/mirai/weibo/command/WeiboContext.kt:
--------------------------------------------------------------------------------
1 | package xyz.cssxsh.mirai.weibo.command
2 |
3 | import net.mamoe.mirai.console.command.*
4 | import net.mamoe.mirai.console.command.descriptor.*
5 | import net.mamoe.mirai.contact.*
6 |
7 | public fun CommandSender.subject(): Contact = subject ?: throw CommandArgumentParserException("无法从当前环境获取联系人")
--------------------------------------------------------------------------------
/src/main/kotlin/xyz/cssxsh/mirai/weibo/command/WeiboDetailCommand.kt:
--------------------------------------------------------------------------------
1 | package xyz.cssxsh.mirai.weibo.command
2 |
3 | import net.mamoe.mirai.console.command.*
4 | import xyz.cssxsh.mirai.weibo.*
5 | import xyz.cssxsh.weibo.api.*
6 |
7 | @PublishedApi
8 | internal object WeiboDetailCommand : SimpleCommand(
9 | owner = WeiboHelperPlugin,
10 | "wdetail", "blog", "微博详情",
11 | description = "微博详情指令",
12 | ) {
13 |
14 | @Handler
15 | suspend fun CommandSenderOnMessage<*>.hendle(mid: String) = quote { client.getMicroBlog(mid).toMessage(it) }
16 | }
--------------------------------------------------------------------------------
/src/main/kotlin/xyz/cssxsh/mirai/weibo/command/WeiboFollowCommand.kt:
--------------------------------------------------------------------------------
1 | package xyz.cssxsh.mirai.weibo.command
2 |
3 | import net.mamoe.mirai.console.command.*
4 | import xyz.cssxsh.mirai.weibo.*
5 | import xyz.cssxsh.weibo.api.*
6 |
7 | @PublishedApi
8 | internal object WeiboFollowCommand : SimpleCommand(
9 | owner = WeiboHelperPlugin,
10 | "wfollow", "微博关注",
11 | description = "微博关注指令",
12 | ) {
13 |
14 | @Handler
15 | suspend fun CommandSenderOnMessage<*>.hendle(uid: Long) = quote { client.follow(uid).toMessage(subject()) }
16 | }
--------------------------------------------------------------------------------
/src/main/kotlin/xyz/cssxsh/mirai/weibo/command/WeiboGroupCommand.kt:
--------------------------------------------------------------------------------
1 | package xyz.cssxsh.mirai.weibo.command
2 |
3 | import net.mamoe.mirai.console.command.*
4 | import net.mamoe.mirai.contact.*
5 | import net.mamoe.mirai.utils.*
6 | import xyz.cssxsh.mirai.weibo.*
7 | import xyz.cssxsh.mirai.weibo.data.*
8 | import xyz.cssxsh.weibo.*
9 | import xyz.cssxsh.weibo.api.*
10 | import xyz.cssxsh.weibo.data.*
11 |
12 | @PublishedApi
13 | internal object WeiboGroupCommand : CompositeCommand(
14 | owner = WeiboHelperPlugin,
15 | "wgroup", "微博分组",
16 | description = "微博分组指令",
17 | ) {
18 |
19 | private val subscriber = object : WeiboSubscriber(primaryName) {
20 | override val load: suspend (Long) -> List = { id ->
21 | client.getGroupsTimeline(gid = id, count = 100).statuses
22 | }
23 |
24 | override val tasks: MutableMap by WeiboTaskData::groups
25 | }
26 |
27 | @SubCommand("list", "列表")
28 | suspend fun CommandSender.list() = sendMessage(client.getFeedGroups().toMessage())
29 |
30 | @SubCommand("add", "task", "订阅")
31 | suspend fun CommandSender.task(id: String, subject: Contact = subject()) {
32 | val group = client.getFeedGroups()[id]
33 | subscriber.add(id = group.gid, name = group.title, subject = subject)
34 | sendMessage("对<${group.title}#${group.gid}>的监听任务, 添加完成")
35 | }
36 |
37 | @SubCommand("stop", "停止")
38 | suspend fun CommandSender.stop(id: String, subject: Contact = subject()) {
39 | val gid = try {
40 | client.getFeedGroups()[id].gid
41 | } catch (cause: Exception) {
42 | logger.warning({ "查询群组失败" }, cause)
43 | return
44 | }
45 | subscriber.remove(id = gid, subject = subject)
46 | sendMessage("对Group(${gid})的监听任务, 取消完成")
47 | }
48 |
49 | @SubCommand("detail", "详情")
50 | suspend fun CommandSender.detail(subject: Contact = subject()) {
51 | sendMessage(subscriber.detail(subject = subject))
52 | }
53 | }
--------------------------------------------------------------------------------
/src/main/kotlin/xyz/cssxsh/mirai/weibo/command/WeiboHotCommand.kt:
--------------------------------------------------------------------------------
1 | package xyz.cssxsh.mirai.weibo.command
2 |
3 | import net.mamoe.mirai.console.command.*
4 | import net.mamoe.mirai.contact.*
5 | import xyz.cssxsh.mirai.weibo.*
6 | import xyz.cssxsh.mirai.weibo.data.*
7 | import xyz.cssxsh.weibo.api.*
8 | import xyz.cssxsh.weibo.data.*
9 |
10 | @PublishedApi
11 | internal object WeiboHotCommand : CompositeCommand(
12 | owner = WeiboHelperPlugin,
13 | "whot", "微博热搜",
14 | description = "微博热搜指令",
15 | ) {
16 |
17 | private val subscriber = object : WeiboSubscriber(primaryName) {
18 | override val load: suspend (String) -> List = { keyword ->
19 | client.search(keyword = keyword, type = ChannelType.HOT).cards.mapNotNull { it.blog }
20 | }
21 |
22 | override val tasks: MutableMap by WeiboTaskData::hots
23 | }
24 |
25 | @SubCommand("add", "task", "订阅")
26 | suspend fun CommandSender.task(word: String, subject: Contact = subject()) {
27 | subscriber.add(id = word, name = word, subject = subject)
28 | sendMessage("对<${word}>的监听任务, 添加完成")
29 | }
30 |
31 | @SubCommand("stop", "停止")
32 | suspend fun CommandSender.stop(word: String, subject: Contact = subject()) {
33 | subscriber.remove(id = word, subject = subject)
34 | sendMessage("对<${word}>的监听任务, 取消完成")
35 | }
36 |
37 | @SubCommand("detail", "详情")
38 | suspend fun CommandSender.detail(subject: Contact = subject()) {
39 | sendMessage(subscriber.detail(subject = subject))
40 | }
41 | }
--------------------------------------------------------------------------------
/src/main/kotlin/xyz/cssxsh/mirai/weibo/command/WeiboLoginCommand.kt:
--------------------------------------------------------------------------------
1 | package xyz.cssxsh.mirai.weibo.command
2 |
3 | import kotlinx.coroutines.*
4 | import net.mamoe.mirai.console.command.*
5 | import net.mamoe.mirai.message.data.*
6 | import net.mamoe.mirai.utils.ExternalResource.Companion.toExternalResource
7 | import net.mamoe.mirai.utils.ExternalResource.Companion.uploadAsImage
8 | import xyz.cssxsh.mirai.weibo.*
9 | import xyz.cssxsh.weibo.*
10 | import xyz.cssxsh.weibo.api.*
11 |
12 | @PublishedApi
13 | internal object WeiboLoginCommand : SimpleCommand(
14 | owner = WeiboHelperPlugin,
15 | "wlogin", "微博登录",
16 | description = "微博登录指令",
17 | ) {
18 |
19 | @Handler
20 | suspend fun CommandSenderOnMessage<*>.hendle() = quote { contact ->
21 | val result = client.qrcode { url ->
22 | logger.info("qrcode: $url")
23 | launch {
24 | val image = try {
25 | withTimeout(60_000) {
26 | client.download(url).toExternalResource().use { it.uploadAsImage(contact) }
27 | }
28 | } catch (cause: Exception) {
29 | logger.warning("qrcode download or upload fail.", cause)
30 | url.toPlainText()
31 | }
32 |
33 | sendMessage(image)
34 | }
35 | }
36 | "@${result.info.display}#${result.info.uid} 登陆成功".toPlainText()
37 | }
38 |
39 | @Handler
40 | suspend fun ConsoleCommandSender.hendle() {
41 | val message = try {
42 | val result = client.qrcode { url ->
43 | launch {
44 | sendMessage(url)
45 | }
46 | }
47 | "@${result.info.display}#${result.info.uid} 登陆成功"
48 | } catch (cause: Exception) {
49 | cause.message ?: cause.toString()
50 | }
51 | sendMessage(message)
52 | }
53 | }
--------------------------------------------------------------------------------
/src/main/kotlin/xyz/cssxsh/mirai/weibo/command/WeiboSuperChatCommand.kt:
--------------------------------------------------------------------------------
1 | package xyz.cssxsh.mirai.weibo.command
2 |
3 | import net.mamoe.mirai.console.command.*
4 | import net.mamoe.mirai.contact.*
5 | import xyz.cssxsh.mirai.weibo.*
6 | import xyz.cssxsh.mirai.weibo.data.*
7 | import xyz.cssxsh.weibo.api.*
8 | import xyz.cssxsh.weibo.data.*
9 |
10 | @PublishedApi
11 | internal object WeiboSuperChatCommand : CompositeCommand(
12 | owner = WeiboHelperPlugin,
13 | "wsc", "微博超话",
14 | description = "微博超话指令",
15 | ) {
16 |
17 | private val subscriber = object : WeiboSubscriber(primaryName) {
18 | override val load: suspend (String) -> List = { id ->
19 | val data = client.getSuperChatData(id = id)
20 | buildList {
21 | for (card in data.cards) {
22 | for (group in card.group) {
23 | add(group.blog?.toMicroBlog() ?: continue)
24 | }
25 | add(card.blog?.toMicroBlog() ?: continue)
26 | }
27 | }
28 | }
29 |
30 | override val tasks: MutableMap by WeiboTaskData::scs
31 | }
32 |
33 | @SubCommand("add", "task", "订阅")
34 | suspend fun CommandSender.task(id: String, subject: Contact = subject()) {
35 | val chat = if (id.length > 10) {
36 | id
37 | } else {
38 | client.getSuperChatHome(name = id)
39 | }
40 | val title = client.getSuperChatData(id = chat).info.title
41 | subscriber.add(id = chat, name = title, subject = subject)
42 | sendMessage("对<${title}>的监听任务, 添加完成")
43 | }
44 |
45 | @SubCommand("stop", "停止")
46 | suspend fun CommandSender.stop(id: String, subject: Contact = subject()) {
47 | subscriber.remove(id = id, subject = subject)
48 | sendMessage("对<${id}>的监听任务, 取消完成")
49 | }
50 |
51 | @SubCommand("detail", "详情")
52 | suspend fun CommandSender.detail(subject: Contact = subject()) {
53 | sendMessage(subscriber.detail(subject = subject))
54 | }
55 | }
--------------------------------------------------------------------------------
/src/main/kotlin/xyz/cssxsh/mirai/weibo/command/WeiboUserCommand.kt:
--------------------------------------------------------------------------------
1 | package xyz.cssxsh.mirai.weibo.command
2 |
3 | import net.mamoe.mirai.console.command.*
4 | import net.mamoe.mirai.contact.*
5 | import xyz.cssxsh.mirai.weibo.*
6 | import xyz.cssxsh.mirai.weibo.data.*
7 | import xyz.cssxsh.weibo.api.*
8 | import xyz.cssxsh.weibo.data.*
9 |
10 | @PublishedApi
11 | internal object WeiboUserCommand : CompositeCommand(
12 | owner = WeiboHelperPlugin,
13 | "wuser", "微博用户",
14 | description = "微博用户指令",
15 | ) {
16 |
17 | private val subscriber = object : WeiboSubscriber(primaryName) {
18 | override val load: suspend (Long) -> List = { id ->
19 | client.getUserMicroBlogs(uid = id, page = 1).list
20 | }
21 |
22 | override val reposts: Boolean = false
23 |
24 | override val tasks: MutableMap by WeiboTaskData::users
25 | }
26 |
27 | @SubCommand("add", "task", "订阅")
28 | suspend fun CommandSender.task(uid: Long, subject: Contact = subject()) {
29 | val user = client.getUserInfo(uid = uid).user
30 | subscriber.add(id = user.id, name = user.screen, subject = subject)
31 | sendMessage("对@${user.screen}#${user.id}的监听任务, 添加完成")
32 | }
33 |
34 | @SubCommand("stop", "停止")
35 | suspend fun CommandSender.stop(uid: Long, subject: Contact = subject()) {
36 | subscriber.remove(id = uid, subject = subject)
37 | sendMessage("对User(${uid})的监听任务, 取消完成")
38 | }
39 |
40 | @SubCommand("detail", "详情")
41 | suspend fun CommandSender.detail(subject: Contact = subject()) {
42 | sendMessage(subscriber.detail(subject = subject))
43 | }
44 | }
--------------------------------------------------------------------------------
/src/main/kotlin/xyz/cssxsh/mirai/weibo/data/OffsetDateTimeSerializer.kt:
--------------------------------------------------------------------------------
1 | package xyz.cssxsh.mirai.weibo.data
2 |
3 | import kotlinx.serialization.*
4 | import kotlinx.serialization.descriptors.*
5 | import kotlinx.serialization.encoding.*
6 | import java.time.*
7 | import java.time.format.*
8 |
9 | @PublishedApi
10 | internal object OffsetDateTimeSerializer : KSerializer {
11 |
12 | private val formatter: DateTimeFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME
13 |
14 | override val descriptor: SerialDescriptor =
15 | PrimitiveSerialDescriptor(OffsetDateTime::class.qualifiedName!!, PrimitiveKind.STRING)
16 |
17 | override fun deserialize(decoder: Decoder): OffsetDateTime =
18 | OffsetDateTime.parse(decoder.decodeString(), formatter)
19 |
20 | override fun serialize(encoder: Encoder, value: OffsetDateTime) =
21 | encoder.encodeString(formatter.format(value))
22 | }
--------------------------------------------------------------------------------
/src/main/kotlin/xyz/cssxsh/mirai/weibo/data/RegexSerializer.kt:
--------------------------------------------------------------------------------
1 | package xyz.cssxsh.mirai.weibo.data
2 |
3 | import kotlinx.serialization.*
4 | import kotlinx.serialization.descriptors.*
5 | import kotlinx.serialization.encoding.*
6 |
7 | @PublishedApi
8 | internal object RegexSerializer : KSerializer {
9 |
10 | override val descriptor: SerialDescriptor =
11 | PrimitiveSerialDescriptor(Regex::class.qualifiedName!!, PrimitiveKind.STRING)
12 |
13 | override fun deserialize(decoder: Decoder): Regex = Regex(pattern = decoder.decodeString())
14 |
15 | override fun serialize(encoder: Encoder, value: Regex) = encoder.encodeString(value = value.pattern)
16 | }
--------------------------------------------------------------------------------
/src/main/kotlin/xyz/cssxsh/mirai/weibo/data/WeiboEmoticonData.kt:
--------------------------------------------------------------------------------
1 | package xyz.cssxsh.mirai.weibo.data
2 |
3 | import kotlinx.serialization.*
4 | import net.mamoe.mirai.console.data.*
5 | import net.mamoe.mirai.console.util.*
6 | import xyz.cssxsh.weibo.*
7 | import xyz.cssxsh.weibo.data.*
8 | import java.io.*
9 |
10 | @PublishedApi
11 | internal object WeiboEmoticonData : AutoSavePluginData("WeiboEmoticonData") {
12 |
13 | @PublishedApi
14 | internal fun default(): Map {
15 | val url = this::class.java.getResource("Emoticons.json") ?: throw FileNotFoundException("Emoticons.json")
16 | return WeiboClient.Json.decodeFromString(url.readText())
17 | }
18 |
19 | @ConsoleExperimentalApi
20 | override fun shouldPerformAutoSaveWheneverChanged(): Boolean = false
21 |
22 | @ValueDescription("表情数据")
23 | val emoticons: MutableMap by value { putAll(default()) }
24 | }
--------------------------------------------------------------------------------
/src/main/kotlin/xyz/cssxsh/mirai/weibo/data/WeiboHelperSettings.kt:
--------------------------------------------------------------------------------
1 | package xyz.cssxsh.mirai.weibo.data
2 |
3 | import kotlinx.serialization.modules.*
4 | import net.mamoe.mirai.console.data.*
5 | import xyz.cssxsh.mirai.weibo.*
6 |
7 | @PublishedApi
8 | internal object WeiboHelperSettings : ReadOnlyPluginConfig("WeiboHelperSettings"), WeiboFilter {
9 |
10 | override val serializersModule: SerializersModule = SerializersModule {
11 | contextual(WeiboPicture.serializer())
12 | contextual(RegexSerializer)
13 | }
14 |
15 | @ValueDescription("登录状态失效联系人")
16 | val contact by value(12345L)
17 |
18 | @ValueDescription("图片缓存位置")
19 | val cache: String by value("WeiboCache")
20 |
21 | @ValueDescription("图片缓存过期时间,单位小时,默认3天,为0时不会过期")
22 | val expire: Int by value(72)
23 |
24 | @ValueDescription("是否清理收藏的用户")
25 | val following: Boolean by value(true)
26 |
27 | @ValueDescription("快速轮询间隔,单位分钟")
28 | val fast: Int by value(1)
29 |
30 | @ValueDescription("慢速轮询间隔,单位分钟")
31 | val slow: Int by value(10)
32 |
33 | @ValueDescription("微博分组订阅器,转发数过滤器,默认16")
34 | override val repost: Long by value(16L)
35 |
36 | @ValueDescription("屏蔽的微博用户")
37 | override val users: Set by value(setOf(1191220232L))
38 |
39 | @ValueDescription("屏蔽的关键词正则表达式")
40 | override val regexes: List by value(listOf("女拳".toRegex()))
41 |
42 | @ValueDescription("屏蔽URL类型,填入 39 可以屏蔽微博视频")
43 | override val urls: Set by value()
44 |
45 | @ValueDescription("屏蔽转发")
46 | override val original: Boolean by value(false)
47 |
48 | @ValueDescription("屏蔽点赞转发")
49 | override val likes: Boolean by value(false)
50 |
51 | @ValueDescription("发送微博视频文件")
52 | val video: Boolean by value(true)
53 |
54 | @ValueDescription("处理微博表情")
55 | val emoticon: Boolean by value(true)
56 |
57 | @ValueDescription("显示图片数设置")
58 | val picture: WeiboPicture by value(WeiboPicture.All())
59 |
60 | @ValueDescription("显示封面设置")
61 | val cover: Boolean by value(true)
62 |
63 | @ValueDescription("历史记录保留时间,单位天,默认 7d")
64 | val history by value(7L)
65 |
66 | @ValueDescription("Http 超时时间")
67 | val timeout by value(60_000L)
68 |
69 | @ValueDescription("以转发消息的方式发送订阅微博")
70 | val forward: Boolean by value(false)
71 |
72 | @ValueName("show_url")
73 | @ValueDescription("是否显示url")
74 | val showUrl: Boolean by value(true)
75 |
76 | @ValueDescription("自动解析同样内容的间隔")
77 | val interval: Long by value(600_000L)
78 | }
--------------------------------------------------------------------------------
/src/main/kotlin/xyz/cssxsh/mirai/weibo/data/WeiboStatusData.kt:
--------------------------------------------------------------------------------
1 | package xyz.cssxsh.mirai.weibo.data
2 |
3 | import net.mamoe.mirai.console.data.*
4 | import xyz.cssxsh.weibo.data.*
5 |
6 | @PublishedApi
7 | internal object WeiboStatusData : AutoSavePluginData("WeiboStatusData") {
8 |
9 | @ValueDescription("登录状态")
10 | var status by value(LoginStatus())
11 | }
--------------------------------------------------------------------------------
/src/main/kotlin/xyz/cssxsh/mirai/weibo/data/WeiboTaskData.kt:
--------------------------------------------------------------------------------
1 | package xyz.cssxsh.mirai.weibo.data
2 |
3 | import net.mamoe.mirai.console.data.*
4 |
5 | @PublishedApi
6 | internal object WeiboTaskData : AutoSavePluginData("WeiboTaskData") {
7 |
8 | @ValueDescription("微博用户订阅器,KEY是UID")
9 | val users: MutableMap by value()
10 |
11 | @ValueDescription("微博分组订阅器,KEY是GID")
12 | val groups: MutableMap by value()
13 |
14 | @ValueDescription("微博热搜订阅器,KEY是 KEYWORD")
15 | val hots: MutableMap by value()
16 |
17 | @ValueDescription("微博超话订阅器,KEY是 SID")
18 | val scs: MutableMap by value()
19 | }
--------------------------------------------------------------------------------
/src/main/kotlin/xyz/cssxsh/mirai/weibo/data/WeiboTaskInfo.kt:
--------------------------------------------------------------------------------
1 | package xyz.cssxsh.mirai.weibo.data
2 |
3 | import kotlinx.serialization.*
4 | import java.time.*
5 |
6 | @Serializable
7 | public data class WeiboTaskInfo(
8 | @SerialName("last")
9 | @Serializable(OffsetDateTimeSerializer::class)
10 | val last: OffsetDateTime = OffsetDateTime.now(),
11 | @SerialName("name")
12 | val name: String = "",
13 | @SerialName("contacts")
14 | val contacts: Set = emptySet()
15 | )
--------------------------------------------------------------------------------
/src/main/kotlin/xyz/cssxsh/weibo/AcceptAllCookiesStorage.kt:
--------------------------------------------------------------------------------
1 | package xyz.cssxsh.weibo
2 |
3 | import io.ktor.client.plugins.cookies.*
4 | import io.ktor.http.*
5 | import kotlinx.coroutines.sync.*
6 | import kotlin.properties.*
7 |
8 | private inline fun reflect() = ReadOnlyProperty { thisRef, property ->
9 | thisRef::class.java.getDeclaredField(property.name).apply { isAccessible = true }.get(thisRef) as R
10 | }
11 |
12 | internal val CookiesStorage.mutex: Mutex by reflect()
13 |
14 | internal val CookiesStorage.container: MutableList by reflect()
--------------------------------------------------------------------------------
/src/main/kotlin/xyz/cssxsh/weibo/Load.kt:
--------------------------------------------------------------------------------
1 | package xyz.cssxsh.weibo
2 |
3 | import io.ktor.client.call.*
4 | import io.ktor.client.plugins.*
5 | import io.ktor.client.request.*
6 | import io.ktor.http.*
7 | import kotlinx.coroutines.*
8 | import kotlinx.coroutines.flow.*
9 | import kotlinx.serialization.*
10 | import kotlinx.serialization.json.*
11 | import xyz.cssxsh.weibo.api.*
12 | import xyz.cssxsh.weibo.data.*
13 | import java.nio.charset.*
14 |
15 | @PublishedApi
16 | internal fun Boolean.toInt(): Int = if (this) 1 else 0
17 |
18 | @Serializable
19 | public data class TempData(
20 | @SerialName("data")
21 | val `data`: JsonElement? = null,
22 | @SerialName("url")
23 | val url: String? = null,
24 | @SerialName("http_code")
25 | val httpCode: Int = 200,
26 | @SerialName("ok")
27 | @Serializable(NumberToBooleanSerializer::class)
28 | val ok: Boolean = true
29 | )
30 |
31 | @PublishedApi
32 | internal const val ErrorMessageLength: Int = 32
33 |
34 | @PublishedApi
35 | internal const val SERIALIZATION_EXCEPTION_SAVE: String = "xyz.cssxsh.weibo.json.save"
36 |
37 | public suspend inline fun WeiboClient.text(
38 | url: String,
39 | crossinline block: HttpRequestBuilder.() -> Unit
40 | ): String {
41 | return useHttpClient { client -> client.prepareGet(url, block).body() }
42 | }
43 |
44 | public suspend inline fun WeiboClient.temp(
45 | url: String,
46 | crossinline block: HttpRequestBuilder.() -> Unit
47 | ): T {
48 | val text = text(url, block)
49 | check(text.startsWith("{")) { text.substring(0, minOf(ErrorMessageLength, text.length)) }
50 | val temp = WeiboClient.Json.decodeFromString(text)
51 | val data = requireNotNull(temp.data) {
52 | if (temp.url.orEmpty().startsWith(LOGIN_PAGE)) {
53 | "登陆状态无效,请登录"
54 | } else {
55 | text
56 | }
57 | }
58 | return try {
59 | WeiboClient.Json.decodeFromJsonElement(data)
60 | } catch (cause: SerializationException) {
61 | supervisorScope {
62 | System.getProperty(SERIALIZATION_EXCEPTION_SAVE)?.let { path ->
63 | val folder = java.io.File(path)
64 | folder.mkdirs()
65 | folder.resolve("${System.currentTimeMillis()}.json").writeText(text)
66 | }
67 | }
68 | throw IllegalStateException("${temp.httpCode} - ${temp.url}", cause)
69 | }
70 | }
71 |
72 | public suspend inline fun WeiboClient.callback(
73 | url: String,
74 | crossinline block: HttpRequestBuilder.() -> Unit
75 | ): T {
76 | val json = text(url, block).substringAfter('(').substringBefore(')')
77 | return try {
78 | WeiboClient.Json.decodeFromString(json)
79 | } catch (cause: Exception) {
80 | throw IllegalArgumentException(json, cause)
81 | }
82 | }
83 |
84 | public suspend inline fun WeiboClient.json(
85 | url: String,
86 | crossinline block: HttpRequestBuilder.() -> Unit
87 | ): T {
88 | val text = text(url, block)
89 | check(text.startsWith("{")) { text.substring(0, minOf(ErrorMessageLength, text.length)) }
90 | val temp = WeiboClient.Json.decodeFromString(text)
91 | check(temp.ok) {
92 | if (temp.url.orEmpty().startsWith(LOGIN_PAGE)) {
93 | "登陆状态无效,请登录"
94 | } else {
95 | text
96 | }
97 | }
98 | return try {
99 | WeiboClient.Json.decodeFromString(text)
100 | } catch (cause: SerializationException) {
101 | supervisorScope {
102 | System.getProperty(SERIALIZATION_EXCEPTION_SAVE)?.let { path ->
103 | val folder = java.io.File(path)
104 | folder.mkdirs()
105 | folder.resolve("${System.currentTimeMillis()}.json").writeText(text)
106 | }
107 | }
108 | throw IllegalStateException("${temp.httpCode} - ${temp.url}", cause)
109 | }
110 | }
111 |
112 | public suspend fun WeiboClient.download(
113 | url: String,
114 | min: Long = 1024
115 | ): ByteArray = useHttpClient { client ->
116 | client.prepareGet(url) {
117 | header(HttpHeaders.Referrer, INDEX_PAGE)
118 | }.execute { response ->
119 | // 部分 response 没有 ContentLength, 直接返回,例如验证码
120 | val length = response.contentLength() ?: Long.MAX_VALUE
121 | if (length < min) {
122 | throw ClientRequestException(response, response.body())
123 | }
124 | response.body()
125 | }
126 | }
127 |
128 | public suspend fun WeiboClient.download(
129 | pid: String,
130 | index: Int
131 | ): ByteArray = useHttpClient { client ->
132 | client.prepareGet(image(pid = pid, server = ImageServer.random(), index = index)) {
133 | header(HttpHeaders.Referrer, INDEX_PAGE)
134 | }.body()
135 | }
136 |
137 | public suspend fun WeiboClient.download(
138 | video: PageInfo.PlayInfo
139 | ): Flow = flow {
140 | for (offset in 0 until video.size step video.buffer) {
141 | val limit = (offset + video.buffer).coerceAtMost(video.size) - 1
142 | emit(useHttpClient { client ->
143 | client.prepareGet(video.url) {
144 | header(HttpHeaders.Referrer, INDEX_PAGE)
145 | header(HttpHeaders.Range, "bytes=${offset}-${limit}")
146 | }.body()
147 | })
148 | }
149 | }
150 |
151 | /**
152 | * 国标码
153 | * Chinese Internal Code Specification
154 | */
155 | @Suppress("unused")
156 | @PublishedApi
157 | internal val Charsets.GBK: Charset
158 | get() = Charset.forName("GBK")
159 |
160 | @PublishedApi
161 | internal const val EncodeChars: String = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
162 |
163 | public fun String.decodeBase62(): Long = fold(0L) { acc, char ->
164 | val index = EncodeChars.indexOf(char)
165 | check(index != -1) { "$char no 62" }
166 | acc * 62 + index
167 | }
168 |
169 | public const val WEIBO_EPOCH: Long = 515483463L
170 |
171 | public fun timestamp(id: Long): Long = (id shr 22) + WEIBO_EPOCH
172 |
173 | public fun id(mid: String): Long {
174 | return mid.substring(0..0).decodeBase62().times(1_0000000_0000000L) +
175 | mid.substring(1..4).decodeBase62().times(1_0000000L) +
176 | mid.substring(5..8).decodeBase62()
177 | }
178 |
179 | public val ImageServer: List = listOf("wx1.sinaimg.cn", "wx2.sinaimg.cn", "wx3.sinaimg.cn", "wx4.sinaimg.cn")
180 |
181 | public val ImageExtensions: Map = mapOf(
182 | ContentType.Image.JPEG to "jpg",
183 | ContentType.Image.GIF to "gif",
184 | ContentType.Image.PNG to "png",
185 | )
186 |
187 | public fun user(pid: String): Long = with(pid.substring(0..7)) {
188 | if (startsWith("00")) decodeBase62() else toLong(16)
189 | }
190 |
191 | public fun extension(pid: String): String = ImageExtensions.values.first { it.startsWith(pid[21]) }
192 |
193 | public fun image(pid: String, server: String, index: Int): String = "https://${server}/large/${pid}.${extension(pid)}#${index}"
194 |
195 | public fun picture(pid: String, index: Int): String = "https://weibo.com/ajax/common/download?pid=${pid}#${index}"
196 |
197 | public val MicroBlog.link: String get() = "https://weibo.com/${user?.id ?: "detail"}/${mid}"
198 |
199 | public val MicroBlog.username: String get() = user?.screen ?: "[未知用户]"
200 |
201 | public val MicroBlog.uid: Long get() = user?.id ?: 0
202 |
203 | public operator fun UserGroupData.get(id: String): UserGroup {
204 | for (category in groups) {
205 | for (group in category.list) {
206 | if (group.gid == id.toLongOrNull()) return group
207 | if (group.title == id) return group
208 | }
209 | }
210 | throw NoSuchElementException("Group: $id")
211 | }
212 |
--------------------------------------------------------------------------------
/src/main/kotlin/xyz/cssxsh/weibo/WeiboClient.kt:
--------------------------------------------------------------------------------
1 | package xyz.cssxsh.weibo
2 |
3 | import io.ktor.client.*
4 | import io.ktor.client.engine.okhttp.*
5 | import io.ktor.client.plugins.*
6 | import io.ktor.client.plugins.compression.*
7 | import io.ktor.client.plugins.cookies.*
8 | import io.ktor.client.request.*
9 | import io.ktor.http.*
10 | import io.ktor.utils.io.core.*
11 | import io.ktor.utils.io.errors.*
12 | import kotlinx.coroutines.*
13 | import kotlinx.coroutines.sync.*
14 | import kotlinx.serialization.json.*
15 | import xyz.cssxsh.weibo.data.*
16 | import kotlin.coroutines.*
17 |
18 | public open class WeiboClient(
19 | public val ignore: suspend (Throwable) -> Boolean = DefaultIgnore
20 | ) : CoroutineScope, Closeable {
21 | override val coroutineContext: CoroutineContext
22 | get() = client.coroutineContext
23 |
24 | override fun close(): Unit = client.close()
25 |
26 | protected val cookies: List
27 | get() = storage.container.filter { it.expires != null }.map(::renderSetCookieHeader)
28 |
29 | public fun status(): LoginStatus = LoginStatus(info, cookies)
30 |
31 | public fun load(status: LoginStatus): Boolean = runBlocking(coroutineContext) {
32 | info = status.info
33 | storage.mutex.withLock {
34 | storage.container.addAll(status.cookies.map(::parseServerSetCookieHeader))
35 | }
36 | }
37 |
38 | protected open val storage: CookiesStorage = AcceptAllCookiesStorage()
39 |
40 | @PublishedApi
41 | internal open var info: LoginUserInfo = LoginUserInfo("", 0)
42 |
43 | @PublishedApi
44 | internal val xsrf: Cookie? get() = storage.container["XSRF-TOKEN"]
45 |
46 | @PublishedApi
47 | internal val srf: Cookie? get() = storage.container["SRF"]
48 |
49 | @PublishedApi
50 | internal val wbpsess: Cookie? get() = storage.container["WBPSESS"]
51 |
52 | protected open val timeout: Long = 30_000 // attr(open) ok ?
53 |
54 | protected open val client: HttpClient = HttpClient(OkHttp) {
55 | install(HttpTimeout) {
56 | socketTimeoutMillis = timeout
57 | connectTimeoutMillis = timeout
58 | requestTimeoutMillis = null
59 | }
60 | install(HttpCookies) {
61 | storage = this@WeiboClient.storage
62 | }
63 | Charsets {
64 | responseCharsetFallback = Charsets.GBK
65 | }
66 | BrowserUserAgent()
67 | ContentEncoding()
68 | defaultRequest {
69 | header("x-xsrf-token", xsrf?.value)
70 | }
71 | expectSuccess = true
72 | }
73 |
74 | public companion object {
75 | public val Json: Json = Json {
76 | prettyPrint = true
77 | ignoreUnknownKeys = true
78 | isLenient = true
79 | }
80 |
81 | public val DefaultIgnore: suspend (Throwable) -> Boolean = { it is IOException }
82 | }
83 |
84 | protected open val max: Int = 32
85 |
86 | public suspend fun useHttpClient(block: suspend (HttpClient) -> T): T = supervisorScope {
87 | var count = 0
88 | var cause: Throwable? = null
89 | while (isActive) {
90 | try {
91 | return@supervisorScope block(client)
92 | } catch (throwable: Throwable) {
93 | cause = throwable
94 | count++
95 | if (count > max || ignore(throwable).not()) throw throwable
96 | }
97 | }
98 | throw CancellationException(null, cause)
99 | }
100 | }
--------------------------------------------------------------------------------
/src/main/kotlin/xyz/cssxsh/weibo/api/Api.kt:
--------------------------------------------------------------------------------
1 | package xyz.cssxsh.weibo.api
2 |
3 | public const val INDEX_PAGE: String = "https://weibo.com"
4 | public const val LOGIN_PAGE: String = "https://weibo.com/login.php"
5 |
6 | public const val PAGE_SIZE: Int = 20
7 |
8 | // STATUSES
9 | public const val STATUSES_CONFIG: String = "https://weibo.com/ajax/statuses/config"
10 | public const val STATUSES_MY_MICRO_BLOG: String = "https://weibo.com/ajax/statuses/mymblog"
11 | public const val STATUSES_SHOW: String = "https://weibo.com/ajax/statuses/show"
12 | public const val STATUSES_LONGTEXT: String = "https://weibo.com/ajax/statuses/longtext"
13 | public const val STATUSES_MENTIONS: String = "https://weibo.com/ajax/statuses/mentions"
14 | public const val STATUSES_REPOST: String = "https://weibo.com/ajax/statuses/repostTimeline"
15 | public const val STATUSES_LIKE_LIST: String = "https://weibo.com/ajax/statuses/likelist"
16 | public const val STATUSES_LIKE_SHOW: String = "https://weibo.com/ajax/statuses/likeShow"
17 | public const val STATUSES_SET_LIKE: String = "https://weibo.com/ajax/statuses/setLike"
18 | public const val STATUSES_CREATE_FAVORITES: String = "https://weibo.com/ajax/statuses/createFavorites"
19 | public const val STATUSES_FAVORITES: String = "https://weibo.com/ajax/favorites/all_fav"
20 | public const val SEARCH_ALL: String = "https://weibo.com/ajax/search/all"
21 |
22 | // COMMENTS
23 | public const val COMMENTS_MENTIONS: String = "https://weibo.com/ajax/comments/mentions"
24 |
25 | // MESSAGE
26 | public const val MESSAGE_CMT: String = "https://weibo.com/ajax/message/cmt"
27 | public const val MESSAGE_ATTITUDES: String = "https://weibo.com/ajax/message/attitudes"
28 | public const val MESSAGE_WHITELIST: String = "https://weibo.com/ajax/message/whitelist"
29 |
30 | // FEED
31 | public const val FEED_ALL_GROUPS: String = "https://weibo.com/ajax/feed/allGroups"
32 | public const val FEED_UNREAD_FRIENDS_TIMELINE: String = "https://weibo.com/ajax/feed/unreadfriendstimeline"
33 | public const val FEED_FRIENDS_TIMELINE: String = "https://weibo.com/ajax/feed/friendstimeline"
34 | public const val FEED_GROUPS_TIMELINE: String = "https://weibo.com/ajax/feed/groupstimeline"
35 | public const val FEED_HOT_TIMELINE: String = "https://weibo.com/ajax/feed/hottimeline"
36 |
37 | // FRIENDSHIPS
38 | public const val FRIENDSHIPS_FRIENDS: String = "https://weibo.com/ajax/friendships/friends"
39 | public const val FRIENDSHIPS_LIST: String = "https://weibo.com/ajax/friendships/friends"
40 | public const val FRIENDSHIPS_CREATE: String = "https://weibo.com/ajax/friendships/create"
41 | public const val FRIENDSHIPS_DESTROY: String = "https://weibo.com/ajax/friendships/destory"
42 |
43 | // PROFILE
44 | public const val PROFILE_INFO: String = "https://weibo.com/ajax/profile/info"
45 | public const val PROFILE_DETAIL: String = "https://weibo.com/ajax/profile/detail"
46 | public const val PROFILE_HISTORY: String = "https://weibo.com/ajax/profile/mbloghistory"
47 | public const val PROFILE_MY_HOT: String = "https://weibo.com/ajax/profile/myhot"
48 | public const val PROFILE_SEARCH: String = "https://weibo.com/ajax/profile/searchblog"
49 | public const val PROFILE_FEATURE_DETAIL: String = "https://weibo.com/ajax/profile/featuredetail"
50 | public const val PROFILE_VIDEO: String = "https://weibo.com/ajax/profile/getprofilevideolist"
51 | public const val PROFILE_TINY_VIDEO: String = "https://weibo.com/ajax/profile/gettinyvideo"
52 | public const val PROFILE_IMAGE: String = "https://weibo.com/ajax/profile/getImageWall"
53 | public const val PROFILE_TAB_LIST: String = "https://weibo.com/ajax/profile/tablist"
54 | public const val PROFILE_GROUP_MEMBERS: String = "https://weibo.com/ajax/profile/getGroupMembers"
55 |
56 | public const val PROFILE_FOLLOW_CONTENT: String = "https://weibo.com/ajax/profile/followContent"
57 | public const val PROFILE_TOPIC_CONTENT: String = "https://weibo.com/ajax/profile/topicContent"
58 | public const val PROFILE_GROUP_LIST: String = "https://weibo.com/ajax/profile/getGroupList"
59 | public const val PROFILE_GROUP_SET: String = "https://weibo.com/ajax/profile/setGroup"
60 |
61 | public const val PROFILE_GROUPS: String = "https://weibo.com/ajax/profile/getGroups"
62 |
63 | // LOGIN
64 | public const val CROSS_DOMAIN: String = "https://login.sina.com.cn/crossdomain2.php"
65 | public const val WEIBO_SSO_LOGIN: String = "https://passport.weibo.com/wbsso/login"
66 | public const val SSO_LOGIN: String = "https://login.sina.com.cn/sso/login.php"
67 | public const val SSO_QRCODE_IMAGE: String = "https://login.sina.com.cn/sso/qrcode/image"
68 | public const val SSO_QRCODE_CHECK: String = "https://login.sina.com.cn/sso/qrcode/check"
69 | public const val PASSPORT_VISITOR: String = "https://passport.weibo.com/visitor/visitor"
70 | public const val PASSPORT_GEN_VISITOR: String = "https://passport.weibo.com/visitor/genvisitor"
71 |
72 | // SUPER CHAT
73 | public const val SUPER_CHAT_LIST: String = "https://www.weibo.com/p/aj/v6/mblog/mbloglist"
--------------------------------------------------------------------------------
/src/main/kotlin/xyz/cssxsh/weibo/api/Feed.kt:
--------------------------------------------------------------------------------
1 | package xyz.cssxsh.weibo.api
2 |
3 | import io.ktor.client.request.*
4 | import io.ktor.http.*
5 | import xyz.cssxsh.weibo.*
6 | import xyz.cssxsh.weibo.data.*
7 |
8 | public suspend fun WeiboClient.getFeedGroups(
9 | isNewSegment: Boolean = true,
10 | fetchHot: Boolean = true
11 | ): UserGroupData = json(FEED_ALL_GROUPS) {
12 | header(HttpHeaders.Referrer, INDEX_PAGE)
13 |
14 | parameter("is_new_segment", isNewSegment.toInt())
15 | parameter("fetch_hot", fetchHot.toInt())
16 | }
17 |
18 | public suspend fun WeiboClient.getGroupsTimeline(
19 | gid: Long,
20 | count: Int = PAGE_SIZE,
21 | refresh: Boolean = true,
22 | since: Long? = null,
23 | max: Long? = null,
24 | fast: Boolean? = null
25 | ): TimelineData = json(FEED_GROUPS_TIMELINE) {
26 | header(HttpHeaders.Referrer, "https://weibo.com/mygroups?gid=$gid")
27 |
28 | parameter("list_id", gid)
29 | parameter("since_id", since)
30 | parameter("max_id", max)
31 | parameter("refresh", refresh.toInt())
32 | parameter("fast_refresh", fast)
33 | parameter("count", count)
34 | }
35 |
36 | public suspend fun WeiboClient.getUnreadTimeline(
37 | gid: Long,
38 | count: Int = PAGE_SIZE,
39 | refresh: Boolean = true,
40 | since: Long? = null,
41 | max: Long? = null,
42 | fast: Boolean? = null
43 | ): TimelineData = json(FEED_UNREAD_FRIENDS_TIMELINE) {
44 | header(HttpHeaders.Referrer, "https://weibo.com/mygroups?gid=$gid")
45 |
46 | parameter("list_id", gid)
47 | parameter("since_id", since)
48 | parameter("max_id", max)
49 | parameter("refresh", refresh.toInt())
50 | parameter("fast_refresh", fast?.toInt())
51 | parameter("count", count)
52 | }
53 |
54 | public suspend fun WeiboClient.getFriendsTimeline(
55 | gid: Long,
56 | count: Int = PAGE_SIZE,
57 | refresh: Boolean = true,
58 | since: Long? = null,
59 | max: Long? = null,
60 | fast: Boolean? = null
61 | ): TimelineData = json(FEED_FRIENDS_TIMELINE) {
62 | header(HttpHeaders.Referrer, "https://weibo.com/mygroups?gid=$gid")
63 |
64 | parameter("list_id", gid)
65 | parameter("since_id", since)
66 | parameter("max_id", max)
67 | parameter("refresh", refresh.toInt())
68 | parameter("fast_refresh", fast?.toInt())
69 | parameter("count", count)
70 | }
71 |
72 | public suspend fun WeiboClient.getHotTimeline(
73 | gid: Long,
74 | max: Long? = null,
75 | extend: List = listOf("discover", "new_feed"),
76 | count: Int = PAGE_SIZE,
77 | refresh: Boolean = false
78 | ): TimelineData = json(FEED_HOT_TIMELINE) {
79 | header(HttpHeaders.Referrer, "https://weibo.com/hot/list/$gid")
80 |
81 | parameter("group_id", gid)
82 | parameter("containerid", gid)
83 | parameter("max_id", max)
84 | parameter("extparam", extend.joinToString("|"))
85 | parameter("count", count)
86 | parameter("refresh", refresh.toInt())
87 | }
88 |
89 | public suspend fun WeiboClient.getTimeline(group: UserGroup): TimelineData = when (group.type) {
90 | UserGroupType.USER, UserGroupType.QUIETLY -> {
91 | getGroupsTimeline(group.gid)
92 | }
93 | UserGroupType.ALL -> {
94 | getUnreadTimeline(group.gid)
95 | }
96 | UserGroupType.FILTER, UserGroupType.MUTUAL, UserGroupType.GROUP -> {
97 | getFriendsTimeline(group.gid)
98 | }
99 | UserGroupType.SYSTEM -> {
100 | getHotTimeline(group.gid)
101 | }
102 | }
--------------------------------------------------------------------------------
/src/main/kotlin/xyz/cssxsh/weibo/api/Login.kt:
--------------------------------------------------------------------------------
1 | package xyz.cssxsh.weibo.api
2 |
3 | import io.ktor.client.request.*
4 | import io.ktor.http.*
5 | import kotlinx.coroutines.*
6 | import kotlinx.serialization.json.*
7 | import xyz.cssxsh.weibo.*
8 | import xyz.cssxsh.weibo.data.*
9 | import java.lang.*
10 |
11 | public const val SUCCESS_CODE: Int = 20000000
12 |
13 | public const val NO_USE_CODE: Int = 50114001
14 |
15 | public const val USED_CODE: Int = 50114002
16 |
17 | public const val QRCODE_SIZE: Int = 180
18 |
19 | public const val CheckDelay: Long = 3 * 1000L
20 |
21 | public suspend inline fun WeiboClient.data(
22 | url: String,
23 | crossinline block: HttpRequestBuilder.() -> Unit
24 | ): T {
25 | return with(callback(url, block)) {
26 | check(code == SUCCESS_CODE) { toString() }
27 | WeiboClient.Json.decodeFromJsonElement(data)
28 | }
29 | }
30 |
31 | private fun location(html: String): String? {
32 | return html.substringAfter("location.replace(").substringBeforeLast(");")
33 | .removeSurrounding("'").removeSurrounding("\"")
34 | .takeIf { it.startsWith("http") }
35 | }
36 |
37 | private suspend fun WeiboClient.login(urls: List): LoginResult {
38 | val result = callback(urls.first { it.startsWith(WEIBO_SSO_LOGIN) }) {
39 | header(HttpHeaders.Host, url.host)
40 | header(HttpHeaders.Referrer, INDEX_PAGE)
41 |
42 | parameter("action", "login")
43 | parameter("callback", "STK_${System.currentTimeMillis()}")
44 | }
45 |
46 | info = result.info
47 |
48 | return result
49 | }
50 |
51 | public suspend fun WeiboClient.qrcode(send: suspend (qrcode: String) -> Unit): LoginResult {
52 | // Set Cookie
53 | download(PASSPORT_VISITOR)
54 |
55 | val qrcode = data(SSO_QRCODE_IMAGE) {
56 | header(HttpHeaders.Host, url.host)
57 | header(HttpHeaders.Referrer, INDEX_PAGE)
58 |
59 | parameter("entry", "sinawap")
60 | parameter("size", QRCODE_SIZE)
61 | parameter("callback", "STK_${System.currentTimeMillis()}")
62 | }
63 |
64 | send(qrcode.image)
65 |
66 | val token: LoginToken = supervisorScope {
67 | while (isActive) {
68 | val json = callback(SSO_QRCODE_CHECK) {
69 | header(HttpHeaders.Host, url.host)
70 | header(HttpHeaders.Referrer, INDEX_PAGE)
71 |
72 | parameter("entry", "sinawap")
73 | parameter("qrid", qrcode.id)
74 | parameter("callback", "STK_${System.currentTimeMillis()}")
75 | }
76 | // println(json)
77 | when (json.code) {
78 | SUCCESS_CODE -> {
79 | return@supervisorScope WeiboClient.Json.decodeFromJsonElement(json.data)
80 | }
81 | NO_USE_CODE, USED_CODE -> {
82 | delay(CheckDelay)
83 | }
84 | else -> {
85 | throw IllegalStateException(json.message)
86 | }
87 | }
88 | }
89 | throw CancellationException()
90 | }
91 |
92 | val flush = callback(SSO_LOGIN) {
93 | header(HttpHeaders.Host, url.host)
94 | header(HttpHeaders.Referrer, INDEX_PAGE)
95 |
96 | parameter("entry", "weibo")
97 | parameter("returntype", "TEXT")
98 | parameter("crossdomain", 1)
99 | parameter("cdult", 3)
100 | parameter("domain", "weibo.com")
101 | parameter("alt", token.alt)
102 | parameter("savestate", token.state)
103 | parameter("callback", "STK_${System.currentTimeMillis()}")
104 | }
105 |
106 | return login(urls = flush.urls)
107 | }
108 |
109 | public suspend fun WeiboClient.restore(): LoginResult {
110 | // Set Cookie
111 | withTimeoutOrNull(10_000) {
112 | var location: String? = INDEX_PAGE
113 | while (isActive && location != null) {
114 | location = location(text(location) {})
115 | }
116 | }
117 |
118 | checkNotNull(srf) { "SRF Cookie 为空" }
119 |
120 | val token = data(PASSPORT_VISITOR) {
121 | header(HttpHeaders.Host, url.host)
122 | header(HttpHeaders.Referrer, PASSPORT_VISITOR)
123 |
124 | parameter("a", "restore")
125 | parameter("cb", "restore_back")
126 | parameter("from", "weibo")
127 | parameter("_rand", System.currentTimeMillis())
128 | }
129 |
130 | val html = text(SSO_LOGIN) {
131 | header(HttpHeaders.Host, url.host)
132 | header(HttpHeaders.Referrer, INDEX_PAGE)
133 |
134 | parameter("entry", "sso")
135 | parameter("returntype", "META")
136 | parameter("gateway", 1)
137 | parameter("alt", token.alt)
138 | parameter("savestate", token.state)
139 | }
140 |
141 | check(location(html).orEmpty().startsWith(CROSS_DOMAIN)) { "CROSS DOMAIN 跳转异常" }
142 |
143 | val flush = callback(CROSS_DOMAIN) {
144 | header(HttpHeaders.Host, url.host)
145 | header(HttpHeaders.Referrer, INDEX_PAGE)
146 |
147 | parameter("action", "login")
148 | parameter("entry", "sso")
149 | parameter("r", INDEX_PAGE)
150 | }
151 |
152 | return login(urls = flush.urls)
153 | }
154 |
155 | public suspend fun WeiboClient.incarnate(): Int {
156 | val visitor = data(PASSPORT_GEN_VISITOR) {
157 | header(HttpHeaders.Host, url.host)
158 | header(HttpHeaders.Referrer, PASSPORT_VISITOR)
159 |
160 | parameter("cb", "restore_back")
161 | parameter("from", "weibo")
162 | parameter("_rand", System.currentTimeMillis())
163 | }
164 |
165 | val recover = if (visitor.new) 3 else 2
166 | val cookies = data