mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-05 12:34:35 +08:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97c74859c0 | ||
|
|
4d5c6459fe | ||
|
|
c835d5c570 | ||
|
|
59872daf52 | ||
|
|
3bd3935042 | ||
|
|
77a6912ade | ||
|
|
4b83aaed61 | ||
|
|
7268188e40 | ||
|
|
069d836dea | ||
|
|
95384a4a92 | ||
|
|
f5633a29cb | ||
|
|
766cd94788 | ||
|
|
b6d287a299 | ||
|
|
4c25236070 |
+2
-1
@@ -10,7 +10,8 @@ out
|
|||||||
coverage/
|
coverage/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
test-results/
|
test-results/
|
||||||
resources/connectors/wechat-personal/
|
resources/runtime/darwin-arm64
|
||||||
|
.native-runtime-source
|
||||||
.omc
|
.omc
|
||||||
.codex/
|
.codex/
|
||||||
skills-lock.json
|
skills-lock.json
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ Agent Hub 让微信机器人调用本机 TraceMemo;Reader Skill / Local HTTP A
|
|||||||
- [实验性:自托管微信分享卡片](./deployment/experimental-wechat-share-card.md)
|
- [实验性:自托管微信分享卡片](./deployment/experimental-wechat-share-card.md)
|
||||||
- [微信分享卡片自动部署 Skill](./skill/setup-wechat-share-card/SKILL.md)
|
- [微信分享卡片自动部署 Skill](./skill/setup-wechat-share-card/SKILL.md)
|
||||||
- [TraceMemo Reader Skill 文件](./skill/tracememo-reader/SKILL.md)
|
- [TraceMemo Reader Skill 文件](./skill/tracememo-reader/SKILL.md)
|
||||||
- [第三方组件说明](./third-party/wechat-chatter/NOTICE.md)
|
|
||||||
|
|
||||||
## 版本说明
|
## 版本说明
|
||||||
|
|
||||||
|
|||||||
-674
@@ -1,674 +0,0 @@
|
|||||||
GNU GENERAL PUBLIC LICENSE
|
|
||||||
Version 3, 29 June 2007
|
|
||||||
|
|
||||||
Copyright (C) 2026 yincongcyincong
|
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
|
||||||
of this license document, but changing it is not allowed.
|
|
||||||
|
|
||||||
Preamble
|
|
||||||
|
|
||||||
The GNU General Public License is a free, copyleft license for
|
|
||||||
software and other kinds of works.
|
|
||||||
|
|
||||||
The licenses for most software and other practical works are designed
|
|
||||||
to take away your freedom to share and change the works. By contrast,
|
|
||||||
the GNU General Public License is intended to guarantee your freedom to
|
|
||||||
share and change all versions of a program--to make sure it remains free
|
|
||||||
software for all its users. We, the Free Software Foundation, use the
|
|
||||||
GNU General Public License for most of our software; it applies also to
|
|
||||||
any other work released this way by its authors. You can apply it to
|
|
||||||
your programs, too.
|
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not
|
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
|
||||||
have the freedom to distribute copies of free software (and charge for
|
|
||||||
them if you wish), that you receive source code or can get it if you
|
|
||||||
want it, that you can change the software or use pieces of it in new
|
|
||||||
free programs, and that you know you can do these things.
|
|
||||||
|
|
||||||
To protect your rights, we need to prevent others from denying you
|
|
||||||
these rights or asking you to surrender the rights. Therefore, you have
|
|
||||||
certain responsibilities if you distribute copies of the software, or if
|
|
||||||
you modify it: responsibilities to respect the freedom of others.
|
|
||||||
|
|
||||||
For example, if you distribute copies of such a program, whether
|
|
||||||
gratis or for a fee, you must pass on to the recipients the same
|
|
||||||
freedoms that you received. You must make sure that they, too, receive
|
|
||||||
or can get the source code. And you must show them these terms so they
|
|
||||||
know their rights.
|
|
||||||
|
|
||||||
Developers that use the GNU GPL protect your rights with two steps:
|
|
||||||
(1) assert copyright on the software, and (2) offer you this License
|
|
||||||
giving you legal permission to copy, distribute and/or modify it.
|
|
||||||
|
|
||||||
For the developers' and authors' protection, the GPL clearly explains
|
|
||||||
that there is no warranty for this free software. For both users' and
|
|
||||||
authors' sake, the GPL requires that modified versions be marked as
|
|
||||||
changed, so that their problems will not be attributed erroneously to
|
|
||||||
authors of previous versions.
|
|
||||||
|
|
||||||
Some devices are designed to deny users access to install or run
|
|
||||||
modified versions of the software inside them, although the manufacturer
|
|
||||||
can do so. This is fundamentally incompatible with the aim of
|
|
||||||
protecting users' freedom to change the software. The systematic
|
|
||||||
pattern of such abuse occurs in the area of products for individuals to
|
|
||||||
use, which is precisely where it is most unacceptable. Therefore, we
|
|
||||||
have designed this version of the GPL to prohibit the practice for those
|
|
||||||
products. If such problems arise substantially in other domains, we
|
|
||||||
stand ready to extend this provision to those domains in future versions
|
|
||||||
of the GPL, as needed to protect the freedom of users.
|
|
||||||
|
|
||||||
Finally, every program is threatened constantly by software patents.
|
|
||||||
States should not allow patents to restrict development and use of
|
|
||||||
software on general-purpose computers, but in those that do, we wish to
|
|
||||||
avoid the special danger that patents applied to a free program could
|
|
||||||
make it effectively proprietary. To prevent this, the GPL assures that
|
|
||||||
patents cannot be used to render the program non-free.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
|
||||||
modification follow.
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
0. Definitions.
|
|
||||||
|
|
||||||
"This License" refers to version 3 of the GNU General Public License.
|
|
||||||
|
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
|
||||||
works, such as semiconductor masks.
|
|
||||||
|
|
||||||
"The Program" refers to any copyrightable work licensed under this
|
|
||||||
License. Each licensee is addressed as "you". "Licensees" and
|
|
||||||
"recipients" may be individuals or organizations.
|
|
||||||
|
|
||||||
To "modify" a work means to copy from or adapt all or part of the work
|
|
||||||
in a fashion requiring copyright permission, other than the making of an
|
|
||||||
exact copy. The resulting work is called a "modified version" of the
|
|
||||||
earlier work or a work "based on" the earlier work.
|
|
||||||
|
|
||||||
A "covered work" means either the unmodified Program or a work based
|
|
||||||
on the Program.
|
|
||||||
|
|
||||||
To "propagate" a work means to do anything with it that, without
|
|
||||||
permission, would make you directly or secondarily liable for
|
|
||||||
infringement under applicable copyright law, except executing it on a
|
|
||||||
computer or modifying a private copy. Propagation includes copying,
|
|
||||||
distribution (with or without modification), making available to the
|
|
||||||
public, and in some countries other activities as well.
|
|
||||||
|
|
||||||
To "convey" a work means any kind of propagation that enables other
|
|
||||||
parties to make or receive copies. Mere interaction with a user through
|
|
||||||
a computer network, with no transfer of a copy, is not conveying.
|
|
||||||
|
|
||||||
An interactive user interface displays "Appropriate Legal Notices"
|
|
||||||
to the extent that it includes a convenient and prominently visible
|
|
||||||
feature that (1) displays an appropriate copyright notice, and (2)
|
|
||||||
tells the user that there is no warranty for the work (except to the
|
|
||||||
extent that warranties are provided), that licensees may convey the
|
|
||||||
work under this License, and how to view a copy of this License. If
|
|
||||||
the interface presents a list of user commands or options, such as a
|
|
||||||
menu, a prominent item in the list meets this criterion.
|
|
||||||
|
|
||||||
1. Source Code.
|
|
||||||
|
|
||||||
The "source code" for a work means the preferred form of the work
|
|
||||||
for making modifications to it. "Object code" means any non-source
|
|
||||||
form of a work.
|
|
||||||
|
|
||||||
A "Standard Interface" means an interface that either is an official
|
|
||||||
standard defined by a recognized standards body, or, in the case of
|
|
||||||
interfaces specified for a particular programming language, one that
|
|
||||||
is widely used among developers working in that language.
|
|
||||||
|
|
||||||
The "System Libraries" of an executable work include anything, other
|
|
||||||
than the work as a whole, that (a) is included in the normal form of
|
|
||||||
packaging a Major Component, but which is not part of that Major
|
|
||||||
Component, and (b) serves only to enable use of the work with that
|
|
||||||
Major Component, or to implement a Standard Interface for which an
|
|
||||||
implementation is available to the public in source code form. A
|
|
||||||
"Major Component", in this context, means a major essential component
|
|
||||||
(kernel, window system, and so on) of the specific operating system
|
|
||||||
(if any) on which the executable work runs, or a compiler used to
|
|
||||||
produce the work, or an object code interpreter used to run it.
|
|
||||||
|
|
||||||
The "Corresponding Source" for a work in object code form means all
|
|
||||||
the source code needed to generate, install, and (for an executable
|
|
||||||
work) run the object code and to modify the work, including scripts to
|
|
||||||
control those activities. However, it does not include the work's
|
|
||||||
System Libraries, or general-purpose tools or generally available free
|
|
||||||
programs which are used unmodified in performing those activities but
|
|
||||||
which are not part of the work. For example, Corresponding Source
|
|
||||||
includes interface definition files associated with source files for
|
|
||||||
the work, and the source code for shared libraries and dynamically
|
|
||||||
linked subprograms that the work is specifically designed to require,
|
|
||||||
such as by intimate data communication or control flow between those
|
|
||||||
subprograms and other parts of the work.
|
|
||||||
|
|
||||||
The Corresponding Source need not include anything that users
|
|
||||||
can regenerate automatically from other parts of the Corresponding
|
|
||||||
Source.
|
|
||||||
|
|
||||||
The Corresponding Source for a work in source code form is that
|
|
||||||
same work.
|
|
||||||
|
|
||||||
2. Basic Permissions.
|
|
||||||
|
|
||||||
All rights granted under this License are granted for the term of
|
|
||||||
copyright on the Program, and are irrevocable provided the stated
|
|
||||||
conditions are met. This License explicitly affirms your unlimited
|
|
||||||
permission to run the unmodified Program. The output from running a
|
|
||||||
covered work is covered by this License only if the output, given its
|
|
||||||
content, constitutes a covered work. This License acknowledges your
|
|
||||||
rights of fair use or other equivalent, as provided by copyright law.
|
|
||||||
|
|
||||||
You may make, run and propagate covered works that you do not
|
|
||||||
convey, without conditions so long as your license otherwise remains
|
|
||||||
in force. You may convey covered works to others for the sole purpose
|
|
||||||
of having them make modifications exclusively for you, or provide you
|
|
||||||
with facilities for running those works, provided that you comply with
|
|
||||||
the terms of this License in conveying all material for which you do
|
|
||||||
not control copyright. Those thus making or running the covered works
|
|
||||||
for you must do so exclusively on your behalf, under your direction
|
|
||||||
and control, on terms that prohibit them from making any copies of
|
|
||||||
your copyrighted material outside their relationship with you.
|
|
||||||
|
|
||||||
Conveying under any other circumstances is permitted solely under
|
|
||||||
the conditions stated below. Sublicensing is not allowed; section 10
|
|
||||||
makes it unnecessary.
|
|
||||||
|
|
||||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
|
||||||
|
|
||||||
No covered work shall be deemed part of an effective technological
|
|
||||||
measure under any applicable law fulfilling obligations under article
|
|
||||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
|
||||||
similar laws prohibiting or restricting circumvention of such
|
|
||||||
measures.
|
|
||||||
|
|
||||||
When you convey a covered work, you waive any legal power to forbid
|
|
||||||
circumvention of technological measures to the extent such circumvention
|
|
||||||
is effected by exercising rights under this License with respect to
|
|
||||||
the covered work, and you disclaim any intention to limit operation or
|
|
||||||
modification of the work as a means of enforcing, against the work's
|
|
||||||
users, your or third parties' legal rights to forbid circumvention of
|
|
||||||
technological measures.
|
|
||||||
|
|
||||||
4. Conveying Verbatim Copies.
|
|
||||||
|
|
||||||
You may convey verbatim copies of the Program's source code as you
|
|
||||||
receive it, in any medium, provided that you conspicuously and
|
|
||||||
appropriately publish on each copy an appropriate copyright notice;
|
|
||||||
keep intact all notices stating that this License and any
|
|
||||||
non-permissive terms added in accord with section 7 apply to the code;
|
|
||||||
keep intact all notices of the absence of any warranty; and give all
|
|
||||||
recipients a copy of this License along with the Program.
|
|
||||||
|
|
||||||
You may charge any price or no price for each copy that you convey,
|
|
||||||
and you may offer support or warranty protection for a fee.
|
|
||||||
|
|
||||||
5. Conveying Modified Source Versions.
|
|
||||||
|
|
||||||
You may convey a work based on the Program, or the modifications to
|
|
||||||
produce it from the Program, in the form of source code under the
|
|
||||||
terms of section 4, provided that you also meet all of these conditions:
|
|
||||||
|
|
||||||
a) The work must carry prominent notices stating that you modified
|
|
||||||
it, and giving a relevant date.
|
|
||||||
|
|
||||||
b) The work must carry prominent notices stating that it is
|
|
||||||
released under this License and any conditions added under section
|
|
||||||
7. This requirement modifies the requirement in section 4 to
|
|
||||||
"keep intact all notices".
|
|
||||||
|
|
||||||
c) You must license the entire work, as a whole, under this
|
|
||||||
License to anyone who comes into possession of a copy. This
|
|
||||||
License will therefore apply, along with any applicable section 7
|
|
||||||
additional terms, to the whole of the work, and all its parts,
|
|
||||||
regardless of how they are packaged. This License gives no
|
|
||||||
permission to license the work in any other way, but it does not
|
|
||||||
invalidate such permission if you have separately received it.
|
|
||||||
|
|
||||||
d) If the work has interactive user interfaces, each must display
|
|
||||||
Appropriate Legal Notices; however, if the Program has interactive
|
|
||||||
interfaces that do not display Appropriate Legal Notices, your
|
|
||||||
work need not make them do so.
|
|
||||||
|
|
||||||
A compilation of a covered work with other separate and independent
|
|
||||||
works, which are not by their nature extensions of the covered work,
|
|
||||||
and which are not combined with it such as to form a larger program,
|
|
||||||
in or on a volume of a storage or distribution medium, is called an
|
|
||||||
"aggregate" if the compilation and its resulting copyright are not
|
|
||||||
used to limit the access or legal rights of the compilation's users
|
|
||||||
beyond what the individual works permit. Inclusion of a covered work
|
|
||||||
in an aggregate does not cause this License to apply to the other
|
|
||||||
parts of the aggregate.
|
|
||||||
|
|
||||||
6. Conveying Non-Source Forms.
|
|
||||||
|
|
||||||
You may convey a covered work in object code form under the terms
|
|
||||||
of sections 4 and 5, provided that you also convey the
|
|
||||||
machine-readable Corresponding Source under the terms of this License,
|
|
||||||
in one of these ways:
|
|
||||||
|
|
||||||
a) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by the
|
|
||||||
Corresponding Source fixed on a durable physical medium
|
|
||||||
customarily used for software interchange.
|
|
||||||
|
|
||||||
b) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by a
|
|
||||||
written offer, valid for at least three years and valid for as
|
|
||||||
long as you offer spare parts or customer support for that product
|
|
||||||
model, to give anyone who possesses the object code either (1) a
|
|
||||||
copy of the Corresponding Source for all the software in the
|
|
||||||
product that is covered by this License, on a durable physical
|
|
||||||
medium customarily used for software interchange, for a price no
|
|
||||||
more than your reasonable cost of physically performing this
|
|
||||||
conveying of source, or (2) access to copy the
|
|
||||||
Corresponding Source from a network server at no charge.
|
|
||||||
|
|
||||||
c) Convey individual copies of the object code with a copy of the
|
|
||||||
written offer to provide the Corresponding Source. This
|
|
||||||
alternative is allowed only occasionally and noncommercially, and
|
|
||||||
only if you received the object code with such an offer, in accord
|
|
||||||
with subsection 6b.
|
|
||||||
|
|
||||||
d) Convey the object code by offering access from a designated
|
|
||||||
place (gratis or for a charge), and offer equivalent access to the
|
|
||||||
Corresponding Source in the same way through the same place at no
|
|
||||||
further charge. You need not require recipients to copy the
|
|
||||||
Corresponding Source along with the object code. If the place to
|
|
||||||
copy the object code is a network server, the Corresponding Source
|
|
||||||
may be on a different server (operated by you or a third party)
|
|
||||||
that supports equivalent copying facilities, provided you maintain
|
|
||||||
clear directions next to the object code saying where to find the
|
|
||||||
Corresponding Source. Regardless of what server hosts the
|
|
||||||
Corresponding Source, you remain obligated to ensure that it is
|
|
||||||
available for as long as needed to satisfy these requirements.
|
|
||||||
|
|
||||||
e) Convey the object code using peer-to-peer transmission, provided
|
|
||||||
you inform other peers where the object code and Corresponding
|
|
||||||
Source of the work are being offered to the general public at no
|
|
||||||
charge under subsection 6d.
|
|
||||||
|
|
||||||
A separable portion of the object code, whose source code is excluded
|
|
||||||
from the Corresponding Source as a System Library, need not be
|
|
||||||
included in conveying the object code work.
|
|
||||||
|
|
||||||
A "User Product" is either (1) a "consumer product", which means any
|
|
||||||
tangible personal property which is normally used for personal, family,
|
|
||||||
or household purposes, or (2) anything designed or sold for incorporation
|
|
||||||
into a dwelling. In determining whether a product is a consumer product,
|
|
||||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
|
||||||
product received by a particular user, "normally used" refers to a
|
|
||||||
typical or common use of that class of product, regardless of the status
|
|
||||||
of the particular user or of the way in which the particular user
|
|
||||||
actually uses, or expects or is expected to use, the product. A product
|
|
||||||
is a consumer product regardless of whether the product has substantial
|
|
||||||
commercial, industrial or non-consumer uses, unless such uses represent
|
|
||||||
the only significant mode of use of the product.
|
|
||||||
|
|
||||||
"Installation Information" for a User Product means any methods,
|
|
||||||
procedures, authorization keys, or other information required to install
|
|
||||||
and execute modified versions of a covered work in that User Product from
|
|
||||||
a modified version of its Corresponding Source. The information must
|
|
||||||
suffice to ensure that the continued functioning of the modified object
|
|
||||||
code is in no case prevented or interfered with solely because
|
|
||||||
modification has been made.
|
|
||||||
|
|
||||||
If you convey an object code work under this section in, or with, or
|
|
||||||
specifically for use in, a User Product, and the conveying occurs as
|
|
||||||
part of a transaction in which the right of possession and use of the
|
|
||||||
User Product is transferred to the recipient in perpetuity or for a
|
|
||||||
fixed term (regardless of how the transaction is characterized), the
|
|
||||||
Corresponding Source conveyed under this section must be accompanied
|
|
||||||
by the Installation Information. But this requirement does not apply
|
|
||||||
if neither you nor any third party retains the ability to install
|
|
||||||
modified object code on the User Product (for example, the work has
|
|
||||||
been installed in ROM).
|
|
||||||
|
|
||||||
The requirement to provide Installation Information does not include a
|
|
||||||
requirement to continue to provide support service, warranty, or updates
|
|
||||||
for a work that has been modified or installed by the recipient, or for
|
|
||||||
the User Product in which it has been modified or installed. Access to a
|
|
||||||
network may be denied when the modification itself materially and
|
|
||||||
adversely affects the operation of the network or violates the rules and
|
|
||||||
protocols for communication across the network.
|
|
||||||
|
|
||||||
Corresponding Source conveyed, and Installation Information provided,
|
|
||||||
in accord with this section must be in a format that is publicly
|
|
||||||
documented (and with an implementation available to the public in
|
|
||||||
source code form), and must require no special password or key for
|
|
||||||
unpacking, reading or copying.
|
|
||||||
|
|
||||||
7. Additional Terms.
|
|
||||||
|
|
||||||
"Additional permissions" are terms that supplement the terms of this
|
|
||||||
License by making exceptions from one or more of its conditions.
|
|
||||||
Additional permissions that are applicable to the entire Program shall
|
|
||||||
be treated as though they were included in this License, to the extent
|
|
||||||
that they are valid under applicable law. If additional permissions
|
|
||||||
apply only to part of the Program, that part may be used separately
|
|
||||||
under those permissions, but the entire Program remains governed by
|
|
||||||
this License without regard to the additional permissions.
|
|
||||||
|
|
||||||
When you convey a copy of a covered work, you may at your option
|
|
||||||
remove any additional permissions from that copy, or from any part of
|
|
||||||
it. (Additional permissions may be written to require their own
|
|
||||||
removal in certain cases when you modify the work.) You may place
|
|
||||||
additional permissions on material, added by you to a covered work,
|
|
||||||
for which you have or can give appropriate copyright permission.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, for material you
|
|
||||||
add to a covered work, you may (if authorized by the copyright holders of
|
|
||||||
that material) supplement the terms of this License with terms:
|
|
||||||
|
|
||||||
a) Disclaiming warranty or limiting liability differently from the
|
|
||||||
terms of sections 15 and 16 of this License; or
|
|
||||||
|
|
||||||
b) Requiring preservation of specified reasonable legal notices or
|
|
||||||
author attributions in that material or in the Appropriate Legal
|
|
||||||
Notices displayed by works containing it; or
|
|
||||||
|
|
||||||
c) Prohibiting misrepresentation of the origin of that material, or
|
|
||||||
requiring that modified versions of such material be marked in
|
|
||||||
reasonable ways as different from the original version; or
|
|
||||||
|
|
||||||
d) Limiting the use for publicity purposes of names of licensors or
|
|
||||||
authors of the material; or
|
|
||||||
|
|
||||||
e) Declining to grant rights under trademark law for use of some
|
|
||||||
trade names, trademarks, or service marks; or
|
|
||||||
|
|
||||||
f) Requiring indemnification of licensors and authors of that
|
|
||||||
material by anyone who conveys the material (or modified versions of
|
|
||||||
it) with contractual assumptions of liability to the recipient, for
|
|
||||||
any liability that these contractual assumptions directly impose on
|
|
||||||
those licensors and authors.
|
|
||||||
|
|
||||||
All other non-permissive additional terms are considered "further
|
|
||||||
restrictions" within the meaning of section 10. If the Program as you
|
|
||||||
received it, or any part of it, contains a notice stating that it is
|
|
||||||
governed by this License along with a term that is a further
|
|
||||||
restriction, you may remove that term. If a license document contains
|
|
||||||
a further restriction but permits relicensing or conveying under this
|
|
||||||
License, you may add to a covered work material governed by the terms
|
|
||||||
of that license document, provided that the further restriction does
|
|
||||||
not survive such relicensing or conveying.
|
|
||||||
|
|
||||||
If you add terms to a covered work in accord with this section, you
|
|
||||||
must place, in the relevant source files, a statement of the
|
|
||||||
additional terms that apply to those files, or a notice indicating
|
|
||||||
where to find the applicable terms.
|
|
||||||
|
|
||||||
Additional terms, permissive or non-permissive, may be stated in the
|
|
||||||
form of a separately written license, or stated as exceptions;
|
|
||||||
the above requirements apply either way.
|
|
||||||
|
|
||||||
8. Termination.
|
|
||||||
|
|
||||||
You may not propagate or modify a covered work except as expressly
|
|
||||||
provided under this License. Any attempt otherwise to propagate or
|
|
||||||
modify it is void, and will automatically terminate your rights under
|
|
||||||
this License (including any patent licenses granted under the third
|
|
||||||
paragraph of section 11).
|
|
||||||
|
|
||||||
However, if you cease all violation of this License, then your
|
|
||||||
license from a particular copyright holder is reinstated (a)
|
|
||||||
provisionally, unless and until the copyright holder explicitly and
|
|
||||||
finally terminates your license, and (b) permanently, if the copyright
|
|
||||||
holder fails to notify you of the violation by some reasonable means
|
|
||||||
prior to 60 days after the cessation.
|
|
||||||
|
|
||||||
Moreover, your license from a particular copyright holder is
|
|
||||||
reinstated permanently if the copyright holder notifies you of the
|
|
||||||
violation by some reasonable means, this is the first time you have
|
|
||||||
received notice of violation of this License (for any work) from that
|
|
||||||
copyright holder, and you cure the violation prior to 30 days after
|
|
||||||
your receipt of the notice.
|
|
||||||
|
|
||||||
Termination of your rights under this section does not terminate the
|
|
||||||
licenses of parties who have received copies or rights from you under
|
|
||||||
this License. If your rights have been terminated and not permanently
|
|
||||||
reinstated, you do not qualify to receive new licenses for the same
|
|
||||||
material under section 10.
|
|
||||||
|
|
||||||
9. Acceptance Not Required for Having Copies.
|
|
||||||
|
|
||||||
You are not required to accept this License in order to receive or
|
|
||||||
run a copy of the Program. Ancillary propagation of a covered work
|
|
||||||
occurring solely as a consequence of using peer-to-peer transmission
|
|
||||||
to receive a copy likewise does not require acceptance. However,
|
|
||||||
nothing other than this License grants you permission to propagate or
|
|
||||||
modify any covered work. These actions infringe copyright if you do
|
|
||||||
not accept this License. Therefore, by modifying or propagating a
|
|
||||||
covered work, you indicate your acceptance of this License to do so.
|
|
||||||
|
|
||||||
10. Automatic Licensing of Downstream Recipients.
|
|
||||||
|
|
||||||
Each time you convey a covered work, the recipient automatically
|
|
||||||
receives a license from the original licensors, to run, modify and
|
|
||||||
propagate that work, subject to this License. You are not responsible
|
|
||||||
for enforcing compliance by third parties with this License.
|
|
||||||
|
|
||||||
An "entity transaction" is a transaction transferring control of an
|
|
||||||
organization, or substantially all assets of one, or subdividing an
|
|
||||||
organization, or merging organizations. If propagation of a covered
|
|
||||||
work results from an entity transaction, each party to that
|
|
||||||
transaction who receives a copy of the work also receives whatever
|
|
||||||
licenses to the work the party's predecessor in interest had or could
|
|
||||||
give under the previous paragraph, plus a right to possession of the
|
|
||||||
Corresponding Source of the work from the predecessor in interest, if
|
|
||||||
the predecessor has it or can get it with reasonable efforts.
|
|
||||||
|
|
||||||
You may not impose any further restrictions on the exercise of the
|
|
||||||
rights granted or affirmed under this License. For example, you may
|
|
||||||
not impose a license fee, royalty, or other charge for exercise of
|
|
||||||
rights granted under this License, and you may not initiate litigation
|
|
||||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
|
||||||
any patent claim is infringed by making, using, selling, offering for
|
|
||||||
sale, or importing the Program or any portion of it.
|
|
||||||
|
|
||||||
11. Patents.
|
|
||||||
|
|
||||||
A "contributor" is a copyright holder who authorizes use under this
|
|
||||||
License of the Program or a work on which the Program is based. The
|
|
||||||
work thus licensed is called the contributor's "contributor version".
|
|
||||||
|
|
||||||
A contributor's "essential patent claims" are all patent claims
|
|
||||||
owned or controlled by the contributor, whether already acquired or
|
|
||||||
hereafter acquired, that would be infringed by some manner, permitted
|
|
||||||
by this License, of making, using, or selling its contributor version,
|
|
||||||
but do not include claims that would be infringed only as a
|
|
||||||
consequence of further modification of the contributor version. For
|
|
||||||
purposes of this definition, "control" includes the right to grant
|
|
||||||
patent sublicenses in a manner consistent with the requirements of
|
|
||||||
this License.
|
|
||||||
|
|
||||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
|
||||||
patent license under the contributor's essential patent claims, to
|
|
||||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
|
||||||
propagate the contents of its contributor version.
|
|
||||||
|
|
||||||
In the following three paragraphs, a "patent license" is any express
|
|
||||||
agreement or commitment, however denominated, not to enforce a patent
|
|
||||||
(such as an express permission to practice a patent or covenant not to
|
|
||||||
sue for patent infringement). To "grant" such a patent license to a
|
|
||||||
party means to make such an agreement or commitment not to enforce a
|
|
||||||
patent against the party.
|
|
||||||
|
|
||||||
If you convey a covered work, knowingly relying on a patent license,
|
|
||||||
and the Corresponding Source of the work is not available for anyone
|
|
||||||
to copy, free of charge and under the terms of this License, through a
|
|
||||||
publicly available network server or other readily accessible means,
|
|
||||||
then you must either (1) cause the Corresponding Source to be so
|
|
||||||
available, or (2) arrange to deprive yourself of the benefit of the
|
|
||||||
patent license for this particular work, or (3) arrange, in a manner
|
|
||||||
consistent with the requirements of this License, to extend the patent
|
|
||||||
license to downstream recipients. "Knowingly relying" means you have
|
|
||||||
actual knowledge that, but for the patent license, your conveying the
|
|
||||||
covered work in a country, or your recipient's use of the covered work
|
|
||||||
in a country, would infringe one or more identifiable patents in that
|
|
||||||
country that you have reason to believe are valid.
|
|
||||||
|
|
||||||
If, pursuant to or in connection with a single transaction or
|
|
||||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
|
||||||
covered work, and grant a patent license to some of the parties
|
|
||||||
receiving the covered work authorizing them to use, propagate, modify
|
|
||||||
or convey a specific copy of the covered work, then the patent license
|
|
||||||
you grant is automatically extended to all recipients of the covered
|
|
||||||
work and works based on it.
|
|
||||||
|
|
||||||
A patent license is "discriminatory" if it does not include within
|
|
||||||
the scope of its coverage, prohibits the exercise of, or is
|
|
||||||
conditioned on the non-exercise of one or more of the rights that are
|
|
||||||
specifically granted under this License. You may not convey a covered
|
|
||||||
work if you are a party to an arrangement with a third party that is
|
|
||||||
in the business of distributing software, under which you make payment
|
|
||||||
to the third party based on the extent of your activity of conveying
|
|
||||||
the work, and under which the third party grants, to any of the
|
|
||||||
parties who would receive the covered work from you, a discriminatory
|
|
||||||
patent license (a) in connection with copies of the covered work
|
|
||||||
conveyed by you (or copies made from those copies), or (b) primarily
|
|
||||||
for and in connection with specific products or compilations that
|
|
||||||
contain the covered work, unless you entered into that arrangement,
|
|
||||||
or that patent license was granted, prior to 28 March 2007.
|
|
||||||
|
|
||||||
Nothing in this License shall be construed as excluding or limiting
|
|
||||||
any implied license or other defenses to infringement that may
|
|
||||||
otherwise be available to you under applicable patent law.
|
|
||||||
|
|
||||||
12. No Surrender of Others' Freedom.
|
|
||||||
|
|
||||||
If conditions are imposed on you (whether by court order, agreement or
|
|
||||||
otherwise) that contradict the conditions of this License, they do not
|
|
||||||
excuse you from the conditions of this License. If you cannot convey a
|
|
||||||
covered work so as to satisfy simultaneously your obligations under this
|
|
||||||
License and any other pertinent obligations, then as a consequence you may
|
|
||||||
not convey it at all. For example, if you agree to terms that obligate you
|
|
||||||
to collect a royalty for further conveying from those to whom you convey
|
|
||||||
the Program, the only way you could satisfy both those terms and this
|
|
||||||
License would be to refrain entirely from conveying the Program.
|
|
||||||
|
|
||||||
13. Use with the GNU Affero General Public License.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have
|
|
||||||
permission to link or combine any covered work with a work licensed
|
|
||||||
under version 3 of the GNU Affero General Public License into a single
|
|
||||||
combined work, and to convey the resulting work. The terms of this
|
|
||||||
License will continue to apply to the part which is the covered work,
|
|
||||||
but the special requirements of the GNU Affero General Public License,
|
|
||||||
section 13, concerning interaction through a network will apply to the
|
|
||||||
combination as such.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
|
||||||
the GNU General Public License from time to time. Such new versions will
|
|
||||||
be similar in spirit to the present version, but may differ in detail to
|
|
||||||
address new problems or concerns.
|
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
|
||||||
Program specifies that a certain numbered version of the GNU General
|
|
||||||
Public License "or any later version" applies to it, you have the
|
|
||||||
option of following the terms and conditions either of that numbered
|
|
||||||
version or of any later version published by the Free Software
|
|
||||||
Foundation. If the Program does not specify a version number of the
|
|
||||||
GNU General Public License, you may choose any version ever published
|
|
||||||
by the Free Software Foundation.
|
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
|
||||||
versions of the GNU General Public License can be used, that proxy's
|
|
||||||
public statement of acceptance of a version permanently authorizes you
|
|
||||||
to choose that version for the Program.
|
|
||||||
|
|
||||||
Later license versions may give you additional or different
|
|
||||||
permissions. However, no additional obligations are imposed on any
|
|
||||||
author or copyright holder as a result of your choosing to follow a
|
|
||||||
later version.
|
|
||||||
|
|
||||||
15. Disclaimer of Warranty.
|
|
||||||
|
|
||||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
|
||||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
|
||||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
|
||||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
|
||||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
|
||||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
|
||||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
|
||||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
|
||||||
|
|
||||||
16. Limitation of Liability.
|
|
||||||
|
|
||||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
|
||||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
|
||||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
|
||||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
|
||||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
|
||||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
|
||||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
|
||||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
|
||||||
SUCH DAMAGES.
|
|
||||||
|
|
||||||
17. Interpretation of Sections 15 and 16.
|
|
||||||
|
|
||||||
If the disclaimer of warranty and limitation of liability provided
|
|
||||||
above cannot be given local legal effect according to their terms,
|
|
||||||
reviewing courts shall apply local law that most closely approximates
|
|
||||||
an absolute waiver of all civil liability in connection with the
|
|
||||||
Program, unless a warranty or assumption of liability accompanies a
|
|
||||||
copy of the Program in return for a fee.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
How to Apply These Terms to Your New Programs
|
|
||||||
|
|
||||||
If you develop a new program, and you want it to be of the greatest
|
|
||||||
possible use to the public, the best way to achieve this is to make it
|
|
||||||
free software which everyone can redistribute and change under these terms.
|
|
||||||
|
|
||||||
To do so, attach the following notices to the program. It is safest
|
|
||||||
to attach them to the start of each source file to most effectively
|
|
||||||
state the exclusion of warranty; and each file should have at least
|
|
||||||
the "copyright" line and a pointer to where the full notice is found.
|
|
||||||
|
|
||||||
<one line to give the program's name and a brief idea of what it does.>
|
|
||||||
Copyright (C) <year> <name of author>
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
|
||||||
|
|
||||||
If the program does terminal interaction, make it output a short
|
|
||||||
notice like this when it starts in an interactive mode:
|
|
||||||
|
|
||||||
<program> Copyright (C) <year> <name of author>
|
|
||||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
|
||||||
This is free software, and you are welcome to redistribute it
|
|
||||||
under certain conditions; type `show c' for details.
|
|
||||||
|
|
||||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
|
||||||
parts of the General Public License. Of course, your program's commands
|
|
||||||
might be different; for a GUI interface, you would use an "about box".
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
|
||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
|
||||||
For more information on this, and how to apply and follow the GNU GPL, see
|
|
||||||
<https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
The GNU General Public License does not permit incorporating your program
|
|
||||||
into proprietary programs. If your program is a subroutine library, you
|
|
||||||
may consider it more useful to permit linking proprietary applications with
|
|
||||||
the library. If this is what you want to do, use the GNU Lesser General
|
|
||||||
Public License instead of this License. But first, please read
|
|
||||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
|
||||||
-28
@@ -1,28 +0,0 @@
|
|||||||
# wechat_chatter / OneBot 第三方组件说明
|
|
||||||
|
|
||||||
TraceMemo 的 macOS 个人微信发送功能会按需使用以下第三方组件:
|
|
||||||
|
|
||||||
- 项目:`yincongcyincong/wechat_chatter`
|
|
||||||
- 上游仓库:https://github.com/yincongcyincong/wechat_chatter
|
|
||||||
- 当前运行时版本:`v0.0.18`
|
|
||||||
- 运行时文件:`onebot_mac_arm64.tar.gz`
|
|
||||||
- 许可证:GNU General Public License version 3(GPL-3.0)
|
|
||||||
- 上游版权:Copyright (C) 2026 yincongcyincong
|
|
||||||
- TraceMemo 修改日期:2026-08-17
|
|
||||||
|
|
||||||
## 集成方式
|
|
||||||
|
|
||||||
OneBot 运行时不会随 TraceMemo 安装包一起分发。用户启用该实验性功能时,TraceMemo 会从上述上游项目的 GitHub Release 按需下载运行时,并将其安装到应用的用户数据目录。
|
|
||||||
|
|
||||||
运行时作为独立进程启动,TraceMemo 通过本机 HTTP 接口与其通信。
|
|
||||||
|
|
||||||
## 本地修改
|
|
||||||
|
|
||||||
为适配连续发送、图片上传 Hook 状态检测以及微信核心模块基址定位,TraceMemo 会在用户设备上对上游 `onebot/script.js` 应用兼容性补丁。补丁逻辑位于:
|
|
||||||
|
|
||||||
- `scripts/prepare-wechat-chatter-runtime.cjs`
|
|
||||||
- `src/main/services/personal-wechat-runtime-manager.ts`
|
|
||||||
|
|
||||||
补丁中源自或修改自上游 `script.js` 的部分,以及补丁应用后产生的修改版 `script.js`,继续按照 GPL-3.0 提供。本说明只针对该第三方组件及相关修改,不用于声明 TraceMemo 仓库其他部分的许可证。
|
|
||||||
|
|
||||||
GPL-3.0 的完整文本见本目录下的 `LICENSE`。
|
|
||||||
@@ -34,7 +34,6 @@ extraResources:
|
|||||||
to: resources
|
to: resources
|
||||||
filter:
|
filter:
|
||||||
- '**/*'
|
- '**/*'
|
||||||
- '!connectors/wechat-personal/**'
|
|
||||||
- from: docs/skill/tracememo-reader
|
- from: docs/skill/tracememo-reader
|
||||||
to: skill/tracememo-reader
|
to: skill/tracememo-reader
|
||||||
filter:
|
filter:
|
||||||
|
|||||||
+2
-2
@@ -39,7 +39,7 @@
|
|||||||
"prepare:ffmpeg:mac:arm64": "node scripts/prepare-electron-runtime.cjs --platform darwin --arch arm64",
|
"prepare:ffmpeg:mac:arm64": "node scripts/prepare-electron-runtime.cjs --platform darwin --arch arm64",
|
||||||
"prepare:ffmpeg:mac:x64": "node scripts/prepare-electron-runtime.cjs --platform darwin --arch x64",
|
"prepare:ffmpeg:mac:x64": "node scripts/prepare-electron-runtime.cjs --platform darwin --arch x64",
|
||||||
"prepare:win-runtime": "node scripts/prepare-win-runtime.cjs && npm run prepare:ffmpeg:win",
|
"prepare:win-runtime": "node scripts/prepare-win-runtime.cjs && npm run prepare:ffmpeg:win",
|
||||||
"prepare:wechat-personal": "node scripts/prepare-wechat-chatter-runtime.cjs",
|
"prepare:wechat-native": "node scripts/prepare-wechat-native-runtime.cjs",
|
||||||
"start": "electron-vite preview",
|
"start": "electron-vite preview",
|
||||||
"predev": "node scripts/ensure-electron-binary.cjs",
|
"predev": "node scripts/ensure-electron-binary.cjs",
|
||||||
"dev": "node scripts/ensure-env.cjs && electron-vite dev",
|
"dev": "node scripts/ensure-env.cjs && electron-vite dev",
|
||||||
@@ -169,4 +169,4 @@
|
|||||||
"ffmpeg-static"
|
"ffmpeg-static"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1 +1 @@
|
|||||||
f1bbd88584075c0e6d3487357a06424949de26d7e6dcad803d210aab83b0cbda xkey_helper_4_1_13
|
a35d3fa67387e049cc30bc073f9e65b077aa1db9c5c2b183125dec695fa013b9 xkey_helper_4_1_13
|
||||||
|
|||||||
+76
-2
@@ -16,6 +16,15 @@ const REQUIRED_RUNTIME_PACKAGES = [
|
|||||||
'koffi'
|
'koffi'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// electron-builder 26 skips macOS signing entirely when no Developer ID
|
||||||
|
// identity is configured, so an unpacked bundle can ship without a usable
|
||||||
|
// signature. macOS kills a helper whose code or signature is missing or
|
||||||
|
// modified even when SIP is disabled, which is what customers hit on newer
|
||||||
|
// macOS releases. Ad-hoc re-sign the runtime helpers and the outer bundle so
|
||||||
|
// every Mach-O verifies strictly; spctl still rejects ad-hoc code, which is
|
||||||
|
// acceptable for the SIP-disabled customer workflow.
|
||||||
|
const MACOS_HELPER_NAMES = ['xkey_helper', 'xkey_helper_4_1_13']
|
||||||
|
|
||||||
function getRuntimeResources(context) {
|
function getRuntimeResources(context) {
|
||||||
const productName = context.packager.appInfo.productFilename
|
const productName = context.packager.appInfo.productFilename
|
||||||
return context.electronPlatformName === 'darwin'
|
return context.electronPlatformName === 'darwin'
|
||||||
@@ -121,6 +130,63 @@ function normalizeBuilderArch(arch) {
|
|||||||
return { 0: 'ia32', 1: 'x64', 2: 'armv7l', 3: 'arm64', 4: 'universal' }[arch] || String(arch)
|
return { 0: 'ia32', 1: 'x64', 2: 'armv7l', 3: 'arm64', 4: 'universal' }[arch] || String(arch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runCodesign(args) {
|
||||||
|
execFileSync('/usr/bin/codesign', args, { stdio: 'ignore' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMacosCodeValid(targetPath, run = runCodesign) {
|
||||||
|
try {
|
||||||
|
run(['--verify', '--strict', targetPath])
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMacosHelperPaths(runtimeResources) {
|
||||||
|
return MACOS_HELPER_NAMES.map((name) => path.join(runtimeResources, 'resources', name)).filter(
|
||||||
|
(helperPath) => existsSync(helperPath)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function signMacosHelpers(runtimeResources, run = runCodesign) {
|
||||||
|
const helperPaths = findMacosHelperPaths(runtimeResources)
|
||||||
|
for (const helperPath of helperPaths) {
|
||||||
|
chmodSync(helperPath, 0o755)
|
||||||
|
if (!isMacosCodeValid(helperPath, run)) {
|
||||||
|
run(['--force', '--sign', '-', helperPath])
|
||||||
|
}
|
||||||
|
for (const arch of ['arm64', 'x86_64']) {
|
||||||
|
try {
|
||||||
|
run(['--verify', '--strict', '--arch', arch, helperPath])
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
'macOS helper signature verification failed: ' +
|
||||||
|
path.basename(helperPath) +
|
||||||
|
' (' +
|
||||||
|
arch +
|
||||||
|
')',
|
||||||
|
{ cause: error }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return helperPaths
|
||||||
|
}
|
||||||
|
|
||||||
|
function signMacosAppBundle(appBundlePath, run = runCodesign) {
|
||||||
|
if (isMacosCodeValid(appBundlePath, run)) return appBundlePath
|
||||||
|
run(['--force', '--sign', '-', appBundlePath])
|
||||||
|
try {
|
||||||
|
run(['--verify', '--strict', appBundlePath])
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('macOS app bundle signature verification failed: ' + appBundlePath, {
|
||||||
|
cause: error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return appBundlePath
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A foreign-architecture binary only fails once the user touches the feature
|
* A foreign-architecture binary only fails once the user touches the feature
|
||||||
* that needs it, so verify the ones whose filename is shared across
|
* that needs it, so verify the ones whose filename is shared across
|
||||||
@@ -226,7 +292,9 @@ function pruneForeignArchNativeRuntimes(runtimeResources, platform, arch) {
|
|||||||
for (const entry of readdirSync(modulesRoot, { withFileTypes: true })) {
|
for (const entry of readdirSync(modulesRoot, { withFileTypes: true })) {
|
||||||
if (!entry.isDirectory() || entry.name === expected || !foreign.test(entry.name)) continue
|
if (!entry.isDirectory() || entry.name === expected || !foreign.test(entry.name)) continue
|
||||||
rmSync(path.join(modulesRoot, entry.name), { recursive: true, force: true })
|
rmSync(path.join(modulesRoot, entry.name), { recursive: true, force: true })
|
||||||
removed.push(runtime.modules.length ? `${runtime.modules.join('/')}/${entry.name}` : entry.name)
|
removed.push(
|
||||||
|
runtime.modules.length ? `${runtime.modules.join('/')}/${entry.name}` : entry.name
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return removed
|
return removed
|
||||||
@@ -280,6 +348,9 @@ exports.default = async function afterPack(context) {
|
|||||||
execFileSync('/usr/bin/codesign', ['--force', '--sign', '-', ffmpegPath], {
|
execFileSync('/usr/bin/codesign', ['--force', '--sign', '-', ffmpegPath], {
|
||||||
stdio: 'ignore'
|
stdio: 'ignore'
|
||||||
})
|
})
|
||||||
|
signMacosHelpers(runtimeResources)
|
||||||
|
const productName = context.packager.appInfo.productFilename
|
||||||
|
signMacosAppBundle(path.join(context.appOutDir, productName + '.app'))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.electronPlatformName === 'win32') {
|
if (context.electronPlatformName === 'win32') {
|
||||||
@@ -298,7 +369,6 @@ exports.default = async function afterPack(context) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.getRuntimeResources = getRuntimeResources
|
exports.getRuntimeResources = getRuntimeResources
|
||||||
@@ -312,3 +382,7 @@ exports.pruneIntelMacKeyTool = pruneIntelMacKeyTool
|
|||||||
exports.pruneForeignArchConnectors = pruneForeignArchConnectors
|
exports.pruneForeignArchConnectors = pruneForeignArchConnectors
|
||||||
exports.pruneForeignArchNativeRuntimes = pruneForeignArchNativeRuntimes
|
exports.pruneForeignArchNativeRuntimes = pruneForeignArchNativeRuntimes
|
||||||
exports.validateRuntimeBinaryArchitecture = validateRuntimeBinaryArchitecture
|
exports.validateRuntimeBinaryArchitecture = validateRuntimeBinaryArchitecture
|
||||||
|
exports.findMacosHelperPaths = findMacosHelperPaths
|
||||||
|
exports.isMacosCodeValid = isMacosCodeValid
|
||||||
|
exports.signMacosHelpers = signMacosHelpers
|
||||||
|
exports.signMacosAppBundle = signMacosAppBundle
|
||||||
|
|||||||
@@ -1,436 +0,0 @@
|
|||||||
/*
|
|
||||||
* wechat_chatter runtime integration
|
|
||||||
*
|
|
||||||
* Upstream: https://github.com/yincongcyincong/wechat_chatter
|
|
||||||
* Runtime version: v0.0.18
|
|
||||||
* Upstream license: GNU General Public License version 3 (GPL-3.0)
|
|
||||||
*
|
|
||||||
* This file applies local compatibility patches to the upstream
|
|
||||||
* onebot/script.js. See docs/third-party/wechat-chatter/NOTICE.md.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-require-imports */
|
|
||||||
const { execFileSync } = require('node:child_process')
|
|
||||||
const fs = require('node:fs')
|
|
||||||
const os = require('node:os')
|
|
||||||
const path = require('node:path')
|
|
||||||
|
|
||||||
const release = 'v0.0.18'
|
|
||||||
const asset = 'onebot_mac_arm64.tar.gz'
|
|
||||||
const url = `https://github.com/yincongcyincong/wechat_chatter/releases/download/${release}/${asset}`
|
|
||||||
const projectRoot = path.resolve(__dirname, '..')
|
|
||||||
const outputDir = path.join(
|
|
||||||
projectRoot,
|
|
||||||
'resources',
|
|
||||||
'connectors',
|
|
||||||
'wechat-personal',
|
|
||||||
'darwin-arm64'
|
|
||||||
)
|
|
||||||
const archive = path.join(os.tmpdir(), `wechat-chatter-${release}-${asset}`)
|
|
||||||
const appleSilicon =
|
|
||||||
process.platform === 'darwin' &&
|
|
||||||
execFileSync('/usr/sbin/sysctl', ['-n', 'hw.optional.arm64'], { encoding: 'utf8' }).trim() === '1'
|
|
||||||
|
|
||||||
if (!appleSilicon) {
|
|
||||||
throw new Error('个人微信发送运行时当前仅支持 macOS arm64')
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.mkdirSync(outputDir, { recursive: true })
|
|
||||||
let archiveReady = false
|
|
||||||
if (fs.existsSync(archive)) {
|
|
||||||
try {
|
|
||||||
execFileSync('/usr/bin/tar', ['-tzf', archive], { stdio: 'ignore' })
|
|
||||||
archiveReady = true
|
|
||||||
console.log(`[wechat-personal] 复用已下载归档:${archive}`)
|
|
||||||
} catch {
|
|
||||||
// The archive is partial or invalid; curl will resume it below.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!archiveReady) {
|
|
||||||
console.log(`[wechat-personal] 下载 ${release},支持断点续传:${archive}`)
|
|
||||||
execFileSync(
|
|
||||||
'/usr/bin/curl',
|
|
||||||
['--http1.1', '-L', '--fail', '--retry', '3', '--continue-at', '-', '--output', archive, url],
|
|
||||||
{ stdio: 'inherit' }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const extractionDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-chatter-extract-'))
|
|
||||||
console.log(`[wechat-personal] 解压并原子安装到 ${outputDir}`)
|
|
||||||
try {
|
|
||||||
execFileSync('/usr/bin/tar', ['-xzf', archive, '-C', extractionDir], { stdio: 'inherit' })
|
|
||||||
fs.mkdirSync(path.join(outputDir, 'onebot'), { recursive: true })
|
|
||||||
fs.mkdirSync(path.join(outputDir, 'wechat_version'), { recursive: true })
|
|
||||||
fs.copyFileSync(
|
|
||||||
path.join(extractionDir, 'onebot', 'script.js'),
|
|
||||||
path.join(outputDir, 'onebot', 'script.js')
|
|
||||||
)
|
|
||||||
fs.cpSync(path.join(extractionDir, 'wechat_version'), path.join(outputDir, 'wechat_version'), {
|
|
||||||
recursive: true,
|
|
||||||
force: true
|
|
||||||
})
|
|
||||||
const stagedExecutable = path.join(outputDir, 'onebot', `.onebot-${process.pid}.tmp`)
|
|
||||||
fs.copyFileSync(path.join(extractionDir, 'onebot', 'onebot'), stagedExecutable)
|
|
||||||
fs.chmodSync(stagedExecutable, 0o755)
|
|
||||||
fs.renameSync(stagedExecutable, path.join(outputDir, 'onebot', 'onebot'))
|
|
||||||
} finally {
|
|
||||||
fs.rmSync(extractionDir, { recursive: true, force: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
const executable = path.join(outputDir, 'onebot', 'onebot')
|
|
||||||
const script = path.join(outputDir, 'onebot', 'script.js')
|
|
||||||
const config = path.join(outputDir, 'wechat_version', '4_1_11_53_mac.json')
|
|
||||||
for (const required of [executable, script, config]) {
|
|
||||||
if (!fs.existsSync(required)) throw new Error(`运行时文件缺失:${required}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchPerSendPayload(scriptPath) {
|
|
||||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
|
||||||
if (!source.includes('var activeTriggerX1Payload = ptr(0);')) return
|
|
||||||
|
|
||||||
const activeSend = ` const payloadData = hexToByteArray(payloadHex);
|
|
||||||
activeTriggerX1Payload = Memory.alloc(payloadData.length);
|
|
||||||
activeTriggerX1Payload.writeByteArray(payloadData);
|
|
||||||
activeTriggerX1Payload.add(0x18).writePointer(info.cgiAddr);
|
|
||||||
activeTriggerX1Payload.add(0xb8).writePointer(activeTriggerX1Payload.add(0xc0));
|
|
||||||
activeTriggerX1Payload.add(0x190).writePointer(activeTriggerX1Payload.add(0x198));`
|
|
||||||
const upstreamSend = ` const payloadData = hexToByteArray(payloadHex);
|
|
||||||
triggerX1Payload.writeByteArray(payloadData);
|
|
||||||
triggerX1Payload.add(0x18).writePointer(info.cgiAddr);
|
|
||||||
triggerX1Payload.add(0xb8).writePointer(triggerX1Payload.add(0xc0));
|
|
||||||
triggerX1Payload.add(0x190).writePointer(triggerX1Payload.add(0x198));`
|
|
||||||
if (!source.includes(activeSend)) throw new Error('无法定位 wechat_chatter 连续发送补丁位置')
|
|
||||||
source = source
|
|
||||||
.replace(
|
|
||||||
'var triggerX1Payload;\nvar activeTriggerX1Payload = ptr(0);\nvar triggerX0;',
|
|
||||||
'var triggerX1Payload;\nvar triggerX0;'
|
|
||||||
)
|
|
||||||
.replace(activeSend, upstreamSend)
|
|
||||||
.replace(
|
|
||||||
' MMStartTask(triggerX0, activeTriggerX1Payload);',
|
|
||||||
' MMStartTask(triggerX0, triggerX1Payload);'
|
|
||||||
)
|
|
||||||
.replace(
|
|
||||||
' activeTriggerX1Payload = ptr(0);\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);',
|
|
||||||
' console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);'
|
|
||||||
)
|
|
||||||
.replace(
|
|
||||||
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\tactiveTriggerX1Payload = ptr(0);\n\t\t\t\treturn',
|
|
||||||
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\treturn'
|
|
||||||
)
|
|
||||||
fs.writeFileSync(scriptPath, source)
|
|
||||||
console.log('[wechat-personal] 已恢复原生发送 payload 布局')
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchSendContextCapture(scriptPath, strict = true) {
|
|
||||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
|
||||||
if (source.includes('function isLikelySendContext(')) return
|
|
||||||
const original = `function AttachSendFunc() {
|
|
||||||
Interceptor.attach(sendFuncAddr.add(0x10), {
|
|
||||||
onEnter: function (args) {
|
|
||||||
|
|
||||||
if (triggerX1Payload) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
triggerX0 = this.context.x0;
|
|
||||||
triggerX1Payload = this.context.x1;
|
|
||||||
console.log(\`[+] 捕获到 StartTask 调用,X0:\${triggerX0}, Payload: \${triggerX1Payload}\`);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}`
|
|
||||||
const patched = `function isLikelySendContext(candidateX0, candidateX1) {
|
|
||||||
try {
|
|
||||||
if (!isReadablePointer(candidateX0) || !isReadablePointer(candidateX1)) return false;
|
|
||||||
var manager = readPointerIfReadable(candidateX0.add(0x18));
|
|
||||||
var cgi = readUtf8StringIfReadable(readPointerIfReadable(candidateX1.add(0x18)));
|
|
||||||
console.log("[debug] StartTask candidate x0=" + candidateX0 + " x1=" + candidateX1 + " x0+0x18=" + manager + " cgi=" + cgi);
|
|
||||||
return !manager.equals(ptr(0));
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[debug] StartTask candidate inspect failed: " + e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function AttachSendFunc() {
|
|
||||||
Interceptor.attach(sendFuncAddr.add(0x10), {
|
|
||||||
onEnter: function (args) {
|
|
||||||
if (triggerX1Payload) return;
|
|
||||||
var candidateX0 = this.context.x0;
|
|
||||||
var candidateX1 = this.context.x1;
|
|
||||||
if (!isLikelySendContext(candidateX0, candidateX1)) return;
|
|
||||||
triggerX0 = candidateX0;
|
|
||||||
triggerX1Payload = candidateX1;
|
|
||||||
console.log(\`[+] 捕获到有效 StartTask 上下文,X0:\${triggerX0}, Payload: \${triggerX1Payload}\`);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}`
|
|
||||||
if (!source.includes(original)) {
|
|
||||||
if (strict) throw new Error('无法定位 StartTask 上下文 Hook')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
source = source.replace(original, patched)
|
|
||||||
fs.writeFileSync(scriptPath, source)
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchVoiceAudioBuffer(scriptPath) {
|
|
||||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
|
||||||
if (source.includes('voiceAudioDataAddr = Memory.alloc(audioLen + 1);')) return
|
|
||||||
|
|
||||||
const staticAllocation = 'voiceAudioDataAddr = Memory.alloc(5 * 1024 * 1024); // 预分配5MB'
|
|
||||||
if (!source.includes(staticAllocation)) {
|
|
||||||
throw new Error('无法定位 wechat_chatter 语音缓冲区')
|
|
||||||
}
|
|
||||||
source = source.replace(
|
|
||||||
staticAllocation,
|
|
||||||
'voiceAudioDataAddr = Memory.alloc(1); // 上传前按语音长度重新分配'
|
|
||||||
)
|
|
||||||
const audioLengthMarker = ' const audioLen = audioBytes.length;\n'
|
|
||||||
if (!source.includes(audioLengthMarker)) {
|
|
||||||
throw new Error('无法定位 wechat_chatter 语音上传逻辑')
|
|
||||||
}
|
|
||||||
source = source.replace(
|
|
||||||
audioLengthMarker,
|
|
||||||
`${audioLengthMarker} voiceAudioDataAddr = Memory.alloc(audioLen + 1);\n`
|
|
||||||
)
|
|
||||||
fs.writeFileSync(scriptPath, source)
|
|
||||||
console.log('[wechat-personal] 已应用按语音长度分配上传缓冲区补丁')
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchImageHookReadiness(scriptPath) {
|
|
||||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
|
||||||
if (
|
|
||||||
source.includes('捕获到图片上传上下文,uploadGlobalX0') &&
|
|
||||||
source.includes('图片上传 Hook Setup Complete')
|
|
||||||
)
|
|
||||||
return
|
|
||||||
const original = `\t\t\tuploadGlobalX0 = this.context.x0;`
|
|
||||||
const patched = `\t\t\tconst capturedUploadX0 = this.context.x0;
|
|
||||||
\t\t\tif (uploadGlobalX0.equals(ptr(0)) && !capturedUploadX0.equals(ptr(0))) {
|
|
||||||
\t\t\t\tconsole.log("[+] 捕获到图片上传上下文,uploadGlobalX0:" + capturedUploadX0);
|
|
||||||
\t\t\t}
|
|
||||||
\t\t\tuploadGlobalX0 = capturedUploadX0;`
|
|
||||||
if (!source.includes(original)) throw new Error('无法定位 wechat_chatter 图片 Hook 状态补丁位置')
|
|
||||||
source = source.replace(original, patched)
|
|
||||||
source = source.replace(
|
|
||||||
' })\n}\n\n\n\nfunction patchCdnOnComplete()',
|
|
||||||
' })\n console.log("[+] 图片上传 Hook Setup Complete.");\n}\n\n\n\nfunction patchCdnOnComplete()'
|
|
||||||
)
|
|
||||||
fs.writeFileSync(scriptPath, source)
|
|
||||||
console.log('[wechat-personal] 已应用图片 Hook 状态补丁')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backport of wechat_chatter PR #36 by @Leslielu:
|
|
||||||
// https://github.com/yincongcyincong/wechat_chatter/pull/36
|
|
||||||
// TraceMemo adds the verified macOS WeChat 4.1.11.53 addresses.
|
|
||||||
// macOS WeChat 4.1.11.53, located and verified by TraceMemo
|
|
||||||
function patchCdnColdStart(scriptPath) {
|
|
||||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
|
||||||
if (source.includes('function resolveCdnManager()')) return
|
|
||||||
|
|
||||||
const initAddresses = ` uploadImageAddr = baseAddr.add({{.uploadImageAddr}});
|
|
||||||
cndOnCompleteAddr = baseAddr.add({{.cndOnCompleteAddr}});`
|
|
||||||
const patchedInitAddresses = ` uploadImageAddr = baseAddr.add({{.uploadImageAddr}});
|
|
||||||
cndOnCompleteAddr = baseAddr.add({{.cndOnCompleteAddr}});
|
|
||||||
// 冷启动 CdnManager 解析(旧版本缺少可选键时保持 hook 捕获行为)
|
|
||||||
{{if .cdnGetServiceAddr}}cdnGetServiceAddr = baseAddr.add({{.cdnGetServiceAddr}});{{end}}
|
|
||||||
{{if .cdnManagerGetterAddr}}cdnManagerGetterAddr = baseAddr.add({{.cdnManagerGetterAddr}});{{end}}`
|
|
||||||
if (!source.includes(initAddresses)) throw new Error('下载的微信版本配置与当前应用不兼容')
|
|
||||||
source = source.replace(initAddresses, patchedInitAddresses)
|
|
||||||
|
|
||||||
const downloadChunkEnd = `}
|
|
||||||
|
|
||||||
function fillUploadX1AndStart`
|
|
||||||
const resolver = `}
|
|
||||||
|
|
||||||
// 上传和下载共用同一个 mars::cdn::CdnManager。冷启动时通过服务定位器
|
|
||||||
// 取得 [ctx + 0x40],避免必须先手动发送图片才能让 Hook 捕获上下文。
|
|
||||||
function resolveCdnManager() {
|
|
||||||
if (cdnGetServiceAddr.equals(ptr(0)) || cdnManagerGetterAddr.equals(ptr(0))) {
|
|
||||||
return ptr(0);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// libc++ SSO 短字符串:数据在 +0,长度写在 +0x17。
|
|
||||||
var strDefault = Memory.alloc(24);
|
|
||||||
strDefault.writeUtf8String("default");
|
|
||||||
strDefault.add(0x17).writeU8(7);
|
|
||||||
|
|
||||||
var getService = new NativeFunction(cdnGetServiceAddr, 'pointer', ['pointer']);
|
|
||||||
var svc = getService(strDefault);
|
|
||||||
if (!isReadablePointer(svc)) {
|
|
||||||
console.error("[!] GetService(\\"default\\") 返回不可读: " + svc);
|
|
||||||
return ptr(0);
|
|
||||||
}
|
|
||||||
var getCtx = new NativeFunction(cdnManagerGetterAddr, 'pointer', ['pointer']);
|
|
||||||
var ctx = getCtx(svc);
|
|
||||||
if (!isReadablePointer(ctx)) {
|
|
||||||
console.error("[!] CdnManager getter 返回不可读: " + ctx);
|
|
||||||
return ptr(0);
|
|
||||||
}
|
|
||||||
var mgr = readPointerIfReadable(ctx.add(0x40));
|
|
||||||
if (!isReadablePointer(mgr)) {
|
|
||||||
console.error("[!] ctx+0x40 管理器指针不可读: ctx=" + ctx);
|
|
||||||
return ptr(0);
|
|
||||||
}
|
|
||||||
return mgr;
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[!] resolveCdnManager 异常: " + e);
|
|
||||||
return ptr(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureCdnManagerX0() {
|
|
||||||
if (uploadGlobalX0.equals(ptr(0)) && downloadGlobalX0 && !downloadGlobalX0.equals(ptr(0))) {
|
|
||||||
uploadGlobalX0 = downloadGlobalX0;
|
|
||||||
console.log("[+] downloadGlobalX0 回填 uploadGlobalX0: " + uploadGlobalX0);
|
|
||||||
}
|
|
||||||
if ((!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) && !uploadGlobalX0.equals(ptr(0))) {
|
|
||||||
downloadGlobalX0 = uploadGlobalX0;
|
|
||||||
console.log("[+] uploadGlobalX0 回填 downloadGlobalX0: " + downloadGlobalX0);
|
|
||||||
}
|
|
||||||
if (uploadGlobalX0.equals(ptr(0))) {
|
|
||||||
var mgr = resolveCdnManager();
|
|
||||||
if (!mgr.equals(ptr(0))) {
|
|
||||||
uploadGlobalX0 = mgr;
|
|
||||||
if (!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) {
|
|
||||||
downloadGlobalX0 = mgr;
|
|
||||||
}
|
|
||||||
console.log("[+] 冷启动服务定位器解析 CdnManager: " + mgr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return !uploadGlobalX0.equals(ptr(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
function fillUploadX1AndStart`
|
|
||||||
if (!source.includes(downloadChunkEnd)) throw new Error('无法定位 wechat_chatter 媒体上传逻辑')
|
|
||||||
source = source.replace(downloadChunkEnd, resolver)
|
|
||||||
|
|
||||||
const declarations = 'var uploadImageAddr;\n'
|
|
||||||
const patchedDeclarations =
|
|
||||||
'var uploadImageAddr;\nvar cdnGetServiceAddr = ptr(0);\nvar cdnManagerGetterAddr = ptr(0);\n'
|
|
||||||
if (!source.includes(declarations)) throw new Error('无法定位 wechat_chatter 媒体地址声明')
|
|
||||||
source = source.replace(declarations, patchedDeclarations)
|
|
||||||
|
|
||||||
const uploadGuard = `function fillUploadX1AndStart(idAddr, pathAddr, x1Buffer, receiver, md5, filePath, payloadHex) {
|
|
||||||
if (uploadGlobalX0.equals(ptr(0))) {`
|
|
||||||
const patchedUploadGuard = `function fillUploadX1AndStart(idAddr, pathAddr, x1Buffer, receiver, md5, filePath, payloadHex) {
|
|
||||||
if (uploadGlobalX0.equals(ptr(0))) {
|
|
||||||
ensureCdnManagerX0();
|
|
||||||
}
|
|
||||||
if (uploadGlobalX0.equals(ptr(0))) {`
|
|
||||||
if (!source.includes(uploadGuard)) throw new Error('无法定位 wechat_chatter 媒体上传入口')
|
|
||||||
source = source.replace(uploadGuard, patchedUploadGuard)
|
|
||||||
|
|
||||||
const voiceGuard = `function triggerUploadVoice(receiver, voicePath, payloadHex, audioDataHex, durationMs) {
|
|
||||||
if (uploadGlobalX0.equals(ptr(0))) {`
|
|
||||||
const patchedVoiceGuard = `function triggerUploadVoice(receiver, voicePath, payloadHex, audioDataHex, durationMs) {
|
|
||||||
if (uploadGlobalX0.equals(ptr(0))) {
|
|
||||||
ensureCdnManagerX0();
|
|
||||||
}
|
|
||||||
if (uploadGlobalX0.equals(ptr(0))) {`
|
|
||||||
if (!source.includes(voiceGuard)) throw new Error('无法定位 wechat_chatter 语音上传入口')
|
|
||||||
source = source.replace(voiceGuard, patchedVoiceGuard)
|
|
||||||
|
|
||||||
const uploadHook = `\t\t\tuploadGlobalX0 = capturedUploadX0;`
|
|
||||||
const patchedUploadHook = `\t\t\tuploadGlobalX0 = capturedUploadX0;
|
|
||||||
if ((!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) && !capturedUploadX0.equals(ptr(0))) {
|
|
||||||
downloadGlobalX0 = capturedUploadX0;
|
|
||||||
console.log("[+] 上传hook回填 downloadGlobalX0: " + downloadGlobalX0);
|
|
||||||
}`
|
|
||||||
if (!source.includes(uploadHook)) throw new Error('无法定位 wechat_chatter 图片 Hook')
|
|
||||||
source = source.replace(uploadHook, patchedUploadHook)
|
|
||||||
|
|
||||||
const downloadHook = ` downloadGlobalX0 = this.context.x0;`
|
|
||||||
const patchedDownloadHook = ` downloadGlobalX0 = this.context.x0;
|
|
||||||
if (uploadGlobalX0.equals(ptr(0)) && !downloadGlobalX0.equals(ptr(0))) {
|
|
||||||
uploadGlobalX0 = downloadGlobalX0;
|
|
||||||
console.log("[+] 下载hook回填 uploadGlobalX0: " + uploadGlobalX0);
|
|
||||||
}`
|
|
||||||
if (!source.includes(downloadHook)) throw new Error('无法定位 wechat_chatter 下载 Hook')
|
|
||||||
source = source.replace(downloadHook, patchedDownloadHook)
|
|
||||||
|
|
||||||
const downloadGuard = `function triggerDownload(receiver, cdnUrl, aesKey, filePath, fileType) {
|
|
||||||
if (!downloadGlobalX0) {`
|
|
||||||
const patchedDownloadGuard = `function triggerDownload(receiver, cdnUrl, aesKey, filePath, fileType) {
|
|
||||||
if (!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) {
|
|
||||||
ensureCdnManagerX0();
|
|
||||||
}
|
|
||||||
if (!downloadGlobalX0) {`
|
|
||||||
if (!source.includes(downloadGuard)) throw new Error('无法定位 wechat_chatter 媒体下载入口')
|
|
||||||
source = source.replace(downloadGuard, patchedDownloadGuard)
|
|
||||||
|
|
||||||
fs.writeFileSync(scriptPath, source)
|
|
||||||
console.log('[wechat-personal] 已应用 CdnManager 冷启动解析补丁')
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchCdnColdStartConfig(configPath) {
|
|
||||||
const source = fs.readFileSync(configPath, 'utf8')
|
|
||||||
let config
|
|
||||||
try {
|
|
||||||
config = JSON.parse(source)
|
|
||||||
} catch {
|
|
||||||
throw new Error('4.1.11.53 版本配置不是有效 JSON')
|
|
||||||
}
|
|
||||||
config.cdnGetServiceAddr = '0x50a15d0'
|
|
||||||
config.cdnManagerGetterAddr = '0x5259290'
|
|
||||||
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
|
||||||
console.log('[wechat-personal] 已写入 4.1.11.53 CdnManager 地址')
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchWechatCoreModuleBase(scriptPath) {
|
|
||||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
|
||||||
if (source.includes('WeChat core module base:')) return
|
|
||||||
const initMarker = 'function initAddresses() {'
|
|
||||||
const initIndex = source.indexOf(initMarker)
|
|
||||||
if (initIndex < 0 || !source.startsWith('var targetPath = ')) {
|
|
||||||
throw new Error('无法定位 wechat_chatter 基址扫描逻辑')
|
|
||||||
}
|
|
||||||
const patchedHeader = `var targetPath = "/Applications/WeChat.app/Contents/Resources/wechat.dylib";
|
|
||||||
var module = Process.enumerateModules().find(function(m) {
|
|
||||||
return m.path === targetPath || m.path.endsWith("/Contents/Resources/wechat.dylib");
|
|
||||||
});
|
|
||||||
if (!module) {
|
|
||||||
throw new Error("[-] Cannot find WeChat core module: " + targetPath);
|
|
||||||
}
|
|
||||||
var moduleBase = module.base;
|
|
||||||
var baseAddr = moduleBase;
|
|
||||||
console.log("[+] WeChat core module base: " + baseAddr + " path=" + module.path);
|
|
||||||
setImmediate(initAddresses);
|
|
||||||
|
|
||||||
`
|
|
||||||
source = patchedHeader + source.slice(initIndex)
|
|
||||||
fs.writeFileSync(scriptPath, source)
|
|
||||||
console.log('[wechat-personal] 已应用微信核心模块基址补丁')
|
|
||||||
}
|
|
||||||
|
|
||||||
function addModifiedWorkNotice(scriptPath) {
|
|
||||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
|
||||||
if (source.includes('TraceMemo wechat_chatter compatibility modifications')) return
|
|
||||||
|
|
||||||
const notice = `/*
|
|
||||||
* TraceMemo wechat_chatter compatibility modifications
|
|
||||||
* Modified: 2026-08-17
|
|
||||||
* Upstream: https://github.com/yincongcyincong/wechat_chatter
|
|
||||||
* Runtime version: v0.0.18
|
|
||||||
* License: GNU General Public License version 3 (GPL-3.0)
|
|
||||||
* Changes: WeChat module discovery, per-send payload isolation, dynamic voice upload buffers,
|
|
||||||
* CdnManager cold-start resolution, media hook backfill, and image Hook readiness logging.
|
|
||||||
* These modifications are not provided by the upstream author.
|
|
||||||
*/
|
|
||||||
|
|
||||||
`
|
|
||||||
source = notice + source
|
|
||||||
fs.writeFileSync(scriptPath, source)
|
|
||||||
console.log('[wechat-personal] 已写入 GPL 修改声明')
|
|
||||||
}
|
|
||||||
|
|
||||||
patchWechatCoreModuleBase(script)
|
|
||||||
patchPerSendPayload(script)
|
|
||||||
patchSendContextCapture(script)
|
|
||||||
patchVoiceAudioBuffer(script)
|
|
||||||
patchImageHookReadiness(script)
|
|
||||||
patchCdnColdStartConfig(config)
|
|
||||||
patchCdnColdStart(script)
|
|
||||||
addModifiedWorkNotice(script)
|
|
||||||
fs.chmodSync(executable, 0o755)
|
|
||||||
console.log('[wechat-personal] 运行时准备完成')
|
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||||
|
const crypto = require('node:crypto')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
|
const REQUIRED_FILES = ['tm-wechat-host', 'libtmwechat.dylib', 'runtime-manifest.json']
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const result = {}
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const value = argv[index]
|
||||||
|
if (value === '--source' || value === '--target') {
|
||||||
|
if (!argv[index + 1]) throw new Error(`${value} requires a path`)
|
||||||
|
result[value.slice(2)] = argv[index + 1]
|
||||||
|
index += 1
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unknown option: ${value}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(filePath) {
|
||||||
|
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function listFiles(root) {
|
||||||
|
const files = []
|
||||||
|
function visit(directory) {
|
||||||
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||||
|
const absolute = path.join(directory, entry.name)
|
||||||
|
if (entry.isDirectory()) visit(absolute)
|
||||||
|
else if (entry.isFile()) files.push(path.relative(root, absolute))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit(root)
|
||||||
|
return files.sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertMachOArm64(filePath, label) {
|
||||||
|
const buffer = fs.readFileSync(filePath)
|
||||||
|
if (buffer.length < 8 || buffer.readUInt32LE(0) !== 0xfeedfacf) {
|
||||||
|
throw new Error(`${label} is not a 64-bit Mach-O binary: ${filePath}`)
|
||||||
|
}
|
||||||
|
if (buffer.readUInt32LE(4) !== 0x0100000c) {
|
||||||
|
throw new Error(`${label} is not arm64: ${filePath}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAndValidateArtifact(sourceDir) {
|
||||||
|
if (!fs.existsSync(sourceDir)) {
|
||||||
|
throw new Error('native runtime artifact not found: build the macOS runtime artifact first')
|
||||||
|
}
|
||||||
|
|
||||||
|
const actualFiles = listFiles(sourceDir)
|
||||||
|
const expectedFiles = [...REQUIRED_FILES].sort()
|
||||||
|
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
|
||||||
|
throw new Error(`native runtime artifact has an unexpected tree: ${actualFiles.join(', ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifestPath = path.join(sourceDir, 'runtime-manifest.json')
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
||||||
|
if (manifest.runtime !== 'tm-wechat-native') throw new Error('Unexpected runtime name')
|
||||||
|
if (manifest.platform !== 'darwin-arm64') throw new Error('Runtime platform must be darwin-arm64')
|
||||||
|
if (manifest.architecture !== 'arm64') throw new Error('Runtime architecture must be arm64')
|
||||||
|
if (manifest.protocolVersion !== 1) throw new Error('Unsupported runtime protocolVersion')
|
||||||
|
if (!/^[a-f0-9]{64}$/.test(manifest.tmSendSourceSha256 || '')) {
|
||||||
|
throw new Error('Runtime manifest has no valid tmSendSourceSha256')
|
||||||
|
}
|
||||||
|
if (!/^[a-f0-9]{64}$/.test(manifest.addressProfileSha256 || '')) {
|
||||||
|
throw new Error('Runtime manifest has no valid addressProfileSha256')
|
||||||
|
}
|
||||||
|
const capabilities = new Set(manifest.capabilities || [])
|
||||||
|
for (const capability of ['text', 'image', 'voice']) {
|
||||||
|
if (!capabilities.has(capability)) throw new Error(`Runtime capability missing: ${capability}`)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Array.isArray(manifest.supportedWechatVersions) ||
|
||||||
|
manifest.supportedWechatVersions.length === 0
|
||||||
|
) {
|
||||||
|
throw new Error('Runtime manifest has no supportedWechatVersions')
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const relative of REQUIRED_FILES) {
|
||||||
|
const absolute = path.join(sourceDir, relative)
|
||||||
|
if (!fs.statSync(absolute).isFile()) throw new Error(`Runtime file missing: ${relative}`)
|
||||||
|
}
|
||||||
|
const hostPath = path.join(sourceDir, 'tm-wechat-host')
|
||||||
|
const dylibPath = path.join(sourceDir, 'libtmwechat.dylib')
|
||||||
|
assertMachOArm64(hostPath, 'tm-wechat-host')
|
||||||
|
assertMachOArm64(dylibPath, 'libtmwechat.dylib')
|
||||||
|
if (!fs.readFileSync(dylibPath).includes(Buffer.from(manifest.tmSendSourceSha256, 'utf8'))) {
|
||||||
|
throw new Error('Embedded agent hash is not present in libtmwechat.dylib')
|
||||||
|
}
|
||||||
|
if (!fs.readFileSync(dylibPath).includes(Buffer.from(manifest.addressProfileSha256, 'utf8'))) {
|
||||||
|
throw new Error('Embedded address profile hash is not present in libtmwechat.dylib')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
manifest,
|
||||||
|
hashes: Object.fromEntries(
|
||||||
|
REQUIRED_FILES.map((file) => [file, sha256(path.join(sourceDir, file))])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncArtifact(sourceDir, targetDir) {
|
||||||
|
const source = readAndValidateArtifact(sourceDir)
|
||||||
|
const parent = path.dirname(targetDir)
|
||||||
|
const name = path.basename(targetDir)
|
||||||
|
const staging = path.join(parent, `.${name}.prepare-${process.pid}`)
|
||||||
|
const backup = path.join(parent, `.${name}.backup-${process.pid}`)
|
||||||
|
|
||||||
|
fs.mkdirSync(parent, { recursive: true })
|
||||||
|
fs.rmSync(staging, { recursive: true, force: true })
|
||||||
|
fs.rmSync(backup, { recursive: true, force: true })
|
||||||
|
fs.mkdirSync(staging, { recursive: true })
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const relative of REQUIRED_FILES) {
|
||||||
|
const destination = path.join(staging, relative)
|
||||||
|
fs.mkdirSync(path.dirname(destination), { recursive: true })
|
||||||
|
fs.copyFileSync(path.join(sourceDir, relative), destination)
|
||||||
|
}
|
||||||
|
fs.chmodSync(path.join(staging, 'tm-wechat-host'), 0o755)
|
||||||
|
|
||||||
|
const staged = readAndValidateArtifact(staging)
|
||||||
|
for (const relative of REQUIRED_FILES) {
|
||||||
|
if (source.hashes[relative] !== staged.hashes[relative]) {
|
||||||
|
throw new Error(`Packaged runtime hash mismatch: ${relative}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(targetDir)) fs.renameSync(targetDir, backup)
|
||||||
|
try {
|
||||||
|
fs.renameSync(staging, targetDir)
|
||||||
|
} catch (error) {
|
||||||
|
if (fs.existsSync(backup)) fs.renameSync(backup, targetDir)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
fs.rmSync(backup, { recursive: true, force: true })
|
||||||
|
return staged
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(staging, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSource(explicit) {
|
||||||
|
if (explicit) return path.resolve(explicit)
|
||||||
|
const fromEnv = String(process.env.TM_NATIVE_RUNTIME_DIR || '').trim()
|
||||||
|
if (fromEnv) return path.resolve(fromEnv)
|
||||||
|
// 本机路径写在这里即可,该文件不进仓库。
|
||||||
|
const localConfig = path.join(__dirname, '..', '.native-runtime-source')
|
||||||
|
if (fs.existsSync(localConfig)) {
|
||||||
|
const configured = fs.readFileSync(localConfig, 'utf8').trim()
|
||||||
|
if (configured) return path.resolve(configured)
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
'runtime artifact source is required: pass --source <dir>, set TM_NATIVE_RUNTIME_DIR, ' +
|
||||||
|
'or write the path into .native-runtime-source'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const projectRoot = path.resolve(__dirname, '..')
|
||||||
|
const args = parseArgs(process.argv.slice(2))
|
||||||
|
const sourceDir = resolveSource(args.source)
|
||||||
|
const targetDir = path.resolve(
|
||||||
|
args.target || path.join(projectRoot, 'resources', 'runtime', 'darwin-arm64')
|
||||||
|
)
|
||||||
|
const result = syncArtifact(sourceDir, targetDir)
|
||||||
|
console.log(`[prepare-wechat-native] source: ${sourceDir}`)
|
||||||
|
console.log(`[prepare-wechat-native] target: ${targetDir}`)
|
||||||
|
console.log(
|
||||||
|
`[prepare-wechat-native] runtime: ${result.manifest.runtime}/${result.manifest.version}`
|
||||||
|
)
|
||||||
|
console.log(`[prepare-wechat-native] agent: ${result.manifest.tmSendSourceSha256}`)
|
||||||
|
console.log(`[prepare-wechat-native] address profile: ${result.manifest.addressProfileSha256}`)
|
||||||
|
console.log('[prepare-wechat-native] files: 3')
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
REQUIRED_FILES,
|
||||||
|
assertMachOArm64,
|
||||||
|
readAndValidateArtifact,
|
||||||
|
syncArtifact
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
try {
|
||||||
|
main()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`[prepare-wechat-native] ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
process.exitCode = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-4
@@ -3,6 +3,7 @@ import http, { IncomingMessage, ServerResponse, Server } from 'http'
|
|||||||
import {
|
import {
|
||||||
isReady,
|
isReady,
|
||||||
listContacts,
|
listContacts,
|
||||||
|
listContactsAsync,
|
||||||
listMessages,
|
listMessages,
|
||||||
getGroupSnapshot,
|
getGroupSnapshot,
|
||||||
listRecentChat,
|
listRecentChat,
|
||||||
@@ -230,21 +231,26 @@ const routes: Record<string, RouteHandler> = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
'/api/v1/contact': ({ res, url }) => {
|
'/api/v1/contact': async ({ res, url }) => {
|
||||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||||
const filter = url.searchParams.get('filter') || undefined
|
const filter = url.searchParams.get('filter') || undefined
|
||||||
const type = url.searchParams.get('type') || undefined
|
const type = url.searchParams.get('type') || undefined
|
||||||
let contacts = listContacts(filter)
|
// Use the hydrated data source: on macOS the session cache only carries raw
|
||||||
|
// ids until display names / contact identities are hydrated, so the sync
|
||||||
|
// `listContacts` would miss nickname and remark matches (Issue #51).
|
||||||
|
let contacts = await listContactsAsync(filter)
|
||||||
if (type === 'user' || type === 'group') {
|
if (type === 'user' || type === 'group') {
|
||||||
contacts = contacts.filter((c) => c.type === type)
|
contacts = contacts.filter((c) => c.type === type)
|
||||||
}
|
}
|
||||||
sendJson(res, 200, { count: contacts.length, contacts })
|
sendJson(res, 200, { count: contacts.length, contacts })
|
||||||
},
|
},
|
||||||
|
|
||||||
'/api/v1/chatroom': ({ res, url }) => {
|
'/api/v1/chatroom': async ({ res, url }) => {
|
||||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||||
const keyword = url.searchParams.get('keyword') || ''
|
const keyword = url.searchParams.get('keyword') || ''
|
||||||
let groups = listContacts().filter((c) => c.type === 'group')
|
// Same hydration requirement as /api/v1/contact: group display names are
|
||||||
|
// exactly the fields that stay un-hydrated on macOS.
|
||||||
|
let groups = (await listContactsAsync()).filter((c) => c.type === 'group')
|
||||||
if (keyword) {
|
if (keyword) {
|
||||||
const lower = keyword.toLowerCase()
|
const lower = keyword.toLowerCase()
|
||||||
groups = groups.filter(
|
groups = groups.filter(
|
||||||
|
|||||||
+413
-114
@@ -14,7 +14,8 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
Tray,
|
Tray,
|
||||||
dialog,
|
dialog,
|
||||||
protocol
|
protocol,
|
||||||
|
screen
|
||||||
} from 'electron'
|
} from 'electron'
|
||||||
import { dirname, extname, join } from 'path'
|
import { dirname, extname, join } from 'path'
|
||||||
import { existsSync, promises as fsPromises } from 'fs'
|
import { existsSync, promises as fsPromises } from 'fs'
|
||||||
@@ -112,22 +113,28 @@ import { agentHubService } from './services/agent-hub-service'
|
|||||||
import { WechatConnectorService } from './services/wechat-ilink'
|
import { WechatConnectorService } from './services/wechat-ilink'
|
||||||
import { wechatSendGateway } from './services/wechat-send-gateway'
|
import { wechatSendGateway } from './services/wechat-send-gateway'
|
||||||
import { groupExitMonitorService } from './services/group-exit-monitor-service'
|
import { groupExitMonitorService } from './services/group-exit-monitor-service'
|
||||||
|
import {
|
||||||
|
filterSendableFriendContacts,
|
||||||
|
leaveNotificationContactDisplayName
|
||||||
|
} from './services/leave-notification-target'
|
||||||
|
import { MessageListenerService } from './services/message-listener-service'
|
||||||
|
import { automationRuleStore } from './services/automation-rule-store'
|
||||||
|
import { automationExecutionLogService } from './services/automation-execution-log-service'
|
||||||
|
import { initAutomationService, getAutomationService } from './services/automation-service'
|
||||||
|
import { GroupStatsService } from './services/group-stats-service'
|
||||||
import { wechatActionLogService } from './services/wechat-action-log-service'
|
import { wechatActionLogService } from './services/wechat-action-log-service'
|
||||||
import { wechatActionGateway } from './services/wechat-action-gateway'
|
import { toPersonalWechatSendResult, wechatActionGateway } from './services/wechat-action-gateway'
|
||||||
import { personalWechatSendService } from './services/personal-wechat-send-service'
|
import { personalWechatSendService } from './services/personal-wechat-send-service'
|
||||||
|
import { macWechatRuntimeManager } from './services/mac-wechat-runtime-manager'
|
||||||
import { getPersonalWechatSendCapability } from './services/personal-wechat-capability-service'
|
import { getPersonalWechatSendCapability } from './services/personal-wechat-capability-service'
|
||||||
import { scheduledReportService } from './services/scheduled-report-service'
|
import { scheduledReportService } from './services/scheduled-report-service'
|
||||||
import { PersonalWechatRuntimeManager } from './services/personal-wechat-runtime-manager'
|
|
||||||
import { personalWechatVoiceEnvironmentService } from './services/personal-wechat-voice-environment-service'
|
import { personalWechatVoiceEnvironmentService } from './services/personal-wechat-voice-environment-service'
|
||||||
import type {
|
import type {
|
||||||
PersonalWechatGeneratedTtsVoiceRequest,
|
PersonalWechatGeneratedTtsVoiceRequest,
|
||||||
PersonalWechatSendRequest,
|
PersonalWechatSendRequest,
|
||||||
PersonalWechatSendResult
|
PersonalWechatSendResult,
|
||||||
|
PersonalWechatSenderStatus
|
||||||
} from '../shared/personal-wechat'
|
} from '../shared/personal-wechat'
|
||||||
import type {
|
|
||||||
ScheduledReportCreateInput,
|
|
||||||
ScheduledReportUpdateInput
|
|
||||||
} from '../shared/scheduled-report'
|
|
||||||
import { isTruthyDebugFlag } from '../shared/debug-flags'
|
import { isTruthyDebugFlag } from '../shared/debug-flags'
|
||||||
import { TextToSpeechSettingsService } from './services/text-to-speech-settings-service'
|
import { TextToSpeechSettingsService } from './services/text-to-speech-settings-service'
|
||||||
import type {
|
import type {
|
||||||
@@ -196,6 +203,12 @@ import type {
|
|||||||
WechatShareServiceConfig
|
WechatShareServiceConfig
|
||||||
} from '../shared/wechat-share-card'
|
} from '../shared/wechat-share-card'
|
||||||
|
|
||||||
|
async function currentPersonalWechatSenderStatus(): Promise<PersonalWechatSenderStatus> {
|
||||||
|
return process.platform === 'darwin'
|
||||||
|
? macWechatRuntimeManager.buildSenderStatus()
|
||||||
|
: personalWechatSendService.getStatus()
|
||||||
|
}
|
||||||
|
|
||||||
// electron-vite can close the child's stdout/stderr after spawning Electron.
|
// electron-vite can close the child's stdout/stderr after spawning Electron.
|
||||||
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
|
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
|
||||||
// handler. Wrap console.* before any other module logs anything.
|
// handler. Wrap console.* before any other module logs anything.
|
||||||
@@ -211,6 +224,7 @@ let voiceService: VoiceService | null = null
|
|||||||
let voiceRecognition: VoiceRecognitionUseCase | null = null
|
let voiceRecognition: VoiceRecognitionUseCase | null = null
|
||||||
let voiceBatchService: VoiceBatchService | null = null
|
let voiceBatchService: VoiceBatchService | null = null
|
||||||
let knowledgeSearchService: KnowledgeSearchService | null = null
|
let knowledgeSearchService: KnowledgeSearchService | null = null
|
||||||
|
let groupStatsService: GroupStatsService | null = null
|
||||||
let localQueryApiService: LocalQueryApiService | null = null
|
let localQueryApiService: LocalQueryApiService | null = null
|
||||||
let aiSearchPipelineService: AiSearchPipelineService | null = null
|
let aiSearchPipelineService: AiSearchPipelineService | null = null
|
||||||
let queryAgentService: QueryAgentService | null = null
|
let queryAgentService: QueryAgentService | null = null
|
||||||
@@ -222,7 +236,6 @@ const databaseKeyStore = new DatabaseKeyStore()
|
|||||||
const imageKeyConfigService = new ImageKeyConfigService()
|
const imageKeyConfigService = new ImageKeyConfigService()
|
||||||
const aiProviderService = new AIProviderService()
|
const aiProviderService = new AIProviderService()
|
||||||
const textToSpeechSettingsService = new TextToSpeechSettingsService()
|
const textToSpeechSettingsService = new TextToSpeechSettingsService()
|
||||||
const personalWechatRuntimeManager = new PersonalWechatRuntimeManager()
|
|
||||||
const keyServiceMac = new KeyServiceMac()
|
const keyServiceMac = new KeyServiceMac()
|
||||||
const keyServiceWin = new KeyServiceWin()
|
const keyServiceWin = new KeyServiceWin()
|
||||||
const wechatShareConfigStore = new WechatShareConfigStore()
|
const wechatShareConfigStore = new WechatShareConfigStore()
|
||||||
@@ -492,9 +505,29 @@ async function createLocalMediaResponse(request: Request, filePath: string): Pro
|
|||||||
|
|
||||||
function createWindow(): void {
|
function createWindow(): void {
|
||||||
// 创建浏览器窗口
|
// 创建浏览器窗口
|
||||||
|
/*
|
||||||
|
* 初始尺寸按当前屏幕工作区的 60% 计算,不再用固定 1400×800。
|
||||||
|
* 两个夹逼都是必要的:
|
||||||
|
* - 下限:小屏上 60% 会算出比原固定值更小的窗口(1920×1080 的工作区高度
|
||||||
|
* 只有 ~985,60% 才 591 高),反而比改动前更糟;高度下限取 900,
|
||||||
|
* 即「高度拉大一点」的诉求。
|
||||||
|
* - 上限:不能超过工作区本身,否则初始尺寸会把标题栏顶出屏幕。
|
||||||
|
*/
|
||||||
|
const { workAreaSize } = screen.getPrimaryDisplay()
|
||||||
|
const initialWidth = Math.min(
|
||||||
|
Math.max(Math.round(workAreaSize.width * 0.6), 1400),
|
||||||
|
workAreaSize.width
|
||||||
|
)
|
||||||
|
const initialHeight = Math.min(
|
||||||
|
Math.max(Math.round(workAreaSize.height * 0.6), 900),
|
||||||
|
workAreaSize.height
|
||||||
|
)
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
width: 1400,
|
width: initialWidth,
|
||||||
height: 800,
|
height: initialHeight,
|
||||||
|
minWidth: 960,
|
||||||
|
minHeight: 640,
|
||||||
|
center: true,
|
||||||
show: false,
|
show: false,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
icon: appIconPath,
|
icon: appIconPath,
|
||||||
@@ -754,6 +787,9 @@ app.whenReady().then(async () => {
|
|||||||
})
|
})
|
||||||
aiSearchPipelineService = new AiSearchPipelineService(knowledgeSearchService, aiProviderService)
|
aiSearchPipelineService = new AiSearchPipelineService(knowledgeSearchService, aiProviderService)
|
||||||
localQueryApiService = new LocalQueryApiService(knowledgeSearchService)
|
localQueryApiService = new LocalQueryApiService(knowledgeSearchService)
|
||||||
|
// 群员统计复用同一个 Knowledge 实例:它只是「读派生库 + 读成员名单」的编排,
|
||||||
|
// 不持有自己的数据库,也不新建索引。
|
||||||
|
groupStatsService = new GroupStatsService(knowledgeSearchService)
|
||||||
// 图片文字索引覆盖度是**独立覆盖维度**:接到 search_messages 的 tool result 上,
|
// 图片文字索引覆盖度是**独立覆盖维度**:接到 search_messages 的 tool result 上,
|
||||||
// 让 Query Agent 在图片索引没做完时不能凭 0 条证据断言"没有"。
|
// 让 Query Agent 在图片索引没做完时不能凭 0 条证据断言"没有"。
|
||||||
localQueryApiService.setImageTextCoverageProvider(() =>
|
localQueryApiService.setImageTextCoverageProvider(() =>
|
||||||
@@ -830,11 +866,6 @@ app.whenReady().then(async () => {
|
|||||||
if (!window.isDestroyed()) window.webContents.send('voice:modelProgress', status)
|
if (!window.isDestroyed()) window.webContents.send('voice:modelProgress', status)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
personalWechatRuntimeManager.setProgressListener((status) => {
|
|
||||||
for (const window of BrowserWindow.getAllWindows()) {
|
|
||||||
if (!window.isDestroyed()) window.webContents.send('wechat-personal:runtimeProgress', status)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
protocol.handle('wxe-media', async (request) => {
|
protocol.handle('wxe-media', async (request) => {
|
||||||
const filePath = videoAssetService?.pathForUrl(request.url)
|
const filePath = videoAssetService?.pathForUrl(request.url)
|
||||||
if (!filePath) return new Response('Not found', { status: 404 })
|
if (!filePath) return new Response('Not found', { status: 404 })
|
||||||
@@ -997,20 +1028,120 @@ app.whenReady().then(async () => {
|
|||||||
return { success: false, error: '应用正在退出,数据库连接已取消', monitoring: false }
|
return { success: false, error: '应用正在退出,数据库连接已取消', monitoring: false }
|
||||||
}
|
}
|
||||||
const wcdb4Client = nextWechatDb.getWcdb4Client()
|
const wcdb4Client = nextWechatDb.getWcdb4Client()
|
||||||
|
/**
|
||||||
|
* 正式 MessageListener(Observation Mode 接入)。
|
||||||
|
*
|
||||||
|
* 本轮**只监听、回读、规范化、去重、统计**,不触发任何业务:
|
||||||
|
* 不匹配关键词、不判断 @我、不出日报、不回复、不调 AI / Agent、不发送。
|
||||||
|
* 下一层的 Trigger 由后续任务接入。
|
||||||
|
*/
|
||||||
|
const messageListener = new MessageListenerService(wcdb4Client)
|
||||||
|
/**
|
||||||
|
* Automation v1(@我生成日报)。
|
||||||
|
*
|
||||||
|
* `isListening` 要等下面 `startMonitor` 有结果才知道,所以先用闭包变量占位。
|
||||||
|
*/
|
||||||
|
let automationListening = false
|
||||||
|
initAutomationService(wcdb4Client, { isListening: () => automationListening })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 旧「定时日报任务」迁移所需的会话解析器。
|
||||||
|
*
|
||||||
|
* **必须在数据库就绪之后注入**:旧 `task.group` 可能是会话 md5、群名或 roomId
|
||||||
|
* 三种形态,只有 `resolveMd5` 能收敛成稳定会话 id。
|
||||||
|
* 注入前若渲染层已经读过一次规则(`automation:listRules`),迁移会**整体推迟**
|
||||||
|
* 而不落盘 —— 注入这一步会立刻补跑(见 `AutomationRuleStore.setLegacyConversationResolver`)。
|
||||||
|
*
|
||||||
|
* 这样设计的原因:拿不到解析器时如果把旧数据判成「无法无损映射」,
|
||||||
|
* 用户的定时日报会被**无辜停用**,而事实只是"数据库还没打开"。
|
||||||
|
*/
|
||||||
|
automationRuleStore.setLegacyConversationResolver((raw) => {
|
||||||
|
const key = String(raw || '').trim()
|
||||||
|
if (!key) return undefined
|
||||||
|
// 已经是稳定会话 id 就直接用,不查库。
|
||||||
|
if (key.endsWith('@chatroom')) return key
|
||||||
|
try {
|
||||||
|
const username = String(chat.resolveMd5(key)?.m_nsUsrName || '').trim()
|
||||||
|
return username || undefined
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* 退群通知的唯一通路:**退群监控只负责产生事件,自动化负责发送**。
|
||||||
|
*
|
||||||
|
* `handleGroupExit` 自己吞掉异常,这里再兜一层 `.catch` 是防御性的 ——
|
||||||
|
* 未捕获的 rejection 会污染监控循环,而退群事实早就已经记录成功了。
|
||||||
|
*/
|
||||||
|
groupExitMonitorService.setGroupExitHandler((event) => {
|
||||||
|
try {
|
||||||
|
return getAutomationService()
|
||||||
|
.handleGroupExit(event)
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn(
|
||||||
|
`[Automation] handleGroupExit failed: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`[Automation] 退群通知未初始化,已跳过本次事件: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
messageListener.onMessage((message) => {
|
||||||
|
// 日志只允许出现「类型 / 群或私聊 / 是否自己发 / @ 数量」这类不可逆标识,
|
||||||
|
// 不含 wxid、昵称、群名、正文与 source。
|
||||||
|
console.log(
|
||||||
|
`[MessageListener] incoming messageType=${message.messageType}` +
|
||||||
|
` group=${message.isGroup} self=${message.isSelf}` +
|
||||||
|
` mentions=${message.mentionTargets.length}`
|
||||||
|
)
|
||||||
|
// Automation 自带 isSelf / cooldown / 同消息幂等三重闸,不会形成回复循环。
|
||||||
|
// 用 try 包住同步那一段:`getAutomationService()` 在未初始化时会抛,
|
||||||
|
// 而 `.catch()` 只能接住异步拒绝 —— 漏了这层就会把异常抛进 MessageListener 的投递循环。
|
||||||
|
try {
|
||||||
|
void getAutomationService()
|
||||||
|
.handleMessage(message)
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn(
|
||||||
|
`[Automation] handleMessage failed: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`[Automation] 未初始化,已跳过本次消息: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
const sessions = await wcdb4Client.getSessionsAsync({ hydrateDisplayNames: false })
|
const sessions = await wcdb4Client.getSessionsAsync({ hydrateDisplayNames: false })
|
||||||
configureRecallProtection(wcdb4Client, resolvedRoot, settings.recallProtectionEnabled)
|
configureRecallProtection(wcdb4Client, resolvedRoot, settings.recallProtectionEnabled)
|
||||||
voiceService = new VoiceService(wcdb4Client, resolvedRoot)
|
voiceService = new VoiceService(wcdb4Client, resolvedRoot)
|
||||||
voiceRecognition?.connect(voiceService, resolvedRoot)
|
voiceRecognition?.connect(voiceService, resolvedRoot)
|
||||||
stickerService = new StickerService(wcdb4Client)
|
stickerService = new StickerService(wcdb4Client)
|
||||||
videoAssetService = new VideoAssetService(wcdb4Client)
|
videoAssetService = new VideoAssetService(wcdb4Client)
|
||||||
const monitoring = await wcdb4Client.startMonitor((type, json) => {
|
const monitoring = await wcdb4Client.startMonitor((event) => {
|
||||||
wcdb4Client.invalidateSessionCache()
|
wcdb4Client.invalidateSessionCache()
|
||||||
groupExitMonitorService.notifyDatabaseChanged(json)
|
// 正式 MessageListener:只做 coalesce + 有界回读 + dedup + 投递,不触发业务。
|
||||||
recallArchiveMonitor?.handleDatabaseChange(json)
|
// v2 事件带 sessionId ⇒ 回读不再依赖「最近活跃会话」。
|
||||||
|
messageListener.handleNativeChange(event)
|
||||||
|
groupExitMonitorService.notifyDatabaseChanged(event.raw)
|
||||||
|
recallArchiveMonitor?.handleDatabaseChange(event.raw)
|
||||||
for (const window of BrowserWindow.getAllWindows()) {
|
for (const window of BrowserWindow.getAllWindows()) {
|
||||||
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
|
if (!window.isDestroyed()) {
|
||||||
|
window.webContents.send('wcdb-change', { type: event.type, json: event.raw })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
automationListening = monitoring === true
|
||||||
void groupExitMonitorService.start(monitoring)
|
void groupExitMonitorService.start(monitoring)
|
||||||
const recentSession = sessions[0]
|
const recentSession = sessions[0]
|
||||||
if (recentSession?.username) {
|
if (recentSession?.username) {
|
||||||
@@ -1152,7 +1283,54 @@ app.whenReady().then(async () => {
|
|||||||
)
|
)
|
||||||
// 日报等系统动作仍复用现有发送服务;普通聊天不再暴露这个入口。
|
// 日报等系统动作仍复用现有发送服务;普通聊天不再暴露这个入口。
|
||||||
ipcMain.handle('wechat-personal:send', async (_, request: PersonalWechatSendRequest) => {
|
ipcMain.handle('wechat-personal:send', async (_, request: PersonalWechatSendRequest) => {
|
||||||
if (request.type !== 'voice' || String(request.fromId || '').trim()) {
|
// 项目规则:**所有发送都必须经过 WechatActionGateway**(审计 + 幂等 + 同一个 Send Log)。
|
||||||
|
// 手动发送在改造前从这里直连 `PersonalWechatSendService`,于是完全不留痕 ——
|
||||||
|
// 排查「消息到底发没发出去」时恰好缺的就是那份证据。
|
||||||
|
// 普通手动发送保持 triggerType=user;带 postfixText 的日报图片由 Gateway 编排成
|
||||||
|
// 两个 automation action,以复用同一条 3s 队列,并严格在图片 sent 后才发后置词。
|
||||||
|
if (request.type === 'text' || request.type === 'image') {
|
||||||
|
const to = String(request.to || '').trim()
|
||||||
|
const recipient = {
|
||||||
|
type:
|
||||||
|
request.isGroup || to.endsWith('@chatroom') ? ('group' as const) : ('contact' as const),
|
||||||
|
id: to
|
||||||
|
}
|
||||||
|
if (request.type === 'image' && request.postfixText !== undefined) {
|
||||||
|
const sequence = await wechatActionGateway.executeReportImageSequence({
|
||||||
|
recipient,
|
||||||
|
imagePath: String(request.filePath || ''),
|
||||||
|
postfixText: request.postfixText
|
||||||
|
})
|
||||||
|
const imageResult = toPersonalWechatSendResult(
|
||||||
|
sequence.image,
|
||||||
|
await currentPersonalWechatSenderStatus()
|
||||||
|
)
|
||||||
|
if (!imageResult.success || !sequence.postfix) return imageResult
|
||||||
|
return {
|
||||||
|
...imageResult,
|
||||||
|
postfixSent: sequence.postfix.status === 'sent',
|
||||||
|
...(sequence.postfix.status === 'sent'
|
||||||
|
? {}
|
||||||
|
: { postfixError: sequence.postfix.reason || '后置词发送失败' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const action = await wechatActionGateway.execute({
|
||||||
|
origin: 'user_manual',
|
||||||
|
purpose: request.type === 'text' ? 'manual_text' : 'manual_image',
|
||||||
|
triggerType: 'user',
|
||||||
|
recipient,
|
||||||
|
content:
|
||||||
|
request.type === 'text'
|
||||||
|
? { type: 'text', text: String(request.text || '') }
|
||||||
|
: { type: 'image', path: String(request.filePath || '') }
|
||||||
|
})
|
||||||
|
// 返回契约保持 `PersonalWechatSendResult`,界面判读不用改。
|
||||||
|
return toPersonalWechatSendResult(action, await currentPersonalWechatSenderStatus())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 语音仍走既有分支:它有自己的网关入口 `wechat-personal:sendGeneratedTtsVoice`,
|
||||||
|
// 且需要先解析当前账号 wxid 才能编码。
|
||||||
|
if (String(request.fromId || '').trim()) {
|
||||||
return personalWechatSendService.send(request)
|
return personalWechatSendService.send(request)
|
||||||
}
|
}
|
||||||
let fromId = ''
|
let fromId = ''
|
||||||
@@ -1469,31 +1647,153 @@ app.whenReady().then(async () => {
|
|||||||
return snapshot
|
return snapshot
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群员统计(单群)。
|
||||||
|
*
|
||||||
|
* 时间单位刻意用 epoch **毫秒**:这一路完全走 Knowledge,而 Knowledge 内部口径就是毫秒。
|
||||||
|
* 沿用聊天消息的秒级口径会在 service 内部凭空多出一次换算 —— 而单位换错是**静默读 0 条**,
|
||||||
|
* 不会报错。
|
||||||
|
*/
|
||||||
|
ipcMain.handle(
|
||||||
|
'group-stats:getMemberStats',
|
||||||
|
async (_, request: { userMd5?: unknown; startTime?: unknown; endTime?: unknown }) => {
|
||||||
|
if (!groupStatsService) throw new Error('群员统计服务尚未就绪')
|
||||||
|
const userMd5 = String(request?.userMd5 || '').trim()
|
||||||
|
if (!userMd5) throw new Error('缺少会话标识')
|
||||||
|
const startTime = Number(request?.startTime)
|
||||||
|
const endTime = Number(request?.endTime)
|
||||||
|
if (!Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime < startTime) {
|
||||||
|
throw new Error('统计时间范围无效')
|
||||||
|
}
|
||||||
|
return groupStatsService.getMemberStats({ userMd5, startTime, endTime })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
ipcMain.handle('group-exit-monitor:getState', () => groupExitMonitorService.getState())
|
ipcMain.handle('group-exit-monitor:getState', () => groupExitMonitorService.getState())
|
||||||
ipcMain.handle('group-exit-monitor:setEnabled', (_, enabled: boolean) =>
|
ipcMain.handle('group-exit-monitor:setEnabled', (_, enabled: boolean) =>
|
||||||
groupExitMonitorService.setEnabled(enabled === true)
|
groupExitMonitorService.setEnabled(enabled === true)
|
||||||
)
|
)
|
||||||
ipcMain.handle(
|
/**
|
||||||
'group-exit-monitor:setGroups',
|
* 保存**监控范围**。
|
||||||
(_, roomIds: string[], notificationRoomIds?: string[]) =>
|
*
|
||||||
groupExitMonitorService.setMonitoredRoomIds(
|
* 只有一个参数:旧版第二个参数(通知群聊)已经迁到「自动化 → 退群通知」,
|
||||||
Array.isArray(roomIds) ? roomIds : [],
|
* 退群监控不再持有任何通知配置。
|
||||||
Array.isArray(notificationRoomIds) ? notificationRoomIds : []
|
*/
|
||||||
)
|
ipcMain.handle('group-exit-monitor:setGroups', (_, roomIds: string[]) =>
|
||||||
)
|
groupExitMonitorService.setMonitoredRoomIds(Array.isArray(roomIds) ? roomIds : [])
|
||||||
ipcMain.handle('group-exit-monitor:setTemplate', (_, template: unknown) =>
|
|
||||||
groupExitMonitorService.setNotificationTemplate(template)
|
|
||||||
)
|
)
|
||||||
ipcMain.handle('group-exit-monitor:checkNow', () => groupExitMonitorService.checkNow())
|
ipcMain.handle('group-exit-monitor:checkNow', () => groupExitMonitorService.checkNow())
|
||||||
|
/**
|
||||||
|
* 按群查退群事件(档案合并展示用)。
|
||||||
|
*
|
||||||
|
* 时间参数是 epoch **毫秒**,与事件的 `detectedAt` 同口径。
|
||||||
|
*/
|
||||||
|
ipcMain.handle('group-exit-monitor:listEvents', (_, query: unknown) => {
|
||||||
|
const input = (query || {}) as {
|
||||||
|
roomId?: unknown
|
||||||
|
sinceMs?: unknown
|
||||||
|
untilMs?: unknown
|
||||||
|
limit?: unknown
|
||||||
|
}
|
||||||
|
return groupExitMonitorService.listEvents({
|
||||||
|
roomId: typeof input.roomId === 'string' ? input.roomId : undefined,
|
||||||
|
sinceMs: Number(input.sinceMs),
|
||||||
|
untilMs: Number(input.untilMs),
|
||||||
|
limit: Number(input.limit)
|
||||||
|
})
|
||||||
|
})
|
||||||
ipcMain.handle('group-exit-monitor:clearEvents', () => groupExitMonitorService.clearEvents())
|
ipcMain.handle('group-exit-monitor:clearEvents', () => groupExitMonitorService.clearEvents())
|
||||||
ipcMain.handle('group-exit-monitor:resendEvent', (_, eventId: string) =>
|
|
||||||
groupExitMonitorService.resendEvent(eventId)
|
|
||||||
)
|
|
||||||
ipcMain.handle('group-exit-monitor:markRead', (_, readAt?: number) =>
|
ipcMain.handle('group-exit-monitor:markRead', (_, readAt?: number) =>
|
||||||
groupExitMonitorService.markRead(readAt)
|
groupExitMonitorService.markRead(readAt)
|
||||||
)
|
)
|
||||||
ipcMain.handle('wechat-action-log:list', () => wechatActionLogService.list())
|
ipcMain.handle('wechat-action-log:list', () => wechatActionLogService.list())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automation v1(@我生成日报)。
|
||||||
|
*
|
||||||
|
* 规则与执行日志都是纯文件存储,**不依赖数据库**,所以这两组 handler 在数据库
|
||||||
|
* 解锁前也可以安全调用;只有 `getStatus` / `listGroups` 需要会话数据,因此用
|
||||||
|
* `tryAutomationService()` 兜底,避免渲染层在启动阶段拿到一个 rejected promise。
|
||||||
|
*/
|
||||||
|
const tryAutomationService = (): ReturnType<typeof getAutomationService> | null => {
|
||||||
|
try {
|
||||||
|
return getAutomationService()
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ipcMain.handle('automation:listRules', () => automationRuleStore.listRules())
|
||||||
|
ipcMain.handle('automation:createRule', (_, draft: unknown) =>
|
||||||
|
automationRuleStore.createRule(draft)
|
||||||
|
)
|
||||||
|
ipcMain.handle('automation:updateRule', (_, input: unknown) => {
|
||||||
|
const payload = (input || {}) as { id?: unknown; draft?: unknown }
|
||||||
|
return automationRuleStore.updateRule(String(payload.id || ''), payload.draft) ?? null
|
||||||
|
})
|
||||||
|
ipcMain.handle('automation:deleteRule', (_, id: string) => automationRuleStore.deleteRule(id))
|
||||||
|
ipcMain.handle('automation:setRuleEnabled', (_, input: unknown) => {
|
||||||
|
const payload = (input || {}) as { id?: unknown; enabled?: unknown }
|
||||||
|
return (
|
||||||
|
automationRuleStore.setRuleEnabled(String(payload.id || ''), payload.enabled === true) ?? null
|
||||||
|
)
|
||||||
|
})
|
||||||
|
ipcMain.handle('automation:listExecutions', (_, query: unknown) => {
|
||||||
|
const input = (query || {}) as { limit?: unknown }
|
||||||
|
return automationExecutionLogService.list({ limit: Number(input.limit) })
|
||||||
|
})
|
||||||
|
ipcMain.handle('automation:clearExecutions', () => automationExecutionLogService.clear())
|
||||||
|
ipcMain.handle('automation:listGroups', () => tryAutomationService()?.listGroups() ?? [])
|
||||||
|
/**
|
||||||
|
* 保存「退群通知」规则(singleton upsert)。
|
||||||
|
*
|
||||||
|
* 单独一个通道而不是复用 `updateRule`:这条规则是系统规则,
|
||||||
|
* 保存语义是"存在即更新、不存在即创建",且 id 固定 —— 不允许渲染层自己拼 id。
|
||||||
|
*/
|
||||||
|
ipcMain.handle('automation:saveLeaveNotificationRule', (_, draft: unknown) =>
|
||||||
|
automationRuleStore.saveLeaveNotificationRule(draft)
|
||||||
|
)
|
||||||
|
/**
|
||||||
|
* 「指定好友」的可选项。
|
||||||
|
*
|
||||||
|
* 过滤(群聊 / 公众号 / 文件传输助手 / 自己)在 main 侧完成 ——
|
||||||
|
* 只有这里知道当前登录账号是谁,渲染层再筛一遍必然会漂移。
|
||||||
|
*/
|
||||||
|
ipcMain.handle('automation:listSendableContacts', () => {
|
||||||
|
try {
|
||||||
|
const selfWxid = String(chat.getSelfAccountInfo()?.wxid || '')
|
||||||
|
return filterSendableFriendContacts(chat.listContacts(), selfWxid).map((contact) => ({
|
||||||
|
id: String(contact.m_nsUsrName || ''),
|
||||||
|
name: leaveNotificationContactDisplayName(contact)
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`[Automation] 读取可选联系人失败: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ipcMain.handle('automation:getStatus', async () => {
|
||||||
|
const service = tryAutomationService()
|
||||||
|
if (service) return service.getStatus()
|
||||||
|
// 数据库尚未就绪:如实返回「未监听 + 能力未知」,而不是假装一切正常。
|
||||||
|
const todayStart = new Date()
|
||||||
|
todayStart.setHours(0, 0, 0, 0)
|
||||||
|
const counts = automationExecutionLogService.countSince(todayStart.getTime())
|
||||||
|
return {
|
||||||
|
listening: false,
|
||||||
|
listeningDegraded: true,
|
||||||
|
todayExecutions: counts.total,
|
||||||
|
todaySuccesses: counts.success,
|
||||||
|
sendCapability: {
|
||||||
|
supported: false,
|
||||||
|
ready: false,
|
||||||
|
canSendText: false,
|
||||||
|
canSendImage: false,
|
||||||
|
message: '数据库尚未就绪,暂时无法获知微信发送能力'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('db:search', (_, keyword: string) => chat.searchMessages(keyword))
|
ipcMain.handle('db:search', (_, keyword: string) => chat.searchMessages(keyword))
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'knowledge:search',
|
'knowledge:search',
|
||||||
@@ -1713,44 +2013,58 @@ app.whenReady().then(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('wechat-personal:getSendCapability', () => getPersonalWechatSendCapability())
|
ipcMain.handle('wechat-personal:getSendCapability', () => getPersonalWechatSendCapability())
|
||||||
ipcMain.handle('scheduled-report:list', () => scheduledReportService.listTasks())
|
|
||||||
ipcMain.handle('scheduled-report:listExecutions', (_, taskId?: string) =>
|
/**
|
||||||
scheduledReportService.listExecutions(taskId)
|
* 定时日报(Automation 的 `scheduled_report` 规则类型)。
|
||||||
|
*
|
||||||
|
* 规则本身的 CRUD 走上面的 `automation:*`;这里只补三块**专属**能力:
|
||||||
|
* 1. 「立即执行」—— 走 `AutomationService.executeScheduledRule(ruleId,'manual')`,
|
||||||
|
* 与 scheduler 触发**同一条链路**(生成 → 落库 → 解析目标 → Gateway 发图);
|
||||||
|
* 2. 微信异常通知 —— 定时日报失败时的 Agent Hub 推送,随功能保留;
|
||||||
|
* 3. 旧执行记录**只读存档** —— 旧记录无法无损转换,原样保留给 UI 展示。
|
||||||
|
*/
|
||||||
|
ipcMain.handle('automation:runScheduledReportRule', async (_, ruleId: string) => {
|
||||||
|
const service = tryAutomationService()
|
||||||
|
if (!service) return { success: false, error: '数据库尚未就绪,暂时无法执行定时日报' }
|
||||||
|
const outcome = await service.executeScheduledRule(String(ruleId || ''), { trigger: 'manual' })
|
||||||
|
if (outcome.executed) {
|
||||||
|
return { success: outcome.status !== 'failed', data: outcome }
|
||||||
|
}
|
||||||
|
// 「没执行」的每一种原因都要能翻译成用户看得懂的一句话 —— 否则点了按钮没反应,无从排查。
|
||||||
|
const skipMessages: Record<string, string> = {
|
||||||
|
missing_rule: '规则 id 无效',
|
||||||
|
rule_not_found: '未找到这条定时日报规则',
|
||||||
|
disabled: '这条定时日报已停用,请先启用再执行',
|
||||||
|
target_needs_review: '这条规则的发送目标需要重新选择后才能执行',
|
||||||
|
in_flight: '这条规则正在执行中,请稍后再试',
|
||||||
|
store_error: '读取规则失败,请稍后再试'
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: skipMessages[outcome.reason || ''] || '定时日报未执行',
|
||||||
|
data: outcome
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ipcMain.handle('automation:listScheduledReportLegacyExecutions', (_, ruleId?: string) =>
|
||||||
|
scheduledReportService.listLegacyExecutions(ruleId)
|
||||||
)
|
)
|
||||||
ipcMain.handle('scheduled-report:getNotificationSettings', () =>
|
ipcMain.handle('automation:getScheduledReportNotificationSettings', () =>
|
||||||
scheduledReportService.getNotificationSettings()
|
scheduledReportService.getNotificationSettings()
|
||||||
)
|
)
|
||||||
ipcMain.handle('scheduled-report:setNotificationEnabled', (_, enabled: boolean) =>
|
ipcMain.handle('automation:getScheduledReportNotificationCapability', () =>
|
||||||
|
scheduledReportService.checkNotificationCapability()
|
||||||
|
)
|
||||||
|
ipcMain.handle('automation:setScheduledReportNotificationEnabled', (_, enabled: boolean) =>
|
||||||
scheduledReportService.setNotificationEnabled(Boolean(enabled))
|
scheduledReportService.setNotificationEnabled(Boolean(enabled))
|
||||||
)
|
)
|
||||||
ipcMain.handle('scheduled-report:create', (_, request: ScheduledReportCreateInput) =>
|
ipcMain.handle('automation:testScheduledReportErrorNotification', (_, ruleId: string) => {
|
||||||
scheduledReportService.createTask(request)
|
|
||||||
)
|
|
||||||
ipcMain.handle(
|
|
||||||
'scheduled-report:update',
|
|
||||||
(_, taskId: string, request: ScheduledReportUpdateInput) =>
|
|
||||||
scheduledReportService.updateTask(taskId, request)
|
|
||||||
)
|
|
||||||
ipcMain.handle('scheduled-report:delete', (_, taskId: string) =>
|
|
||||||
scheduledReportService.deleteTask(taskId)
|
|
||||||
)
|
|
||||||
ipcMain.handle('scheduled-report:setEnabled', (_, taskId: string, enabled: boolean) =>
|
|
||||||
scheduledReportService.setTaskEnabled(taskId, Boolean(enabled))
|
|
||||||
)
|
|
||||||
ipcMain.handle('scheduled-report:runNow', (_, taskId: string) =>
|
|
||||||
scheduledReportService.runScheduledReportNow(taskId)
|
|
||||||
)
|
|
||||||
ipcMain.handle('scheduled-report:retrySend', (_, executionId: string) =>
|
|
||||||
scheduledReportService.retryScheduledReportSend(executionId)
|
|
||||||
)
|
|
||||||
ipcMain.handle('scheduled-report:testErrorNotification', (_, taskId: string) => {
|
|
||||||
if (!isTruthyDebugFlag(import.meta.env.VITE_SCHEDULED_REPORT_DEBUG)) {
|
if (!isTruthyDebugFlag(import.meta.env.VITE_SCHEDULED_REPORT_DEBUG)) {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
success: false,
|
success: false,
|
||||||
error: '调试测试按钮未开启,请在 .env 中设置 VITE_SCHEDULED_REPORT_DEBUG=true。'
|
error: '调试测试按钮未开启,请在 .env 中设置 VITE_SCHEDULED_REPORT_DEBUG=true。'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return scheduledReportService.testScheduledReportErrorNotification(taskId)
|
return scheduledReportService.testScheduledReportErrorNotification(ruleId)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('report:reveal', async (_, filePath: string) => {
|
ipcMain.handle('report:reveal', async (_, filePath: string) => {
|
||||||
@@ -2216,11 +2530,13 @@ app.whenReady().then(async () => {
|
|||||||
if (client) {
|
if (client) {
|
||||||
voiceService = new VoiceService(client, client.getAccountRoot())
|
voiceService = new VoiceService(client, client.getAccountRoot())
|
||||||
voiceRecognition?.connect(voiceService, client.getAccountRoot())
|
voiceRecognition?.connect(voiceService, client.getAccountRoot())
|
||||||
const monitoring = await client.startMonitor((type, json) => {
|
const monitoring = await client.startMonitor((event) => {
|
||||||
client.invalidateSessionCache()
|
client.invalidateSessionCache()
|
||||||
groupExitMonitorService.notifyDatabaseChanged(json)
|
groupExitMonitorService.notifyDatabaseChanged(event.raw)
|
||||||
for (const window of BrowserWindow.getAllWindows()) {
|
for (const window of BrowserWindow.getAllWindows()) {
|
||||||
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
|
if (!window.isDestroyed()) {
|
||||||
|
window.webContents.send('wcdb-change', { type: event.type, json: event.raw })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
void groupExitMonitorService.start(monitoring)
|
void groupExitMonitorService.start(monitoring)
|
||||||
@@ -2345,42 +2661,16 @@ app.whenReady().then(async () => {
|
|||||||
agentHubService.clearConversations()
|
agentHubService.clearConversations()
|
||||||
return { success: true }
|
return { success: true }
|
||||||
})
|
})
|
||||||
ipcMain.handle('wechat-personal:getStatus', () => personalWechatSendService.getStatus())
|
ipcMain.handle('wechat-personal:getStatus', () => currentPersonalWechatSenderStatus())
|
||||||
ipcMain.handle('wechat-personal:getKeepProcess', () =>
|
|
||||||
personalWechatSendService.getKeepOneBotProcess()
|
|
||||||
)
|
|
||||||
ipcMain.handle('wechat-personal:setKeepProcess', (_, keep: boolean) =>
|
|
||||||
personalWechatSendService.setKeepOneBotProcess(Boolean(keep))
|
|
||||||
)
|
|
||||||
ipcMain.handle('wechat-personal:checkStatus', (_, port?: string) =>
|
ipcMain.handle('wechat-personal:checkStatus', (_, port?: string) =>
|
||||||
personalWechatSendService.checkWindowsStatus(port)
|
personalWechatSendService.checkWindowsStatus(port)
|
||||||
)
|
)
|
||||||
ipcMain.handle('wechat-personal:checkVoiceEnvironment', () =>
|
ipcMain.handle('wechat-personal:checkVoiceEnvironment', () =>
|
||||||
personalWechatVoiceEnvironmentService.check()
|
personalWechatVoiceEnvironmentService.check()
|
||||||
)
|
)
|
||||||
ipcMain.handle('wechat-personal:installPilk', async () => {
|
ipcMain.handle('wechat-personal:installPilk', () =>
|
||||||
const result = await personalWechatVoiceEnvironmentService.installPilk()
|
personalWechatVoiceEnvironmentService.installPilk()
|
||||||
if (!result.success || process.platform !== 'darwin') return result
|
)
|
||||||
|
|
||||||
const senderStatus = await personalWechatSendService.getStatus()
|
|
||||||
if (!senderStatus.oneBotPid) return result
|
|
||||||
try {
|
|
||||||
const restartedStatus = await personalWechatSendService.restartRuntime()
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
restarted: restartedStatus.state !== 'error',
|
|
||||||
...(restartedStatus.state === 'error'
|
|
||||||
? { restartError: restartedStatus.error || restartedStatus.message }
|
|
||||||
: {})
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
restarted: false,
|
|
||||||
restartError: error instanceof Error ? error.message : String(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
ipcMain.handle('wechat-personal:openVoicePythonDownload', async () => {
|
ipcMain.handle('wechat-personal:openVoicePythonDownload', async () => {
|
||||||
try {
|
try {
|
||||||
await shell.openExternal('https://www.python.org/downloads/macos/')
|
await shell.openExternal('https://www.python.org/downloads/macos/')
|
||||||
@@ -2397,23 +2687,20 @@ app.whenReady().then(async () => {
|
|||||||
return { success: false, error: '无法打开 FFmpeg 安装页面' }
|
return { success: false, error: '无法打开 FFmpeg 安装页面' }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
ipcMain.handle('wechat-personal:getRuntimeStatus', () => personalWechatRuntimeManager.getStatus())
|
ipcMain.handle('wechat-personal:rebind', async () => {
|
||||||
ipcMain.handle('wechat-personal:downloadRuntime', () => personalWechatRuntimeManager.download())
|
if (process.platform !== 'darwin') return personalWechatSendService.rebind()
|
||||||
ipcMain.handle('wechat-personal:cancelRuntimeDownload', () => ({
|
|
||||||
success: personalWechatRuntimeManager.cancelDownload()
|
const result = await macWechatRuntimeManager.bind()
|
||||||
}))
|
const status = await macWechatRuntimeManager.buildSenderStatus()
|
||||||
ipcMain.handle('wechat-personal:removeRuntime', async () => {
|
if (result.ok) return status
|
||||||
await personalWechatSendService.terminate(true)
|
|
||||||
return personalWechatRuntimeManager.remove()
|
return {
|
||||||
|
...status,
|
||||||
|
state: 'error',
|
||||||
|
message: result.message,
|
||||||
|
error: result.message
|
||||||
|
}
|
||||||
})
|
})
|
||||||
ipcMain.handle('wechat-personal:openRuntimeDirectory', async () => {
|
|
||||||
const status = await personalWechatRuntimeManager.getStatus()
|
|
||||||
const directory = status.directory || personalWechatRuntimeManager.directory
|
|
||||||
await fsPromises.mkdir(directory, { recursive: true })
|
|
||||||
const error = await shell.openPath(directory)
|
|
||||||
return error ? { success: false, error } : { success: true }
|
|
||||||
})
|
|
||||||
ipcMain.handle('wechat-personal:rebind', () => personalWechatSendService.rebind())
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'wechat-personal:sendGeneratedTtsVoice',
|
'wechat-personal:sendGeneratedTtsVoice',
|
||||||
async (_, request: PersonalWechatGeneratedTtsVoiceRequest) => {
|
async (_, request: PersonalWechatGeneratedTtsVoiceRequest) => {
|
||||||
@@ -2441,7 +2728,7 @@ app.whenReady().then(async () => {
|
|||||||
action.sendResult && typeof action.sendResult === 'object'
|
action.sendResult && typeof action.sendResult === 'object'
|
||||||
? (action.sendResult as PersonalWechatSendResult)
|
? (action.sendResult as PersonalWechatSendResult)
|
||||||
: undefined
|
: undefined
|
||||||
const status = sendResult?.status || (await personalWechatSendService.getStatus())
|
const status = sendResult?.status || (await currentPersonalWechatSenderStatus())
|
||||||
return { action, status }
|
return { action, status }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -2516,9 +2803,21 @@ app.on('before-quit', (event) => {
|
|||||||
chat.closeChatDbForQuit().catch(() => false),
|
chat.closeChatDbForQuit().catch(() => false),
|
||||||
voiceRecognition?.dispose().catch(() => undefined),
|
voiceRecognition?.dispose().catch(() => undefined),
|
||||||
knowledgeSearchService?.dispose().catch(() => undefined),
|
knowledgeSearchService?.dispose().catch(() => undefined),
|
||||||
personalWechatSendService.terminate().catch((error) => {
|
/*
|
||||||
console.warn('[Shutdown] personal WeChat sender cleanup failed:', error)
|
* Deliberately do NOT stop the macOS native runtime here.
|
||||||
})
|
*
|
||||||
|
* The host is designed to outlive TraceMemo ("保留发送能力进程"): keeping
|
||||||
|
* it alive preserves the bound frida session, so relaunching the app —
|
||||||
|
* including every dev-mode restart — re-adopts it and works immediately
|
||||||
|
* instead of forcing the user to bind WeChat again.
|
||||||
|
*
|
||||||
|
* Stopping it on quit threw that away and, worse, gave teardown a chance
|
||||||
|
* to hang mid-unload while the app was already exiting. The host has its
|
||||||
|
* own lifecycle instead: it exits itself once WeChat is gone ("stale" ->
|
||||||
|
* self-shutdown), and it can be restarted explicitly from the settings
|
||||||
|
* card ("重新加载组件").
|
||||||
|
*/
|
||||||
|
Promise.resolve()
|
||||||
])
|
])
|
||||||
if (!nativeCallsDrained) {
|
if (!nativeCallsDrained) {
|
||||||
console.warn('[Shutdown] WCDB async calls did not fully drain before quit')
|
console.warn('[Shutdown] WCDB async calls did not fully drain before quit')
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
KnowledgeImageOcrState,
|
KnowledgeImageOcrState,
|
||||||
KnowledgeAttachmentMetadata,
|
KnowledgeAttachmentMetadata,
|
||||||
KnowledgeEvidence,
|
KnowledgeEvidence,
|
||||||
|
KnowledgeMemberStatsResult,
|
||||||
KnowledgeMessageKind,
|
KnowledgeMessageKind,
|
||||||
KnowledgePassProgress,
|
KnowledgePassProgress,
|
||||||
KnowledgeRuntimeState,
|
KnowledgeRuntimeState,
|
||||||
@@ -498,6 +499,37 @@ export class KnowledgeSearchService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单群发言聚合(群员统计的数据来源)。
|
||||||
|
*
|
||||||
|
* 这一层只**如实**返回引擎能给出的东西(含 `indexLatestAt`),不做追赶决策 ——
|
||||||
|
* 「要不要等索引、要不要把结果标成不完整」是产品判断,属于 GroupStatsService。
|
||||||
|
* 同时返回一次 `sourceLatestAt`(同步、零额外 WCDB 调用),让调用方一次拿到
|
||||||
|
* freshness 的两个口径,不必再发一次 status 请求。
|
||||||
|
*/
|
||||||
|
async memberStats(request: {
|
||||||
|
conversationId: string
|
||||||
|
startTime: number
|
||||||
|
endTime: number
|
||||||
|
}): Promise<{ result: KnowledgeMemberStatsResult | null; sourceLatestAt: number | null }> {
|
||||||
|
const sourceLatestAt = this.sourceLatestAt()
|
||||||
|
const accountId = this.currentAccountId()
|
||||||
|
if (!accountId) return { result: null, sourceLatestAt }
|
||||||
|
try {
|
||||||
|
const result = await this.service.memberStats({
|
||||||
|
accountId,
|
||||||
|
fts: DEFAULT_KNOWLEDGE_FTS_CONFIG,
|
||||||
|
conversationId: request.conversationId,
|
||||||
|
startTime: request.startTime,
|
||||||
|
endTime: request.endTime
|
||||||
|
})
|
||||||
|
return { result, sourceLatestAt }
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Knowledge] member stats failed:', error)
|
||||||
|
return { result: null, sourceLatestAt }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 源数据最新活跃时间(epoch ms)。派生索引看到不源数据,freshness 判定由它 + `indexLatestAt` 组成。
|
* 源数据最新活跃时间(epoch ms)。派生索引看到不源数据,freshness 判定由它 + `indexLatestAt` 组成。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type {
|
|||||||
KnowledgeIndexProgress,
|
KnowledgeIndexProgress,
|
||||||
KnowledgeIndexRequest,
|
KnowledgeIndexRequest,
|
||||||
KnowledgeIndexResult,
|
KnowledgeIndexResult,
|
||||||
|
KnowledgeMemberStatsRequest,
|
||||||
|
KnowledgeMemberStatsResult,
|
||||||
KnowledgeRuntimeStatus,
|
KnowledgeRuntimeStatus,
|
||||||
KnowledgeSearchRequest,
|
KnowledgeSearchRequest,
|
||||||
KnowledgeSearchResult,
|
KnowledgeSearchResult,
|
||||||
@@ -53,6 +55,13 @@ export class KnowledgeService {
|
|||||||
return this.worker.highWaterMarks({ ...request, databaseRoot: this.databaseRoot })
|
return this.worker.highWaterMarks({ ...request, databaseRoot: this.databaseRoot })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 单个会话内「按发送者聚合」的发言统计(群员统计用)。 */
|
||||||
|
memberStats(
|
||||||
|
request: Omit<KnowledgeMemberStatsRequest, 'databaseRoot'>
|
||||||
|
): Promise<KnowledgeMemberStatsResult> {
|
||||||
|
return this.worker.memberStats({ ...request, databaseRoot: this.databaseRoot })
|
||||||
|
}
|
||||||
|
|
||||||
/** 只中止正在跑的索引任务;查询请求不受影响。 */
|
/** 只中止正在跑的索引任务;查询请求不受影响。 */
|
||||||
cancelIndex(): Promise<boolean> {
|
cancelIndex(): Promise<boolean> {
|
||||||
return this.worker.cancelActiveIndex()
|
return this.worker.cancelActiveIndex()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
KnowledgeIndexProgress,
|
KnowledgeIndexProgress,
|
||||||
KnowledgeIndexRequest,
|
KnowledgeIndexRequest,
|
||||||
KnowledgeIndexResult,
|
KnowledgeIndexResult,
|
||||||
|
KnowledgeMemberStatsResult,
|
||||||
KnowledgeRuntimeStatus,
|
KnowledgeRuntimeStatus,
|
||||||
KnowledgeNormalizedMessage,
|
KnowledgeNormalizedMessage,
|
||||||
KnowledgeQuery,
|
KnowledgeQuery,
|
||||||
@@ -317,6 +318,93 @@ export class KnowledgeStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个会话在时间窗内的「按发送者聚合」统计。
|
||||||
|
*
|
||||||
|
* 只回聚合结果、不回消息正文:群员统计只需要「谁说了几条、最后一条是什么时候」,
|
||||||
|
* 把消息逐条搬到主进程再统计会把几十万行推过 IPC 边界。
|
||||||
|
*
|
||||||
|
* 三条硬规则**全部由 SQL 保证**,不指望调用方记得:
|
||||||
|
* 1. `kind <> 'system'`:系统消息不是任何成员的发言(微信侧 10000/10002 在建库时
|
||||||
|
* 已归一为 `kind = 'system'`,见 `knowledge-search-service` 的 kind 映射);
|
||||||
|
* 2. `sender_id IS NULL` 的行不归给任何人,只计入 `unattributedMessages` ——
|
||||||
|
* 硬塞给某个成员会让「未发言」名单出现错误否定;
|
||||||
|
* 3. 时间窗口是**闭区间**,单位 **epoch 毫秒**,与 knowledge 内部口径一致,
|
||||||
|
* 不经过 WCDB 的秒级边界(跨错单位会静默读到 0 条)。
|
||||||
|
*/
|
||||||
|
memberStats(request: {
|
||||||
|
conversationId: string
|
||||||
|
startTime: number
|
||||||
|
endTime: number
|
||||||
|
}): KnowledgeMemberStatsResult {
|
||||||
|
const { conversationId, startTime, endTime } = request
|
||||||
|
const indexLatestAt = this.readIndexLatestAt()
|
||||||
|
const empty: KnowledgeMemberStatsResult = {
|
||||||
|
conversationId,
|
||||||
|
totalMessages: 0,
|
||||||
|
senders: [],
|
||||||
|
unattributedMessages: 0,
|
||||||
|
excludedSystemMessages: 0,
|
||||||
|
earliestMessageTime: null,
|
||||||
|
indexLatestAt
|
||||||
|
}
|
||||||
|
if (!conversationId) return empty
|
||||||
|
|
||||||
|
// 用 `(conversation_id, create_time)` 索引直接命中:这是本查询唯一的访问路径,
|
||||||
|
// 写成全表扫描等价于把单群统计的 26ms 变成 10s。
|
||||||
|
const scope = 'conversation_id = ? AND create_time >= ? AND create_time <= ?'
|
||||||
|
const args = [conversationId, startTime, endTime]
|
||||||
|
|
||||||
|
const count = (extra: string): number => {
|
||||||
|
const row = this.database
|
||||||
|
.prepare(`SELECT COUNT(*) AS n FROM knowledge_messages WHERE ${scope} AND ${extra}`)
|
||||||
|
.get(...args) as DbRow | undefined
|
||||||
|
return Number(row?.n) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = asRows(
|
||||||
|
this.database
|
||||||
|
.prepare(
|
||||||
|
`SELECT sender_id,
|
||||||
|
COUNT(*) AS message_count,
|
||||||
|
MAX(create_time) AS last_message_time
|
||||||
|
FROM knowledge_messages
|
||||||
|
WHERE ${scope} AND kind <> 'system' AND sender_id IS NOT NULL
|
||||||
|
GROUP BY sender_id
|
||||||
|
ORDER BY message_count DESC, last_message_time DESC`
|
||||||
|
)
|
||||||
|
.all(...args)
|
||||||
|
)
|
||||||
|
|
||||||
|
const senders: KnowledgeMemberStatsResult['senders'] = []
|
||||||
|
for (const row of rows) {
|
||||||
|
const senderId = String(row.sender_id ?? '').trim()
|
||||||
|
if (!senderId) continue
|
||||||
|
senders.push({
|
||||||
|
senderId,
|
||||||
|
messageCount: Number(row.message_count) || 0,
|
||||||
|
lastMessageTime: Number(row.last_message_time) || 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 窗口内最早一条消息:**不过滤 kind** —— 群的第一条常常是建群通知,
|
||||||
|
// 那才是用户认知里的「这个群第一条消息」。选「全部」时用它显示真实起点。
|
||||||
|
const earliestRow = this.database
|
||||||
|
.prepare(`SELECT MIN(create_time) AS m FROM knowledge_messages WHERE ${scope}`)
|
||||||
|
.get(...args) as DbRow | undefined
|
||||||
|
const earliestValue = Number(earliestRow?.m)
|
||||||
|
|
||||||
|
return {
|
||||||
|
conversationId,
|
||||||
|
totalMessages: count("kind <> 'system'"),
|
||||||
|
senders,
|
||||||
|
unattributedMessages: count("kind <> 'system' AND sender_id IS NULL"),
|
||||||
|
excludedSystemMessages: count("kind = 'system'"),
|
||||||
|
earliestMessageTime: Number.isFinite(earliestValue) && earliestValue > 0 ? earliestValue : null,
|
||||||
|
indexLatestAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 索引已经覆盖到的源数据时间(epoch ms)——「索引更新到哪」的权威口径。
|
* 索引已经覆盖到的源数据时间(epoch ms)——「索引更新到哪」的权威口径。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import type {
|
|||||||
KnowledgeIndexProgress,
|
KnowledgeIndexProgress,
|
||||||
KnowledgeIndexRequest,
|
KnowledgeIndexRequest,
|
||||||
KnowledgeIndexResult,
|
KnowledgeIndexResult,
|
||||||
|
KnowledgeMemberStatsRequest,
|
||||||
|
KnowledgeMemberStatsResult,
|
||||||
KnowledgeRuntimeStatus,
|
KnowledgeRuntimeStatus,
|
||||||
KnowledgeSearchRequest,
|
KnowledgeSearchRequest,
|
||||||
KnowledgeSearchResult,
|
KnowledgeSearchResult,
|
||||||
@@ -19,6 +21,7 @@ type WorkerResult =
|
|||||||
| KnowledgeCapacityPreflight
|
| KnowledgeCapacityPreflight
|
||||||
| KnowledgeSearchResult
|
| KnowledgeSearchResult
|
||||||
| KnowledgeRuntimeStatus
|
| KnowledgeRuntimeStatus
|
||||||
|
| KnowledgeMemberStatsResult
|
||||||
| { marks: Record<string, number> }
|
| { marks: Record<string, number> }
|
||||||
| { removed: true }
|
| { removed: true }
|
||||||
type PendingRequest = {
|
type PendingRequest = {
|
||||||
@@ -76,6 +79,11 @@ export class KnowledgeWorkerHost {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 单个会话内「按发送者聚合」的发言统计(群员统计用,只回聚合不回正文)。 */
|
||||||
|
memberStats(payload: KnowledgeMemberStatsRequest): Promise<KnowledgeMemberStatsResult> {
|
||||||
|
return this.request('memberStats', payload) as Promise<KnowledgeMemberStatsResult>
|
||||||
|
}
|
||||||
|
|
||||||
/** 只中止正在跑的索引任务,返回是否真的有任务被中止。 */
|
/** 只中止正在跑的索引任务,返回是否真的有任务被中止。 */
|
||||||
async cancelActiveIndex(): Promise<boolean> {
|
async cancelActiveIndex(): Promise<boolean> {
|
||||||
const target = this.activeIndexRequestId
|
const target = this.activeIndexRequestId
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type {
|
import type {
|
||||||
KnowledgeCapacityPreflightRequest,
|
KnowledgeCapacityPreflightRequest,
|
||||||
KnowledgeIndexRequest,
|
KnowledgeIndexRequest,
|
||||||
|
KnowledgeMemberStatsRequest,
|
||||||
|
KnowledgeMemberStatsResult,
|
||||||
KnowledgeRuntimeStatus,
|
KnowledgeRuntimeStatus,
|
||||||
KnowledgeSearchRequest,
|
KnowledgeSearchRequest,
|
||||||
KnowledgeStatusRequest,
|
KnowledgeStatusRequest,
|
||||||
@@ -187,6 +189,38 @@ async function handleHighWater(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群员统计的按发送者聚合。
|
||||||
|
*
|
||||||
|
* 与 `handleSearch` 同样先判库是否存在:**「还没建索引」是正常状态,不是故障**,
|
||||||
|
* 返回空结果而不是抛错,让上层能稳定地区分「没人发言」与「索引不存在」。
|
||||||
|
*/
|
||||||
|
async function handleMemberStats(
|
||||||
|
request: KnowledgeWorkerRequest,
|
||||||
|
payload: KnowledgeMemberStatsRequest
|
||||||
|
): Promise<void> {
|
||||||
|
const path = getKnowledgeDatabasePath(payload.databaseRoot, payload.accountId)
|
||||||
|
if (!existsSync(path)) {
|
||||||
|
const unavailable: KnowledgeMemberStatsResult = {
|
||||||
|
conversationId: payload.conversationId,
|
||||||
|
totalMessages: 0,
|
||||||
|
senders: [],
|
||||||
|
unattributedMessages: 0,
|
||||||
|
excludedSystemMessages: 0,
|
||||||
|
earliestMessageTime: null,
|
||||||
|
indexLatestAt: null
|
||||||
|
}
|
||||||
|
send({ version: 1, type: 'result', requestId: request.requestId, payload: unavailable })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
send({
|
||||||
|
version: 1,
|
||||||
|
type: 'result',
|
||||||
|
requestId: request.requestId,
|
||||||
|
payload: getStore(payload).memberStats(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number): Promise<void> {
|
async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (request.type === 'cancel') {
|
if (request.type === 'cancel') {
|
||||||
@@ -226,6 +260,10 @@ async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number
|
|||||||
await handleHighWater(request, request.payload as KnowledgeStatusRequest)
|
await handleHighWater(request, request.payload as KnowledgeStatusRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (request.type === 'memberStats') {
|
||||||
|
await handleMemberStats(request, request.payload as KnowledgeMemberStatsRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (request.type === 'index') {
|
if (request.type === 'index') {
|
||||||
await handleIndex(request, request.payload as KnowledgeIndexRequest)
|
await handleIndex(request, request.payload as KnowledgeIndexRequest)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,731 @@
|
|||||||
|
import {
|
||||||
|
AUTOMATION_STEP_LABELS,
|
||||||
|
AUTOMATION_SEND_ORIGIN,
|
||||||
|
AUTOMATION_SEND_PURPOSE,
|
||||||
|
DEFAULT_REPLY_TEXT,
|
||||||
|
automationIdempotencyKey,
|
||||||
|
normalizeReplyDelaySeconds,
|
||||||
|
scheduledReportRangeLabel,
|
||||||
|
type AutomationAction,
|
||||||
|
type AutomationExecutionTrigger,
|
||||||
|
type AutomationRule,
|
||||||
|
type AutomationStep,
|
||||||
|
type AutomationStepKey,
|
||||||
|
type LeaveNotificationConfig,
|
||||||
|
type ScheduledReportAutomationConfig
|
||||||
|
} from '../../shared/automation'
|
||||||
|
import {
|
||||||
|
renderLeaveNotificationText,
|
||||||
|
type GroupMemberExitedEvent
|
||||||
|
} from '../../shared/group-exit-event'
|
||||||
|
import type { WechatActionContent, WechatActionRequest, WechatActionResult } from '../../shared/wechat-action'
|
||||||
|
import type { SaveGeneratedReportRequest, SaveGeneratedReportResult } from '../../shared/report-history'
|
||||||
|
import {
|
||||||
|
generateAgentGroupReport,
|
||||||
|
type AgentGroupReportRequest,
|
||||||
|
type AgentGroupReportResult
|
||||||
|
} from './agent-group-report-service'
|
||||||
|
import { wechatActionGateway } from './wechat-action-gateway'
|
||||||
|
import { saveGeneratedReport } from '../report-history-service'
|
||||||
|
import { getContactAvatars, resolveMd5 } from './chat-service'
|
||||||
|
import type { AutomationTargetResolution } from './automation-wechat-target'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AutomationActionRunner —— 把一次命中跑成**步骤序列**。
|
||||||
|
*
|
||||||
|
* 两条硬要求:
|
||||||
|
*
|
||||||
|
* 1. **每一步都有独立的状态、起止时间与耗时。** 用户层日志的全部价值就在于此:
|
||||||
|
* 只告诉用户「失败了」等于没说,得告诉他是「生成日报」那步坏了。
|
||||||
|
* 2. **失败必须切断后续。** 日报没生成出来就绝不能继续发图 —— 那会发出一条
|
||||||
|
* 空的 / 过期的 / 上一条的消息,比不发更糟。所以失败后剩下的步骤一律 `skipped`。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 一步执行完毕后,后续步骤的处置方式。 */
|
||||||
|
const STEP_ORDER: AutomationStepKey[] = ['received', 'matched', 'reply', 'report', 'send']
|
||||||
|
|
||||||
|
/** 退群通知的步骤顺序(与消息型规则**不共用**)。 */
|
||||||
|
const LEAVE_NOTIFICATION_STEP_ORDER: AutomationStepKey[] = [
|
||||||
|
'exit_received',
|
||||||
|
'exit_matched',
|
||||||
|
'exit_target',
|
||||||
|
'exit_send'
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时日报的步骤顺序(与另外两族**不共用**)。
|
||||||
|
*
|
||||||
|
* 「生成中 / 已生成」与「确定目标 / 已发送」**刻意拆成四步**:
|
||||||
|
* 用户必须能一眼看出"日报确实生成了,只是没发出去" —— 合成两步就表达不了。
|
||||||
|
*/
|
||||||
|
const SCHEDULED_REPORT_STEP_ORDER: AutomationStepKey[] = [
|
||||||
|
'schedule_triggered',
|
||||||
|
'report_generating',
|
||||||
|
'report_generated',
|
||||||
|
'send_resolved',
|
||||||
|
'report_sent'
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 前置步骤失败时,后续步骤的 `skipReason`。
|
||||||
|
*
|
||||||
|
* 必须写清「是因为前面那步没成」,否则用户看到一连串「已跳过」会以为是规则没配好。
|
||||||
|
*/
|
||||||
|
const SKIPPED_AFTER_REPLY_FAILURE = '前置步骤失败(回复确认未成功),本次不再继续。'
|
||||||
|
const SKIPPED_AFTER_REPORT_FAILURE = '前置步骤失败(日报未生成),没有图片可发送。'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时日报「生成 / 落库」失败时,后续步骤的 `skipReason`。
|
||||||
|
*
|
||||||
|
* 与 `SKIPPED_AFTER_REPORT_FAILURE` 的区别:那条说的是「@我日报没图可发」,
|
||||||
|
* 这条说的是「定时日报这一步就没跑起来」—— 文案分开,避免用户混淆两条链路。
|
||||||
|
*/
|
||||||
|
const SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE =
|
||||||
|
'前置步骤失败(定时日报未生成或未保存),本次未发送。'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规则启用了「发送日报图片」,但手上没有图片文件。
|
||||||
|
*
|
||||||
|
* 这不是「正常跳过」,也不允许退而求其次去发空路径 / 上一张旧图 / 不存在的文件 ——
|
||||||
|
* 发错东西比不发更糟,所以直接判失败。
|
||||||
|
*/
|
||||||
|
const SEND_WITHOUT_IMAGE_ERROR = '日报图片未生成,无法发送。'
|
||||||
|
|
||||||
|
export interface AutomationRunInput {
|
||||||
|
executionId: string
|
||||||
|
rule: AutomationRule
|
||||||
|
/** 会话标识:群为 `xxx@chatroom`,私聊为 wxid。同时也是发送对象。 */
|
||||||
|
conversationId: string
|
||||||
|
isGroup: boolean
|
||||||
|
/** 用户可读的来源名(群名 / 昵称)。**不是** id。 */
|
||||||
|
sourceDisplayName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutomationRunResult {
|
||||||
|
steps: AutomationStep[]
|
||||||
|
status: 'success' | 'failed'
|
||||||
|
errorSummary?: string
|
||||||
|
pngPath?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退群通知的执行输入。
|
||||||
|
*
|
||||||
|
* 目标解析与发送能力预检都由 `AutomationService` 在调用前完成,
|
||||||
|
* 这里只负责"把它跑成步骤序列" —— 于是**所有步骤构造只在一处**,
|
||||||
|
* 不会出现"服务拼一半、runner 拼一半"的裂口。
|
||||||
|
*/
|
||||||
|
export interface LeaveNotificationRunInput {
|
||||||
|
executionId: string
|
||||||
|
event: GroupMemberExitedEvent
|
||||||
|
config: LeaveNotificationConfig
|
||||||
|
resolution: AutomationTargetResolution
|
||||||
|
/** 事件来源的显示名(群名 / 「群聊」)。**不含 wxid**。 */
|
||||||
|
sourceDisplayName: string
|
||||||
|
/** 发送能力缺失时的一句话说明;有值时 `exit_send` 直接判失败。 */
|
||||||
|
sendBlockedReason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaveNotificationRunResult {
|
||||||
|
steps: AutomationStep[]
|
||||||
|
status: 'success' | 'failed'
|
||||||
|
errorSummary?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时日报的执行输入。
|
||||||
|
*
|
||||||
|
* 与退群通知同构:目标解析、来源显示名、发送能力预检都在 `AutomationService`
|
||||||
|
* 调用前算好,runner 只负责"跑成步骤序列"。
|
||||||
|
*/
|
||||||
|
export interface ScheduledReportRunInput {
|
||||||
|
executionId: string
|
||||||
|
rule: AutomationRule
|
||||||
|
config: ScheduledReportAutomationConfig
|
||||||
|
resolution: AutomationTargetResolution
|
||||||
|
/** 日报来源群的显示名(群名 / 「日报来源群」)。**不含 roomId**。 */
|
||||||
|
sourceDisplayName: string
|
||||||
|
/**
|
||||||
|
* 本次触发方式。
|
||||||
|
*
|
||||||
|
* 只影响**发送节流口径**:定时触发算 `automation`(纳入发送节流),
|
||||||
|
* 用户在页面上点「立即执行」算 `user`(与旧定时日报的手动执行一致)。
|
||||||
|
* 执行日志里的 `trigger` 由 `AutomationService` 单独记录,不从这里读。
|
||||||
|
*/
|
||||||
|
trigger: Extract<AutomationExecutionTrigger, 'schedule' | 'manual'>
|
||||||
|
/** 发送能力缺失时的一句话说明;有值时 `report_sent` 直接判失败。 */
|
||||||
|
sendBlockedReason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduledReportRunResult {
|
||||||
|
steps: AutomationStep[]
|
||||||
|
status: 'success' | 'failed'
|
||||||
|
errorSummary?: string
|
||||||
|
/**
|
||||||
|
* 日报是否**已经生成并落库**。
|
||||||
|
*
|
||||||
|
* 这是「生成成功但发送失败」的判据:`reportGenerated === true && status === 'failed'`
|
||||||
|
* 就是"日报在,只是没发出去"。日报本身已进日报历史,不会被丢掉。
|
||||||
|
*/
|
||||||
|
reportGenerated: boolean
|
||||||
|
/** 生成出来的 PNG(仅在内存里传递,**不落执行日志**)。 */
|
||||||
|
pngPath?: string
|
||||||
|
/**
|
||||||
|
* 底层生成错误的**机器可读码**(例如 `NO_MESSAGES`)。
|
||||||
|
*
|
||||||
|
* 用途只有一个:让上层区分「真的失败了」和「这一天没有消息可生成」——
|
||||||
|
* 后者不该给用户推微信异常通知。
|
||||||
|
*/
|
||||||
|
errorCode?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutomationActionRunnerDependencies {
|
||||||
|
generateReport?: (request: AgentGroupReportRequest) => Promise<AgentGroupReportResult>
|
||||||
|
executeAction?: (request: WechatActionRequest) => Promise<WechatActionResult>
|
||||||
|
now?: () => number
|
||||||
|
/** 延迟实现。默认真 sleep;单测注入即时 resolve 的假实现,避免真的等 2 秒。 */
|
||||||
|
delay?: (ms: number) => Promise<void>
|
||||||
|
/** 日报落库(日报历史)。与旧定时日报**复用同一个**实现,不复制。 */
|
||||||
|
saveGeneratedReport?: (request: SaveGeneratedReportRequest) => Promise<SaveGeneratedReportResult>
|
||||||
|
/** 把日报来源群标识解析成联系人(拿头像 / 显示名)。 */
|
||||||
|
resolveReportContact?: (raw: string) => {
|
||||||
|
md5?: string
|
||||||
|
m_nsUsrName?: string
|
||||||
|
m_nsNickName?: string
|
||||||
|
avatar?: string
|
||||||
|
} | null
|
||||||
|
getContactAvatars?: (usernames: string[]) => Promise<Record<string, string>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 策略层的错误码 → 用户可读短句。UI 直接展示这些文案,不做二次翻译。 */
|
||||||
|
const ACTION_ERROR_MESSAGES: Record<string, string> = {
|
||||||
|
INVALID_REQUEST: '发送请求不合法',
|
||||||
|
INVALID_RECIPIENT: '找不到有效的发送对象',
|
||||||
|
ACTION_NOT_ALLOWED: '该自动化动作未被允许执行',
|
||||||
|
RECIPIENT_SCOPE_VIOLATION: '发送对象与触发来源不一致',
|
||||||
|
SEND_CAPABILITY_UNAVAILABLE: '当前环境没有可用的微信发送能力',
|
||||||
|
SEND_NOT_READY: '微信发送能力尚未就绪,请先绑定个人微信',
|
||||||
|
SEND_FAILED: '微信发送失败',
|
||||||
|
POLICY_BLOCKED: '该发送动作未通过策略检查',
|
||||||
|
UNKNOWN: '发送失败(未知原因)'
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStep(key: AutomationStepKey): AutomationStep {
|
||||||
|
return { key, label: AUTOMATION_STEP_LABELS[key], status: 'pending' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function markSuccess(step: AutomationStep, at: number): void {
|
||||||
|
step.status = 'success'
|
||||||
|
step.startedAt = step.startedAt ?? at
|
||||||
|
step.finishedAt = at
|
||||||
|
step.durationMs = Math.max(0, at - step.startedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
function markFailed(step: AutomationStep, at: number, error: string): void {
|
||||||
|
step.status = 'failed'
|
||||||
|
step.startedAt = step.startedAt ?? at
|
||||||
|
step.finishedAt = at
|
||||||
|
step.durationMs = Math.max(0, at - step.startedAt)
|
||||||
|
step.error = error
|
||||||
|
}
|
||||||
|
|
||||||
|
function markSkipped(step: AutomationStep, skipReason?: string): void {
|
||||||
|
step.status = 'skipped'
|
||||||
|
if (skipReason) step.skipReason = skipReason
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionErrorMessage(code: string | undefined, fallback: string | undefined): string {
|
||||||
|
if (fallback && fallback.trim()) {
|
||||||
|
// 策略层给的 reason 已是中文短句;直接用,避免二次包装丢信息。
|
||||||
|
return fallback.trim()
|
||||||
|
}
|
||||||
|
return (code && ACTION_ERROR_MESSAGES[code]) || ACTION_ERROR_MESSAGES.UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AutomationActionRunner {
|
||||||
|
private readonly generateReport: (request: AgentGroupReportRequest) => Promise<AgentGroupReportResult>
|
||||||
|
private readonly executeAction: (request: WechatActionRequest) => Promise<WechatActionResult>
|
||||||
|
private readonly now: () => number
|
||||||
|
private readonly delay: (ms: number) => Promise<void>
|
||||||
|
private readonly saveGeneratedReport: (
|
||||||
|
request: SaveGeneratedReportRequest
|
||||||
|
) => Promise<SaveGeneratedReportResult>
|
||||||
|
private readonly resolveReportContact: NonNullable<
|
||||||
|
AutomationActionRunnerDependencies['resolveReportContact']
|
||||||
|
>
|
||||||
|
private readonly getContactAvatars: (usernames: string[]) => Promise<Record<string, string>>
|
||||||
|
|
||||||
|
constructor(dependencies: AutomationActionRunnerDependencies = {}) {
|
||||||
|
this.generateReport = dependencies.generateReport ?? generateAgentGroupReport
|
||||||
|
this.executeAction = dependencies.executeAction ?? ((request) => wechatActionGateway.execute(request))
|
||||||
|
this.now = dependencies.now ?? (() => Date.now())
|
||||||
|
this.delay =
|
||||||
|
dependencies.delay ??
|
||||||
|
((ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)))
|
||||||
|
this.saveGeneratedReport = dependencies.saveGeneratedReport ?? saveGeneratedReport
|
||||||
|
this.resolveReportContact = dependencies.resolveReportContact ?? ((raw) => resolveMd5(raw))
|
||||||
|
this.getContactAvatars = dependencies.getContactAvatars ?? ((ids) => getContactAvatars(ids))
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(input: AutomationRunInput): Promise<AutomationRunResult> {
|
||||||
|
const steps = STEP_ORDER.map((key) => createStep(key))
|
||||||
|
const stepAt = (key: AutomationStepKey): AutomationStep =>
|
||||||
|
steps.find((step) => step.key === key) as AutomationStep
|
||||||
|
|
||||||
|
markSuccess(stepAt('received'), this.now())
|
||||||
|
markSuccess(stepAt('matched'), this.now())
|
||||||
|
|
||||||
|
const replyAction = findAction(input.rule, 'replyText')
|
||||||
|
const reportAction = findAction(input.rule, 'generateReport')
|
||||||
|
const sendAction = findAction(input.rule, 'sendReportImage')
|
||||||
|
|
||||||
|
// ---- 步骤 3:回复确认 ----
|
||||||
|
//
|
||||||
|
// 回复等待:规则一命中就秒回,看起来就是个机器人(消息刚到、回复就到)。
|
||||||
|
// 等待时长是**规则自己的一项执行参数**(`rule.replyDelaySeconds`,在
|
||||||
|
// 「编辑自动化 → 3 · 触发后执行」里配),所以不同规则可以不一样。
|
||||||
|
//
|
||||||
|
// 等待刻意放在 `reply` 步骤计时**之外** —— `reply.durationMs` 只应该反映发送本身,
|
||||||
|
// 否则用户看到「回复确认 2000ms」会误以为是发送慢。
|
||||||
|
// 已经命中就不再回头重判规则:等待窗口里规则被停用/删掉也不中断本次执行,
|
||||||
|
// 与 cooldown、同消息幂等的口径一致(都是「命中那一刻」的快照)。
|
||||||
|
if (replyAction) {
|
||||||
|
// 用共享的归一化函数,而不是 `Number(...) || 0`:旧版 rules.json 里没有这个字段,
|
||||||
|
// 那应该按**默认 2 秒**处理(否则「默认 2 秒」要等用户手动进编辑页才会生效)。
|
||||||
|
const replyDelayMs = normalizeReplyDelaySeconds(input.rule.replyDelaySeconds) * 1_000
|
||||||
|
if (replyDelayMs > 0) await this.delay(replyDelayMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!replyAction) {
|
||||||
|
markSkipped(stepAt('reply'))
|
||||||
|
} else {
|
||||||
|
const replyStep = stepAt('reply')
|
||||||
|
replyStep.status = 'running'
|
||||||
|
replyStep.startedAt = this.now()
|
||||||
|
const sent = await this.sendThroughGateway(input, 'reply', {
|
||||||
|
type: 'text',
|
||||||
|
text: replyAction.text?.trim() || DEFAULT_REPLY_TEXT
|
||||||
|
})
|
||||||
|
if (!sent.ok) {
|
||||||
|
markFailed(replyStep, this.now(), sent.error || '回复确认失败')
|
||||||
|
markRemainingSkipped(steps, 'reply', SKIPPED_AFTER_REPLY_FAILURE)
|
||||||
|
return { steps, status: 'failed', errorSummary: replyStep.error }
|
||||||
|
}
|
||||||
|
markSuccess(replyStep, this.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 步骤 4:生成日报 ----
|
||||||
|
let pngPath: string | undefined
|
||||||
|
if (!reportAction) {
|
||||||
|
markSkipped(stepAt('report'))
|
||||||
|
} else {
|
||||||
|
const reportStep = stepAt('report')
|
||||||
|
reportStep.status = 'running'
|
||||||
|
reportStep.startedAt = this.now()
|
||||||
|
let result: AgentGroupReportResult
|
||||||
|
try {
|
||||||
|
result = await this.generateReport({ group: input.conversationId, range: 'today' })
|
||||||
|
} catch (error) {
|
||||||
|
result = {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!result.success || !result.pngPath) {
|
||||||
|
markFailed(reportStep, this.now(), result.error || '日报生成失败')
|
||||||
|
markRemainingSkipped(steps, 'report', SKIPPED_AFTER_REPORT_FAILURE)
|
||||||
|
return { steps, status: 'failed', errorSummary: reportStep.error }
|
||||||
|
}
|
||||||
|
pngPath = result.pngPath
|
||||||
|
markSuccess(reportStep, this.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 步骤 5:发送日报图片 ----
|
||||||
|
//
|
||||||
|
// 三种情况必须分开判断 —— 合并成 `!sendAction || !pngPath` 会把
|
||||||
|
// 「规则要求发图、但图根本没生成」当成正常跳过,execution 还记成 success:
|
||||||
|
//
|
||||||
|
// A. 规则**本来就没有启用**这个动作 → skipped,这是正常的,不影响整体结果;
|
||||||
|
// B. 启用了,但要发的东西不存在 → **failed**,不能假装成功,
|
||||||
|
// 更不能退而求其次去发空路径 / 上一次的旧图 / 不存在的文件;
|
||||||
|
// C. 前置(生成日报)已经失败 → 上面就 return 了,走不到这里。
|
||||||
|
if (!sendAction) {
|
||||||
|
markSkipped(stepAt('send'))
|
||||||
|
return { steps, status: 'success', ...(pngPath ? { pngPath } : {}) }
|
||||||
|
}
|
||||||
|
const sendStep = stepAt('send')
|
||||||
|
if (!pngPath) {
|
||||||
|
markFailed(sendStep, this.now(), SEND_WITHOUT_IMAGE_ERROR)
|
||||||
|
return { steps, status: 'failed', errorSummary: sendStep.error }
|
||||||
|
}
|
||||||
|
sendStep.status = 'running'
|
||||||
|
sendStep.startedAt = this.now()
|
||||||
|
const sent = await this.sendThroughGateway(input, 'report', { type: 'image', path: pngPath })
|
||||||
|
if (!sent.ok) {
|
||||||
|
markFailed(sendStep, this.now(), sent.error || '发送日报图片失败')
|
||||||
|
return { steps, status: 'failed', errorSummary: sendStep.error, pngPath }
|
||||||
|
}
|
||||||
|
markSuccess(sendStep, this.now())
|
||||||
|
return { steps, status: 'success', pngPath }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时日报:把一次「到点触发 / 手动立即执行」跑成一次生成 + 一次发送。
|
||||||
|
*
|
||||||
|
* 与旧 `ScheduledReportService.executeTask` 的行为对齐(**不重写日报能力**):
|
||||||
|
* 1. 先生成(含落库进日报历史)—— 发送能力不足**也照常生成**(旧语义如此);
|
||||||
|
* 2. 再解析目标 —— 解析失败**不 fallback**,直接判失败;
|
||||||
|
* 3. 最后经 `WechatActionGateway` 发图片 —— 仍然是**发图片**,不退化成纯文本。
|
||||||
|
*
|
||||||
|
* 「生成成功但发送失败」是可表达的:`report_generated` 为 success、
|
||||||
|
* `report_sent` 为 failed,整体 `failed`,且 `reportGenerated === true`。
|
||||||
|
*/
|
||||||
|
async runScheduledReport(input: ScheduledReportRunInput): Promise<ScheduledReportRunResult> {
|
||||||
|
const steps = SCHEDULED_REPORT_STEP_ORDER.map((key) => createStep(key))
|
||||||
|
const stepAt = (key: AutomationStepKey): AutomationStep =>
|
||||||
|
steps.find((step) => step.key === key) as AutomationStep
|
||||||
|
|
||||||
|
markSuccess(stepAt('schedule_triggered'), this.now())
|
||||||
|
|
||||||
|
// ---- 生成日报(含落进日报历史)----
|
||||||
|
const generatingStep = stepAt('report_generating')
|
||||||
|
generatingStep.status = 'running'
|
||||||
|
generatingStep.startedAt = this.now()
|
||||||
|
let generated: AgentGroupReportResult
|
||||||
|
try {
|
||||||
|
generated = await this.generateReport({
|
||||||
|
group: input.config.report.sourceConversationId,
|
||||||
|
range: input.config.report.range,
|
||||||
|
messageTypes: input.config.report.messageTypes,
|
||||||
|
templateId: input.config.report.templateId,
|
||||||
|
memberNameMode: input.config.report.memberNameMode,
|
||||||
|
timeoutSeconds: input.config.report.timeoutSeconds
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
generated = {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!generated.success || !generated.pngPath) {
|
||||||
|
// 生成失败就**不发**:绝不生成空图片、也绝不退而求其次发上一张旧图。
|
||||||
|
const reason = generated.error || '日报生成失败'
|
||||||
|
markFailed(generatingStep, this.now(), reason)
|
||||||
|
markSkipped(stepAt('report_generated'), SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE)
|
||||||
|
markSkipped(stepAt('send_resolved'), SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE)
|
||||||
|
markSkipped(stepAt('report_sent'), SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE)
|
||||||
|
return {
|
||||||
|
steps,
|
||||||
|
status: 'failed',
|
||||||
|
errorSummary: reason,
|
||||||
|
reportGenerated: false,
|
||||||
|
...(generated.errorCode ? { errorCode: generated.errorCode } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pngPath = generated.pngPath
|
||||||
|
try {
|
||||||
|
const reportContact = this.resolveReportContact(input.config.report.sourceConversationId)
|
||||||
|
let contactAvatar = reportContact?.avatar
|
||||||
|
if (!contactAvatar && reportContact?.m_nsUsrName) {
|
||||||
|
try {
|
||||||
|
const avatars = await this.getContactAvatars([reportContact.m_nsUsrName])
|
||||||
|
contactAvatar = avatars[reportContact.m_nsUsrName]
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Automation] 日报群头像补全失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const savedHistory = await this.saveGeneratedReport({
|
||||||
|
contactId: reportContact?.md5 || input.config.report.sourceConversationId,
|
||||||
|
contactName:
|
||||||
|
generated.groupName || reportContact?.m_nsNickName || input.sourceDisplayName || '群聊',
|
||||||
|
contactAvatar,
|
||||||
|
source: 'scheduled',
|
||||||
|
dateRange: generated.reportMetadata?.dateRange || scheduledReportRangeLabel(input.config.report.range),
|
||||||
|
reportDate: generated.reportMetadata?.reportDate,
|
||||||
|
messageCount: generated.messageCount ?? generated.reportMetadata?.messageCount ?? 0,
|
||||||
|
generatedAt: new Date(this.now()).toISOString(),
|
||||||
|
htmlPath: generated.htmlPath,
|
||||||
|
pngPath: generated.pngPath,
|
||||||
|
duration: generated.duration,
|
||||||
|
modelName: generated.modelName,
|
||||||
|
tokenUsage: generated.tokenUsage,
|
||||||
|
reportSnapshot: generated.reportSnapshot,
|
||||||
|
reportMetadata: generated.reportMetadata,
|
||||||
|
templateId: input.config.report.templateId
|
||||||
|
})
|
||||||
|
if (!savedHistory.success) {
|
||||||
|
throw new Error(savedHistory.error || '日报历史保存失败')
|
||||||
|
}
|
||||||
|
const recordPath = savedHistory.record?.pngPath || generated.pngPath
|
||||||
|
if (!recordPath) throw new Error('日报历史未返回可发送的 PNG 文件')
|
||||||
|
pngPath = recordPath
|
||||||
|
} catch (error) {
|
||||||
|
const reason = error instanceof Error ? error.message : String(error)
|
||||||
|
markFailed(generatingStep, this.now(), reason)
|
||||||
|
markSkipped(stepAt('report_generated'), SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE)
|
||||||
|
markSkipped(stepAt('send_resolved'), SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE)
|
||||||
|
markSkipped(stepAt('report_sent'), SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE)
|
||||||
|
return { steps, status: 'failed', errorSummary: reason, reportGenerated: false }
|
||||||
|
}
|
||||||
|
markSuccess(generatingStep, this.now())
|
||||||
|
|
||||||
|
const generatedStep = stepAt('report_generated')
|
||||||
|
markSuccess(generatedStep, this.now())
|
||||||
|
const messageCount = generated.messageCount ?? 0
|
||||||
|
if (messageCount > 0) generatedStep.detail = `共 ${messageCount} 条消息`
|
||||||
|
|
||||||
|
// ---- 目标解析失败:不许 fallback 到任何地方,直接判失败 ----
|
||||||
|
const targetStep = stepAt('send_resolved')
|
||||||
|
if (!input.resolution.ok) {
|
||||||
|
markFailed(targetStep, this.now(), input.resolution.error)
|
||||||
|
markSkipped(stepAt('report_sent'), '没有可用的发送目标,本次未发送。')
|
||||||
|
return {
|
||||||
|
steps,
|
||||||
|
status: 'failed',
|
||||||
|
errorSummary: input.resolution.error,
|
||||||
|
reportGenerated: true,
|
||||||
|
pngPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
targetStep.status = 'success'
|
||||||
|
targetStep.startedAt = this.now()
|
||||||
|
targetStep.finishedAt = targetStep.startedAt
|
||||||
|
targetStep.durationMs = 0
|
||||||
|
// 用户可读的目标名(例如「文件传输助手」「张三」「我」)—— 不带任何 id。
|
||||||
|
targetStep.detail = input.resolution.target.displayName
|
||||||
|
|
||||||
|
const sendStep = stepAt('report_sent')
|
||||||
|
|
||||||
|
// ---- 发送能力缺失:日报已生成,如实判失败(不回滚、不隐藏)----
|
||||||
|
if (input.sendBlockedReason) {
|
||||||
|
markFailed(sendStep, this.now(), input.sendBlockedReason)
|
||||||
|
return {
|
||||||
|
steps,
|
||||||
|
status: 'failed',
|
||||||
|
errorSummary: input.sendBlockedReason,
|
||||||
|
reportGenerated: true,
|
||||||
|
pngPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendStep.status = 'running'
|
||||||
|
sendStep.startedAt = this.now()
|
||||||
|
const sent = await this.sendScheduledReportImage(input, pngPath)
|
||||||
|
if (!sent.ok) {
|
||||||
|
markFailed(sendStep, this.now(), sent.error || '发送日报失败')
|
||||||
|
return {
|
||||||
|
steps,
|
||||||
|
status: 'failed',
|
||||||
|
errorSummary: sendStep.error,
|
||||||
|
reportGenerated: true,
|
||||||
|
pngPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
markSuccess(sendStep, this.now())
|
||||||
|
return { steps, status: 'success', reportGenerated: true, pngPath }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时日报的统一发送出口。
|
||||||
|
*
|
||||||
|
* **必须走 `WechatActionGateway`**:幂等 + 3 秒发送间隔 + 审计落盘都在那里。
|
||||||
|
* Automation 层不允许知道 OneBot / WCHook / native host / Windows hook 的存在。
|
||||||
|
*/
|
||||||
|
private async sendScheduledReportImage(
|
||||||
|
input: ScheduledReportRunInput,
|
||||||
|
pngPath: string
|
||||||
|
): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
if (!input.resolution.ok) return { ok: false, error: input.resolution.error }
|
||||||
|
// 定时触发走 automation(纳入发送节流);用户手动执行走 user(不纳入节流)。
|
||||||
|
const triggerType = input.trigger === 'manual' ? 'user' : 'automation'
|
||||||
|
try {
|
||||||
|
const result = await this.executeAction({
|
||||||
|
idempotencyKey: `${AUTOMATION_SEND_PURPOSE.scheduledReport}:${input.executionId}`,
|
||||||
|
origin: AUTOMATION_SEND_ORIGIN,
|
||||||
|
purpose: AUTOMATION_SEND_PURPOSE.scheduledReport,
|
||||||
|
triggerType,
|
||||||
|
executionId: input.executionId,
|
||||||
|
recipient: input.resolution.target.recipient,
|
||||||
|
content: { type: 'image', path: pngPath }
|
||||||
|
})
|
||||||
|
if (result.status !== 'sent') {
|
||||||
|
return { ok: false, error: actionErrorMessage(result.errorCode, result.reason) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 后置词:**只在图片明确 sent 之后**才发,图片失败时严格短路 ——
|
||||||
|
* 与 `WechatActionGateway.executeReportImageSequence`(手动发送)同一口径。
|
||||||
|
* 空字符串 = 用户只要图片,什么都不补发。
|
||||||
|
*
|
||||||
|
* 独立 purpose + 独立幂等位:共用一个 key 会让「图片发成功、后置词被
|
||||||
|
* 幂等短路」变成常态。
|
||||||
|
*/
|
||||||
|
const postfixText = String(input.config.postfixText || '').trim()
|
||||||
|
if (!postfixText) return { ok: true }
|
||||||
|
|
||||||
|
const postfix = await this.executeAction({
|
||||||
|
idempotencyKey: `${AUTOMATION_SEND_PURPOSE.scheduledReportPostfix}:${input.executionId}`,
|
||||||
|
origin: AUTOMATION_SEND_ORIGIN,
|
||||||
|
purpose: AUTOMATION_SEND_PURPOSE.scheduledReportPostfix,
|
||||||
|
triggerType,
|
||||||
|
executionId: input.executionId,
|
||||||
|
recipient: input.resolution.target.recipient,
|
||||||
|
content: { type: 'text', text: postfixText }
|
||||||
|
})
|
||||||
|
if (postfix.status === 'sent') return { ok: true }
|
||||||
|
// 绝不吞掉:图片确实发出去了,但这一次执行**没有完成**,必须如实报出来。
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `日报图片已发送,但后置词发送失败:${actionErrorMessage(
|
||||||
|
postfix.errorCode,
|
||||||
|
postfix.reason
|
||||||
|
)}`
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退群通知:把「检测到成员退出」这件事跑成一次发送。
|
||||||
|
*
|
||||||
|
* 与 `run()` 的差别:没有动作链、没有回复等待、没有 cooldown
|
||||||
|
* (退群是低频事件,且**不能被时间窗合并** —— 两个成员先后退出就是两条通知)。
|
||||||
|
*/
|
||||||
|
async runLeaveNotification(input: LeaveNotificationRunInput): Promise<LeaveNotificationRunResult> {
|
||||||
|
const steps = LEAVE_NOTIFICATION_STEP_ORDER.map((key) => createStep(key))
|
||||||
|
const stepAt = (key: AutomationStepKey): AutomationStep =>
|
||||||
|
steps.find((step) => step.key === key) as AutomationStep
|
||||||
|
|
||||||
|
markSuccess(stepAt('exit_received'), this.now())
|
||||||
|
markSuccess(stepAt('exit_matched'), this.now())
|
||||||
|
|
||||||
|
// ---- 目标解析失败:不许 fallback 到任何地方,直接判失败 ----
|
||||||
|
if (!input.resolution.ok) {
|
||||||
|
markFailed(stepAt('exit_target'), this.now(), input.resolution.error)
|
||||||
|
markSkipped(stepAt('exit_send'), '没有可用的通知目标,未发送。')
|
||||||
|
return { steps, status: 'failed', errorSummary: input.resolution.error }
|
||||||
|
}
|
||||||
|
const targetStep = stepAt('exit_target')
|
||||||
|
targetStep.status = 'success'
|
||||||
|
targetStep.startedAt = this.now()
|
||||||
|
targetStep.finishedAt = targetStep.startedAt
|
||||||
|
targetStep.durationMs = 0
|
||||||
|
// 用户可读的目标名(例如「文件传输助手」「张三」「我」)—— 不带任何 id。
|
||||||
|
targetStep.detail = input.resolution.target.displayName
|
||||||
|
|
||||||
|
const sendStep = stepAt('exit_send')
|
||||||
|
|
||||||
|
// ---- 发送能力缺失:如实判失败(退群事实本身仍然是成功的) ----
|
||||||
|
if (input.sendBlockedReason) {
|
||||||
|
markFailed(sendStep, this.now(), input.sendBlockedReason)
|
||||||
|
return { steps, status: 'failed', errorSummary: input.sendBlockedReason }
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = renderLeaveNotificationText(input.event, input.config.template).trim()
|
||||||
|
if (!text) {
|
||||||
|
const error = '通知内容为空,未发送。'
|
||||||
|
markFailed(sendStep, this.now(), error)
|
||||||
|
return { steps, status: 'failed', errorSummary: error }
|
||||||
|
}
|
||||||
|
|
||||||
|
sendStep.status = 'running'
|
||||||
|
sendStep.startedAt = this.now()
|
||||||
|
const sent = await this.sendLeaveNotification(input, text)
|
||||||
|
if (!sent.ok) {
|
||||||
|
markFailed(sendStep, this.now(), sent.error || '发送退群通知失败')
|
||||||
|
return { steps, status: 'failed', errorSummary: sendStep.error }
|
||||||
|
}
|
||||||
|
markSuccess(sendStep, this.now())
|
||||||
|
return { steps, status: 'success' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退群通知的统一发送出口。
|
||||||
|
*
|
||||||
|
* **必须走 `WechatActionGateway`**:幂等 + 3 秒发送间隔 + 审计落盘都在那里,
|
||||||
|
* 直接调个人微信发送会绕过全部三样。Automation 层也不允许知道
|
||||||
|
* OneBot / WCHook / native host / Windows hook 的存在。
|
||||||
|
*
|
||||||
|
* 幂等键由 **eventId** 派生(不是 executionId):审计是落盘的,
|
||||||
|
* 于是"重启后重复投递同一退群事件"会被持久层直接短路。
|
||||||
|
*/
|
||||||
|
private async sendLeaveNotification(
|
||||||
|
input: LeaveNotificationRunInput,
|
||||||
|
text: string
|
||||||
|
): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
if (!input.resolution.ok) return { ok: false, error: input.resolution.error }
|
||||||
|
try {
|
||||||
|
const result = await this.executeAction({
|
||||||
|
idempotencyKey: `${AUTOMATION_SEND_PURPOSE.leaveNotification}:${input.event.eventId}`,
|
||||||
|
origin: AUTOMATION_SEND_ORIGIN,
|
||||||
|
purpose: AUTOMATION_SEND_PURPOSE.leaveNotification,
|
||||||
|
triggerType: 'automation',
|
||||||
|
executionId: input.executionId,
|
||||||
|
sourceId: input.event.eventId,
|
||||||
|
recipient: input.resolution.target.recipient,
|
||||||
|
content: { type: 'text', text }
|
||||||
|
})
|
||||||
|
if (result.status === 'sent') return { ok: true }
|
||||||
|
return { ok: false, error: actionErrorMessage(result.errorCode, result.reason) }
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一发送出口。
|
||||||
|
*
|
||||||
|
* **刻意不直接调 `WechatSendGateway`,而是走 `WechatActionGateway`**:后者在
|
||||||
|
* `WechatSendGateway` 之上多给了三样本功能必须的东西 ——
|
||||||
|
* 幂等(同一 executionId 不会重复发)、自动化发送节流(3s 间隔,防刷屏)、
|
||||||
|
* 审计落盘。底层实际发送仍然经由 `WechatSendGateway.sendPersonal`,
|
||||||
|
* 所以「所有发送统一走 WechatSendGateway」这条约束依然成立。
|
||||||
|
*/
|
||||||
|
private async sendThroughGateway(
|
||||||
|
input: AutomationRunInput,
|
||||||
|
kind: 'reply' | 'report',
|
||||||
|
content: WechatActionContent
|
||||||
|
): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const result = await this.executeAction({
|
||||||
|
idempotencyKey: automationIdempotencyKey(kind, input.executionId),
|
||||||
|
origin: AUTOMATION_SEND_ORIGIN,
|
||||||
|
purpose: AUTOMATION_SEND_PURPOSE[kind],
|
||||||
|
triggerType: 'automation',
|
||||||
|
executionId: input.executionId,
|
||||||
|
recipient: {
|
||||||
|
type: input.isGroup ? 'group' : 'contact',
|
||||||
|
id: input.conversationId,
|
||||||
|
name: input.sourceDisplayName
|
||||||
|
},
|
||||||
|
content
|
||||||
|
})
|
||||||
|
if (result.status === 'sent') return { ok: true }
|
||||||
|
return { ok: false, error: actionErrorMessage(result.errorCode, result.reason) }
|
||||||
|
} catch (error) {
|
||||||
|
// execute() 本身刻意不抛,这里兜的是注入实现或意外异常。
|
||||||
|
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findAction(rule: AutomationRule, type: AutomationAction['type']): AutomationAction | undefined {
|
||||||
|
return rule.actions.find((action) => action.type === type && action.enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 `after` 之后的步骤全部标成 `skipped`(前一步挂了,后面的不许再动)。 */
|
||||||
|
function markRemainingSkipped(
|
||||||
|
steps: AutomationStep[],
|
||||||
|
after: AutomationStepKey,
|
||||||
|
skipReason?: string
|
||||||
|
): void {
|
||||||
|
const from = STEP_ORDER.indexOf(after) + 1
|
||||||
|
for (const key of STEP_ORDER.slice(from)) {
|
||||||
|
const step = steps.find((item) => item.key === key)
|
||||||
|
if (step) markSkipped(step, skipReason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const automationActionRunner = new AutomationActionRunner()
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
import { app } from 'electron'
|
||||||
|
import fs from 'fs-extra'
|
||||||
|
import type {
|
||||||
|
AutomationExecution,
|
||||||
|
AutomationExecutionTrigger
|
||||||
|
} from '../../shared/automation'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AutomationExecutionLogService —— **用户层**执行日志。
|
||||||
|
*
|
||||||
|
* 与 debug 日志的区别:这份记录是给用户看「哪一步坏了」的,所以
|
||||||
|
* - 只保留用户可读字段(规则名、来源显示名、步骤状态、耗时、错误);
|
||||||
|
* - **不含** wxid / localId / serverId / source XML / raw payload / 图片绝对路径;
|
||||||
|
* - 必须落盘,重启后还能看到近期记录。
|
||||||
|
*
|
||||||
|
* 容量有界(`MAX_RECORDS`),否则长期运行会把文件撑到几十兆。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_DIR = 'automation'
|
||||||
|
const EXECUTIONS_FILE = 'executions.json'
|
||||||
|
const MAX_RECORDS = 200
|
||||||
|
|
||||||
|
export interface AutomationExecutionLogDependencies {
|
||||||
|
userDataPath?: () => string
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXECUTION_STATUSES: AutomationExecution['status'][] = ['running', 'success', 'failed']
|
||||||
|
|
||||||
|
const EXECUTION_TRIGGERS: AutomationExecutionTrigger[] = ['message', 'exit', 'schedule', 'manual']
|
||||||
|
|
||||||
|
function normalizeExecution(value: unknown): AutomationExecution | null {
|
||||||
|
if (!value || typeof value !== 'object') return null
|
||||||
|
const record = value as Partial<AutomationExecution>
|
||||||
|
const executionId = String(record.executionId || '').trim()
|
||||||
|
if (!executionId) return null
|
||||||
|
return {
|
||||||
|
executionId,
|
||||||
|
ruleId: String(record.ruleId || ''),
|
||||||
|
ruleName: String(record.ruleName || ''),
|
||||||
|
triggerTime: Number(record.triggerTime) || 0,
|
||||||
|
/*
|
||||||
|
* `trigger` 必须在这里**显式透传**。
|
||||||
|
*
|
||||||
|
* 这个归一化是白名单式的:没列出来的字段会被静默丢掉。定时日报的
|
||||||
|
* 「本次是定时跑的还是用户点的立即执行」全靠这个字段区分,
|
||||||
|
* 漏掉它就会变成"写的时候有、读出来永远没有"—— 而且没有任何报错。
|
||||||
|
* 旧记录没有这个字段,保持 `undefined`(读盘不猜测)。
|
||||||
|
*/
|
||||||
|
...(EXECUTION_TRIGGERS.includes(record.trigger as AutomationExecutionTrigger)
|
||||||
|
? { trigger: record.trigger as AutomationExecutionTrigger }
|
||||||
|
: {}),
|
||||||
|
sourceDisplayName: String(record.sourceDisplayName || ''),
|
||||||
|
// 不认识的 status(含历史遗留值)一律降级成 `running`,绝不凭空造出成功/失败。
|
||||||
|
status: EXECUTION_STATUSES.includes(record.status as AutomationExecution['status'])
|
||||||
|
? (record.status as AutomationExecution['status'])
|
||||||
|
: 'running',
|
||||||
|
durationMs: Number(record.durationMs) || 0,
|
||||||
|
steps: Array.isArray(record.steps) ? record.steps : [],
|
||||||
|
...(record.errorSummary ? { errorSummary: String(record.errorSummary) } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AutomationExecutionLogService {
|
||||||
|
private readonly userDataPath: () => string
|
||||||
|
private records: AutomationExecution[] = []
|
||||||
|
private loaded = false
|
||||||
|
|
||||||
|
constructor(dependencies: AutomationExecutionLogDependencies = {}) {
|
||||||
|
this.userDataPath = dependencies.userDataPath ?? (() => app.getPath('userData'))
|
||||||
|
}
|
||||||
|
|
||||||
|
list(query: { limit?: number } = {}): AutomationExecution[] {
|
||||||
|
this.ensureLoaded()
|
||||||
|
const requested = Number(query?.limit)
|
||||||
|
const limit = Number.isFinite(requested) && requested > 0 ? Math.floor(requested) : MAX_RECORDS
|
||||||
|
return this.records.slice(0, Math.min(limit, MAX_RECORDS)).map((record) => structuredClone(record))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入(或按 `executionId` 覆盖)一条执行记录。
|
||||||
|
*
|
||||||
|
* 覆盖语义是必需的:执行是「先建 running、跑完再回落终态」,
|
||||||
|
* 中间态与终态共用同一个 `executionId`。
|
||||||
|
*/
|
||||||
|
record(execution: AutomationExecution): void {
|
||||||
|
this.ensureLoaded()
|
||||||
|
const normalized = normalizeExecution(execution)
|
||||||
|
if (!normalized) return
|
||||||
|
const withoutSame = this.records.filter((item) => item.executionId !== normalized.executionId)
|
||||||
|
this.records = [normalized, ...withoutSame].slice(0, MAX_RECORDS)
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): boolean {
|
||||||
|
this.ensureLoaded()
|
||||||
|
this.records = []
|
||||||
|
this.persist()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计 `sinceMs` 之后(含)的记录数与成功数。用于顶部「今日执行」。
|
||||||
|
*
|
||||||
|
* 每条记录都对应一次**真正跑过**的执行(gate 拦下的消息不会产生记录),
|
||||||
|
* 所以这里直接计数即可。
|
||||||
|
*/
|
||||||
|
countSince(sinceMs: number): { total: number; success: number } {
|
||||||
|
this.ensureLoaded()
|
||||||
|
const from = Number(sinceMs) || 0
|
||||||
|
let total = 0
|
||||||
|
let success = 0
|
||||||
|
for (const record of this.records) {
|
||||||
|
if (record.triggerTime < from) continue
|
||||||
|
total += 1
|
||||||
|
if (record.status === 'success') success += 1
|
||||||
|
}
|
||||||
|
return { total, success }
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureLoaded(): void {
|
||||||
|
if (this.loaded) return
|
||||||
|
this.loaded = true
|
||||||
|
try {
|
||||||
|
const raw = fs.readJsonSync(this.executionsFilePath()) as unknown
|
||||||
|
const values = Array.isArray(raw) ? raw : []
|
||||||
|
this.records = values
|
||||||
|
.map((value) => normalizeExecution(value))
|
||||||
|
.filter((value): value is AutomationExecution => value !== null)
|
||||||
|
.slice(0, MAX_RECORDS)
|
||||||
|
} catch {
|
||||||
|
this.records = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private executionsFilePath(): string {
|
||||||
|
return path.join(this.userDataPath(), STORAGE_DIR, EXECUTIONS_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
private persist(): void {
|
||||||
|
try {
|
||||||
|
const filePath = this.executionsFilePath()
|
||||||
|
fs.ensureDirSync(path.dirname(filePath))
|
||||||
|
fs.writeJsonSync(filePath, this.records, { spaces: 2 })
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`[Automation] 保存执行日志失败: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const automationExecutionLogService = new AutomationExecutionLogService()
|
||||||
@@ -0,0 +1,652 @@
|
|||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { app } from 'electron'
|
||||||
|
import fs from 'fs-extra'
|
||||||
|
import {
|
||||||
|
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
|
||||||
|
createDefaultDailyReportRule,
|
||||||
|
createDefaultLeaveNotificationRule,
|
||||||
|
createDefaultScheduledReportRule,
|
||||||
|
normalizeRuleDraft,
|
||||||
|
type AutomationRule,
|
||||||
|
type AutomationRuleType,
|
||||||
|
type ScheduledReportAutomationConfig
|
||||||
|
} from '../../shared/automation'
|
||||||
|
import {
|
||||||
|
planLeaveNotificationMigration,
|
||||||
|
type LeaveNotificationMigrationPlan,
|
||||||
|
type LegacyLeaveNotificationState
|
||||||
|
} from './leave-notification-migration'
|
||||||
|
import {
|
||||||
|
planScheduledReportMigrationBatch,
|
||||||
|
type LegacyScheduledReportTask,
|
||||||
|
type ScheduledReportMigrationSummary
|
||||||
|
} from './scheduled-report-migration'
|
||||||
|
import {
|
||||||
|
GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH,
|
||||||
|
insertGroupNamePlaceholder
|
||||||
|
} from '../../shared/group-exit-monitor'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AutomationRuleStore —— 自动化规则的持久化与增删改查。
|
||||||
|
*
|
||||||
|
* 存储形态刻意做到最简:一个 JSON 文件(`{userData}/automation/rules.json`)。
|
||||||
|
* 规则总量是个位数到几十条,引入数据库只会增加迁移负担。
|
||||||
|
*
|
||||||
|
* **一次性标记全部单独存**(`builtinSeeded` / `leaveNotificationMigrated` /
|
||||||
|
* `notificationTemplateUpgraded` / `scheduledReportMigrated`):
|
||||||
|
* 如果靠"文件里有没有那条内置规则"来判断是否播种,用户一旦删掉它,
|
||||||
|
* 下次启动就会被重新塞回来 —— 用户会认为删除功能坏了。迁移同理。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_DIR = 'automation'
|
||||||
|
const RULES_FILE = 'rules.json'
|
||||||
|
const MIGRATION_BACKUP_FILE = 'leave-notification-migration-backup.json'
|
||||||
|
/** 旧退群监控的状态文件(迁移时读一次,之后运行期不再读)。 */
|
||||||
|
const LEGACY_MONITOR_FILE = 'group-exit-monitor.json'
|
||||||
|
/** 旧定时日报的**任务**文件(迁移时读一次,之后运行期不再读)。 */
|
||||||
|
const LEGACY_SCHEDULED_DIR = 'scheduled-reports'
|
||||||
|
const LEGACY_SCHEDULED_TASKS_FILE = 'tasks.json'
|
||||||
|
const SCHEDULED_MIGRATION_BACKUP_FILE = 'scheduled-report-migration-backup.json'
|
||||||
|
const CURRENT_VERSION = 4
|
||||||
|
|
||||||
|
interface StoredRules {
|
||||||
|
version: number
|
||||||
|
/** 内置「@我生成日报」是否已经播种过。**即使随后被删除也保持 true**。 */
|
||||||
|
builtinSeeded: boolean
|
||||||
|
/** 旧退群通知配置是否已经迁移过。**即使随后被删除也保持 true**。 */
|
||||||
|
leaveNotificationMigrated: boolean
|
||||||
|
/**
|
||||||
|
* 是否已经替用户往模板里补过 `{groupName}`。
|
||||||
|
*
|
||||||
|
* **必须有这个标记**:没有它就得靠"模板里有没有 `{groupName}`"来判断,
|
||||||
|
* 于是用户主动删掉那一行后、下次启动又会被补回来 —— 删不掉的东西最烦人。
|
||||||
|
*/
|
||||||
|
notificationTemplateUpgraded: boolean
|
||||||
|
/**
|
||||||
|
* 旧「定时日报」任务是否已经迁移过。
|
||||||
|
*
|
||||||
|
* ⚠️ 与其它标记有一点不同:迁移需要**把旧群标识解析成稳定会话 id**,
|
||||||
|
* 而那需要数据库。数据库没就绪时这一位**保持 false**,等解析器注入后再补跑 ——
|
||||||
|
* 否则会把每一条规则都误判成"目标无法确认"。
|
||||||
|
*/
|
||||||
|
scheduledReportMigrated: boolean
|
||||||
|
rules: AutomationRule[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutomationRuleStoreDependencies {
|
||||||
|
userDataPath?: () => string
|
||||||
|
now?: () => number
|
||||||
|
/**
|
||||||
|
* 迁移旧定时日报时,把 legacy 群标识(会话 md5 / 群名 / roomId)解析成
|
||||||
|
* **稳定会话 id**(`xxx@chatroom`)。解析不到返回 `undefined`。
|
||||||
|
*
|
||||||
|
* 不注入 ⇒ 迁移**推迟**(不写任何规则、不置标记)。
|
||||||
|
*/
|
||||||
|
resolveLegacyConversationId?: (raw: string) => string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyState(): StoredRules {
|
||||||
|
return {
|
||||||
|
version: CURRENT_VERSION,
|
||||||
|
builtinSeeded: false,
|
||||||
|
leaveNotificationMigrated: false,
|
||||||
|
notificationTemplateUpgraded: false,
|
||||||
|
scheduledReportMigrated: false,
|
||||||
|
rules: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRuleType(value: unknown): AutomationRuleType {
|
||||||
|
return value === 'leave_notification'
|
||||||
|
? 'leave_notification'
|
||||||
|
: value === 'scheduled_report'
|
||||||
|
? 'scheduled_report'
|
||||||
|
: 'daily_report'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单条规则的读盘归一化。
|
||||||
|
*
|
||||||
|
* 走 `normalizeRuleDraft` —— 它覆盖了 `AutomationRule` 除 id / 时间戳之外的**全部**字段,
|
||||||
|
* 所以这是无损的,同时自动补上历史 rules.json 缺失的 `ruleType` / 各类 config。
|
||||||
|
*/
|
||||||
|
function normalizeStoredRule(value: unknown): AutomationRule | null {
|
||||||
|
if (!value || typeof value !== 'object') return null
|
||||||
|
const raw = value as Partial<AutomationRule>
|
||||||
|
const id = String(raw.id || '').trim()
|
||||||
|
if (!id) return null
|
||||||
|
const ruleType = normalizeRuleType(raw.ruleType)
|
||||||
|
const normalized = normalizeRuleDraft(
|
||||||
|
{
|
||||||
|
...raw,
|
||||||
|
ruleType,
|
||||||
|
// 历史规则没有这些字段;反过来也要避免脏字段落盘。
|
||||||
|
...(ruleType === 'leave_notification' ? { leaveNotification: raw.leaveNotification } : {}),
|
||||||
|
...(ruleType === 'scheduled_report' ? { scheduledReport: raw.scheduledReport } : {})
|
||||||
|
},
|
||||||
|
String(raw.name || '').trim() || '未命名自动化'
|
||||||
|
)
|
||||||
|
const createdAt = Number(raw.createdAt) || 0
|
||||||
|
const updatedAt = Number(raw.updatedAt) || createdAt
|
||||||
|
return {
|
||||||
|
...normalized,
|
||||||
|
id,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
...(normalized.ruleType === 'leave_notification' && normalized.leaveNotification
|
||||||
|
? { leaveNotification: normalized.leaveNotification }
|
||||||
|
: {}),
|
||||||
|
...(normalized.ruleType === 'scheduled_report' && normalized.scheduledReport
|
||||||
|
? { scheduledReport: normalized.scheduledReport }
|
||||||
|
: {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一次性替用户把 `{groupName}` 补进退群通知模板。
|
||||||
|
*
|
||||||
|
* **为什么替他填**:模板里原先根本没有群名变量(`{groupRemark}` 是成员在本群的昵称)。
|
||||||
|
* 目标一旦不是「当前群聊」,通知就等于「张三退群了」—— 收件人不知道是哪个群。
|
||||||
|
*
|
||||||
|
* **为什么只跑一次**:靠"模板里有没有 `{groupName}`"判断会导致用户删掉后被反复补回来。
|
||||||
|
* 所以由 `notificationTemplateUpgraded` 标记保证一次性;用户之后删掉不会回来。
|
||||||
|
*/
|
||||||
|
function upgradeLeaveNotificationTemplate(rules: AutomationRule[]): {
|
||||||
|
rules: AutomationRule[]
|
||||||
|
count: number
|
||||||
|
} {
|
||||||
|
let count = 0
|
||||||
|
const next = rules.map((rule) => {
|
||||||
|
if (rule.ruleType !== 'leave_notification' || !rule.leaveNotification) return rule
|
||||||
|
const current = rule.leaveNotification.template
|
||||||
|
const upgraded = insertGroupNamePlaceholder(current)
|
||||||
|
// 补完不能超长:超了下次读盘会被校验拦下、整条模板被重置成默认,反而更糟。
|
||||||
|
if (upgraded === current || upgraded.length > GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH) {
|
||||||
|
return rule
|
||||||
|
}
|
||||||
|
count += 1
|
||||||
|
return { ...rule, leaveNotification: { ...rule.leaveNotification, template: upgraded } }
|
||||||
|
})
|
||||||
|
return { rules: next, count }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调度游标(`lastRunAt` / `lastScheduledSlot`)**由 scheduler 拥有**,草稿改不动它们。
|
||||||
|
*
|
||||||
|
* 保存规则时一律从 `current` 取,而不是从草稿取 —— 否则用户进一次编辑页保存,
|
||||||
|
* 就会把"已消费的槽位"抹掉,导致当天重复补跑一次日报。
|
||||||
|
*/
|
||||||
|
function pickScheduledReportRuntime(
|
||||||
|
current: ScheduledReportAutomationConfig | undefined
|
||||||
|
): Partial<ScheduledReportAutomationConfig> {
|
||||||
|
if (!current) return {}
|
||||||
|
return {
|
||||||
|
...(current.lastRunAt ? { lastRunAt: current.lastRunAt } : {}),
|
||||||
|
...(current.lastScheduledSlot ? { lastScheduledSlot: current.lastScheduledSlot } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读盘容错:任何字段可疑都降级成安全值,绝不因为一个坏文件让功能整体不可用。 */
|
||||||
|
function normalizeStored(value: unknown): StoredRules {
|
||||||
|
if (!value || typeof value !== 'object') return emptyState()
|
||||||
|
const input = value as Partial<StoredRules>
|
||||||
|
const rules = Array.isArray(input.rules)
|
||||||
|
? input.rules
|
||||||
|
.map((rule) => normalizeStoredRule(rule))
|
||||||
|
.filter((rule): rule is AutomationRule => rule !== null)
|
||||||
|
: []
|
||||||
|
return {
|
||||||
|
version: Number(input.version) || 1,
|
||||||
|
builtinSeeded: input.builtinSeeded === true,
|
||||||
|
leaveNotificationMigrated: input.leaveNotificationMigrated === true,
|
||||||
|
notificationTemplateUpgraded: input.notificationTemplateUpgraded === true,
|
||||||
|
scheduledReportMigrated: input.scheduledReportMigrated === true,
|
||||||
|
rules
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AutomationRuleStore {
|
||||||
|
private readonly userDataPath: () => string
|
||||||
|
private readonly now: () => number
|
||||||
|
private state: StoredRules = emptyState()
|
||||||
|
private loaded = false
|
||||||
|
/** 迁移结果,供启动日志/报告读取(不含任何 id)。 */
|
||||||
|
private lastMigrationPlan: LeaveNotificationMigrationPlan | null = null
|
||||||
|
private lastScheduledMigrationSummary: ScheduledReportMigrationSummary | null = null
|
||||||
|
private legacyConversationResolver: ((raw: string) => string | undefined) | null
|
||||||
|
|
||||||
|
constructor(dependencies: AutomationRuleStoreDependencies = {}) {
|
||||||
|
this.userDataPath = dependencies.userDataPath ?? (() => app.getPath('userData'))
|
||||||
|
this.now = dependencies.now ?? (() => Date.now())
|
||||||
|
this.legacyConversationResolver = dependencies.resolveLegacyConversationId ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注入 / 替换旧定时日报迁移所需的会话解析器(数据库就绪后由 main 调用)。
|
||||||
|
*
|
||||||
|
* 若之前因为"解析器不可用"推迟了迁移,这里会**立刻补跑**并落盘。
|
||||||
|
*/
|
||||||
|
setLegacyConversationResolver(resolver: (raw: string) => string | undefined): void {
|
||||||
|
this.legacyConversationResolver = resolver
|
||||||
|
if (!this.loaded) return
|
||||||
|
if (this.state.scheduledReportMigrated) return
|
||||||
|
if (this.runScheduledReportMigration(this.state)) this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 旧定时日报迁移是否仍在等待解析器(仅用于启动日志与测试断言)。 */
|
||||||
|
hasPendingScheduledReportMigration(): boolean {
|
||||||
|
this.ensureLoaded()
|
||||||
|
return !this.state.scheduledReportMigrated && this.readLegacyScheduledTasks().length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
listRules(): AutomationRule[] {
|
||||||
|
this.ensureLoaded()
|
||||||
|
return this.state.rules.map((rule) => structuredClone(rule))
|
||||||
|
}
|
||||||
|
|
||||||
|
getRule(id: string): AutomationRule | undefined {
|
||||||
|
const key = String(id || '').trim()
|
||||||
|
if (!key) return undefined
|
||||||
|
const found = this.listRules().find((rule) => rule.id === key)
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上一次迁移的判定结果(未迁移时为 null)。 */
|
||||||
|
getLastLeaveNotificationMigration(): LeaveNotificationMigrationPlan | null {
|
||||||
|
this.ensureLoaded()
|
||||||
|
return this.lastMigrationPlan ? { ...this.lastMigrationPlan } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上一次旧定时日报迁移的统计(未迁移时为 null)。 */
|
||||||
|
getLastScheduledReportMigration(): ScheduledReportMigrationSummary | null {
|
||||||
|
this.ensureLoaded()
|
||||||
|
return this.lastScheduledMigrationSummary ? { ...this.lastScheduledMigrationSummary } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
createRule(draft: unknown): AutomationRule {
|
||||||
|
this.ensureLoaded()
|
||||||
|
const timestamp = this.now()
|
||||||
|
const normalized = normalizeRuleDraft(draft)
|
||||||
|
const rule: AutomationRule = {
|
||||||
|
...normalized,
|
||||||
|
id: randomUUID(),
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp
|
||||||
|
}
|
||||||
|
this.state.rules = [...this.state.rules, rule]
|
||||||
|
this.persist()
|
||||||
|
return structuredClone(rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRule(id: string, draft: unknown): AutomationRule | undefined {
|
||||||
|
this.ensureLoaded()
|
||||||
|
const key = String(id || '').trim()
|
||||||
|
const index = this.state.rules.findIndex((rule) => rule.id === key)
|
||||||
|
if (index < 0) return undefined
|
||||||
|
const current = this.state.rules[index]
|
||||||
|
// 名字留空时沿用原名,而不是变成「未命名自动化」—— 编辑页只改开关时不该改名。
|
||||||
|
const normalized = normalizeRuleDraft(draft, current.name)
|
||||||
|
const rule: AutomationRule = {
|
||||||
|
...current,
|
||||||
|
...normalized,
|
||||||
|
id: current.id,
|
||||||
|
// ruleType 不允许被草稿改掉:它是规则的**身份**,不是可编辑字段。
|
||||||
|
ruleType: current.ruleType,
|
||||||
|
...(current.ruleType === 'leave_notification' && normalized.leaveNotification
|
||||||
|
? { leaveNotification: normalized.leaveNotification }
|
||||||
|
: {}),
|
||||||
|
...(current.ruleType === 'scheduled_report' && normalized.scheduledReport
|
||||||
|
? {
|
||||||
|
scheduledReport: {
|
||||||
|
...normalized.scheduledReport,
|
||||||
|
...pickScheduledReportRuntime(current.scheduledReport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
createdAt: current.createdAt,
|
||||||
|
updatedAt: this.now()
|
||||||
|
}
|
||||||
|
// 切类型时不能留下另一种类型才认识的字段。
|
||||||
|
if (rule.ruleType === 'daily_report') {
|
||||||
|
delete rule.leaveNotification
|
||||||
|
delete rule.scheduledReport
|
||||||
|
} else if (rule.ruleType === 'leave_notification') {
|
||||||
|
delete rule.scheduledReport
|
||||||
|
} else {
|
||||||
|
delete rule.leaveNotification
|
||||||
|
}
|
||||||
|
this.state.rules = this.state.rules.map((item, at) => (at === index ? rule : item))
|
||||||
|
this.persist()
|
||||||
|
return structuredClone(rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存「退群通知」规则(**singleton upsert**)。
|
||||||
|
*
|
||||||
|
* 不存在则创建(固定 id),存在则更新 —— 所以点多少次保存都只有一条规则。
|
||||||
|
*/
|
||||||
|
saveLeaveNotificationRule(draft: unknown): AutomationRule {
|
||||||
|
this.ensureLoaded()
|
||||||
|
const existing = this.state.rules.find(
|
||||||
|
(rule) => rule.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID
|
||||||
|
)
|
||||||
|
if (existing) {
|
||||||
|
const updated = this.updateRule(existing.id, { ...(draft as object), ruleType: 'leave_notification' })
|
||||||
|
if (updated) return updated
|
||||||
|
}
|
||||||
|
const timestamp = this.now()
|
||||||
|
const normalized = normalizeRuleDraft({ ...(draft as object), ruleType: 'leave_notification' })
|
||||||
|
const rule: AutomationRule = {
|
||||||
|
...createDefaultLeaveNotificationRule(timestamp),
|
||||||
|
...normalized,
|
||||||
|
id: BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
|
||||||
|
ruleType: 'leave_notification',
|
||||||
|
name: normalized.name || '退群通知',
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp
|
||||||
|
}
|
||||||
|
this.state.rules = [...this.state.rules, rule]
|
||||||
|
this.persist()
|
||||||
|
return structuredClone(rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteRule(id: string): boolean {
|
||||||
|
this.ensureLoaded()
|
||||||
|
const key = String(id || '').trim()
|
||||||
|
const before = this.state.rules.length
|
||||||
|
this.state.rules = this.state.rules.filter((rule) => rule.id !== key)
|
||||||
|
if (this.state.rules.length === before) return false
|
||||||
|
this.persist()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
setRuleEnabled(id: string, enabled: boolean): AutomationRule | undefined {
|
||||||
|
const rule = this.getRule(id)
|
||||||
|
if (!rule) return undefined
|
||||||
|
return this.updateRule(id, { ...rule, enabled: enabled === true })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写**调度游标**(`lastRunAt` / `lastScheduledSlot`)。
|
||||||
|
*
|
||||||
|
* 刻意不走 `updateRule`:这不是用户配置编辑,走草稿归一化会顺带触碰别的字段,
|
||||||
|
* 也可能被"草稿里没这个字段"给抹掉。这里只允许改这两个键。
|
||||||
|
*/
|
||||||
|
setScheduledReportRuntime(
|
||||||
|
id: string,
|
||||||
|
patch: { lastRunAt?: string; lastScheduledSlot?: string }
|
||||||
|
): AutomationRule | undefined {
|
||||||
|
this.ensureLoaded()
|
||||||
|
const key = String(id || '').trim()
|
||||||
|
const index = this.state.rules.findIndex((rule) => rule.id === key)
|
||||||
|
if (index < 0) return undefined
|
||||||
|
const current = this.state.rules[index]
|
||||||
|
if (current.ruleType !== 'scheduled_report' || !current.scheduledReport) return undefined
|
||||||
|
const next: AutomationRule = {
|
||||||
|
...current,
|
||||||
|
scheduledReport: {
|
||||||
|
...current.scheduledReport,
|
||||||
|
...(patch.lastRunAt !== undefined ? { lastRunAt: patch.lastRunAt } : {}),
|
||||||
|
...(patch.lastScheduledSlot !== undefined
|
||||||
|
? { lastScheduledSlot: patch.lastScheduledSlot }
|
||||||
|
: {})
|
||||||
|
},
|
||||||
|
updatedAt: this.now()
|
||||||
|
}
|
||||||
|
this.state.rules = this.state.rules.map((item, at) => (at === index ? next : item))
|
||||||
|
this.persist()
|
||||||
|
return structuredClone(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureLoaded(): void {
|
||||||
|
if (this.loaded) return
|
||||||
|
this.loaded = true
|
||||||
|
let stored: StoredRules
|
||||||
|
try {
|
||||||
|
stored = normalizeStored(fs.readJsonSync(this.rulesFilePath()) as unknown)
|
||||||
|
} catch {
|
||||||
|
stored = emptyState()
|
||||||
|
}
|
||||||
|
if (!stored.builtinSeeded) {
|
||||||
|
stored.builtinSeeded = true
|
||||||
|
stored.rules = [...stored.rules, createDefaultDailyReportRule(this.now())]
|
||||||
|
}
|
||||||
|
if (!stored.leaveNotificationMigrated) {
|
||||||
|
stored.leaveNotificationMigrated = true
|
||||||
|
stored.rules = this.migrateLeaveNotification(stored.rules)
|
||||||
|
}
|
||||||
|
if (!stored.notificationTemplateUpgraded) {
|
||||||
|
stored.notificationTemplateUpgraded = true
|
||||||
|
const upgraded = upgradeLeaveNotificationTemplate(stored.rules)
|
||||||
|
stored.rules = upgraded.rules
|
||||||
|
// 只记条数,**不记模板正文**。
|
||||||
|
if (upgraded.count > 0) {
|
||||||
|
console.log(`[Automation] 退群通知模板已补上群名变量(${upgraded.count} 条)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.runScheduledReportMigration(stored)
|
||||||
|
stored.version = CURRENT_VERSION
|
||||||
|
this.state = stored
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 旧退群通知配置 → 内置退群通知规则。**只跑一次**,且先备份旧配置。 */
|
||||||
|
private migrateLeaveNotification(rules: AutomationRule[]): AutomationRule[] {
|
||||||
|
if (rules.some((rule) => rule.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID)) return rules
|
||||||
|
const timestamp = this.now()
|
||||||
|
const legacy = this.readLegacyLeaveNotificationState()
|
||||||
|
if (!legacy) {
|
||||||
|
// 全新安装:没有历史可迁,直接建默认规则(默认目标:文件传输助手)。
|
||||||
|
this.logMigration('fresh_install', null)
|
||||||
|
return [...rules, createDefaultLeaveNotificationRule(timestamp)]
|
||||||
|
}
|
||||||
|
const plan = planLeaveNotificationMigration(legacy)
|
||||||
|
this.lastMigrationPlan = plan
|
||||||
|
this.writeMigrationBackup(legacy, plan)
|
||||||
|
this.logMigration(plan.outcome, plan)
|
||||||
|
return [
|
||||||
|
...rules,
|
||||||
|
createDefaultLeaveNotificationRule(timestamp, {
|
||||||
|
template: plan.template,
|
||||||
|
target: plan.target,
|
||||||
|
enabled: plan.enabled,
|
||||||
|
// 二次勾选(旧「通知群聊」)逐字保留,迁移不再是有损的。
|
||||||
|
notifyScope: plan.notifyScope,
|
||||||
|
notifyRoomIds: plan.notifyRoomIds
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 旧「定时日报」任务 → `scheduled_report` 规则。**只跑一次**。
|
||||||
|
*
|
||||||
|
* 返回 `true` 表示本次真的做了迁移并需要落盘。
|
||||||
|
* **解析器不可用时返回 `false` 且不置标记** —— 迁移推迟到数据库就绪后补跑。
|
||||||
|
*/
|
||||||
|
private runScheduledReportMigration(stored: StoredRules): boolean {
|
||||||
|
if (stored.scheduledReportMigrated) return false
|
||||||
|
const tasks = this.readLegacyScheduledTasks()
|
||||||
|
if (!tasks.length) {
|
||||||
|
// 全新安装 / 从没用过旧定时日报:没有历史可迁,直接置位。
|
||||||
|
stored.scheduledReportMigrated = true
|
||||||
|
console.log('[Automation] 定时日报迁移 outcome=fresh_install')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const resolver = this.legacyConversationResolver
|
||||||
|
if (!resolver) {
|
||||||
|
console.log(
|
||||||
|
`[Automation] 定时日报迁移已推迟:数据库尚未就绪,无法解析旧群标识(待迁 ${tasks.length} 条)`
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const { plans, summary } = planScheduledReportMigrationBatch(tasks, resolver, this.now())
|
||||||
|
this.lastScheduledMigrationSummary = summary
|
||||||
|
this.writeScheduledMigrationBackup(tasks, summary)
|
||||||
|
const existing = new Set(stored.rules.map((rule) => rule.id))
|
||||||
|
const migrated: AutomationRule[] = []
|
||||||
|
for (const plan of plans) {
|
||||||
|
// 幂等:同一 id 已存在就不再插入(重复执行迁移也不会多出规则)。
|
||||||
|
if (existing.has(plan.ruleId)) continue
|
||||||
|
existing.add(plan.ruleId)
|
||||||
|
migrated.push({
|
||||||
|
...createDefaultScheduledReportRule(plan.createdAt, {
|
||||||
|
id: plan.ruleId,
|
||||||
|
name: plan.name,
|
||||||
|
enabled: plan.enabled,
|
||||||
|
config: plan.config
|
||||||
|
}),
|
||||||
|
createdAt: plan.createdAt,
|
||||||
|
updatedAt: plan.updatedAt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
stored.rules = [...stored.rules, ...migrated]
|
||||||
|
stored.scheduledReportMigrated = true
|
||||||
|
// 只记数量与判定分布,**禁止**出现群名 / roomId / 群主昵称。
|
||||||
|
console.log(
|
||||||
|
`[Automation] 定时日报迁移 outcome=done total=${summary.total} migrated=${migrated.length}` +
|
||||||
|
` lossless=${summary.lossless} needsReview=${summary.needsReview}` +
|
||||||
|
` duplicatesSkipped=${summary.duplicatesSkipped}`
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读旧定时日报**任务**文件。
|
||||||
|
*
|
||||||
|
* 只读、只在这里读一次;运行期其余代码**不再读** legacy(避免双读)。
|
||||||
|
* 读失败视为"没有历史任务",不抛。
|
||||||
|
*/
|
||||||
|
private readLegacyScheduledTasks(): LegacyScheduledReportTask[] {
|
||||||
|
try {
|
||||||
|
const value = fs.readJsonSync(
|
||||||
|
path.join(this.userDataPath(), LEGACY_SCHEDULED_DIR, LEGACY_SCHEDULED_TASKS_FILE)
|
||||||
|
) as unknown
|
||||||
|
return Array.isArray(value) ? (value as LegacyScheduledReportTask[]) : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 迁移前把旧任务**原样**落一份 backup(不做不可逆覆盖)。 */
|
||||||
|
private writeScheduledMigrationBackup(
|
||||||
|
tasks: LegacyScheduledReportTask[],
|
||||||
|
summary: ScheduledReportMigrationSummary
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
const filePath = path.join(
|
||||||
|
this.userDataPath(),
|
||||||
|
STORAGE_DIR,
|
||||||
|
SCHEDULED_MIGRATION_BACKUP_FILE
|
||||||
|
)
|
||||||
|
fs.ensureDirSync(path.dirname(filePath))
|
||||||
|
fs.writeJsonSync(
|
||||||
|
filePath,
|
||||||
|
{
|
||||||
|
migratedAt: this.now(),
|
||||||
|
summary,
|
||||||
|
// 备份里保留原始字段:这是**用户数据**,不是日志,不进任何用户可见界面。
|
||||||
|
legacyTasks: tasks
|
||||||
|
},
|
||||||
|
{ spaces: 2 }
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[Automation] 写入定时日报迁移备份失败: ${errorText(error)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读旧退群监控状态文件里的通知相关字段。
|
||||||
|
*
|
||||||
|
* 只读、只在这里读一次;运行期其余代码**不再读** legacy(避免双读)。
|
||||||
|
* 读失败视为"没有历史配置",不抛。
|
||||||
|
*/
|
||||||
|
private readLegacyLeaveNotificationState(): LegacyLeaveNotificationState | null {
|
||||||
|
try {
|
||||||
|
const raw = fs.readJsonSync(
|
||||||
|
path.join(this.userDataPath(), LEGACY_MONITOR_FILE)
|
||||||
|
) as Partial<LegacyLeaveNotificationState>
|
||||||
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
return {
|
||||||
|
monitoredRoomIds: Array.isArray(raw.monitoredRoomIds) ? raw.monitoredRoomIds : [],
|
||||||
|
notificationRoomIds: Array.isArray(raw.notificationRoomIds) ? raw.notificationRoomIds : [],
|
||||||
|
...(raw.notificationTemplate !== undefined
|
||||||
|
? { notificationTemplate: raw.notificationTemplate }
|
||||||
|
: {})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 迁移前把旧配置原样落一份 backup。
|
||||||
|
*
|
||||||
|
* 不做不可逆覆盖:旧状态文件本身也**不删**,只是不再被运行时代码读取。
|
||||||
|
*/
|
||||||
|
private writeMigrationBackup(
|
||||||
|
legacy: LegacyLeaveNotificationState,
|
||||||
|
plan: LeaveNotificationMigrationPlan
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
const filePath = path.join(this.userDataPath(), STORAGE_DIR, MIGRATION_BACKUP_FILE)
|
||||||
|
fs.ensureDirSync(path.dirname(filePath))
|
||||||
|
fs.writeJsonSync(
|
||||||
|
filePath,
|
||||||
|
{
|
||||||
|
migratedAt: this.now(),
|
||||||
|
outcome: plan.outcome,
|
||||||
|
// 备份里保留原始 roomId:这是**用户数据**,不是日志,不进任何用户可见界面。
|
||||||
|
legacy: {
|
||||||
|
monitoredRoomIds: legacy.monitoredRoomIds,
|
||||||
|
notificationRoomIds: legacy.notificationRoomIds,
|
||||||
|
notificationTemplate: legacy.notificationTemplate
|
||||||
|
},
|
||||||
|
applied: {
|
||||||
|
enabled: plan.enabled,
|
||||||
|
targetType: plan.target.type,
|
||||||
|
notifyScope: plan.notifyScope,
|
||||||
|
notifyRoomIds: plan.notifyRoomIds
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ spaces: 2 }
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[Automation] 写入退群通知迁移备份失败: ${errorText(error)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 迁移日志:只允许出现判定结果与布尔量,**禁止** roomId / 模板正文。 */
|
||||||
|
private logMigration(outcome: string, plan: LeaveNotificationMigrationPlan | null): void {
|
||||||
|
if (!plan) {
|
||||||
|
console.log(`[Automation] 退群通知迁移 outcome=${outcome}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`[Automation] 退群通知迁移 outcome=${outcome} enabled=${plan.enabled}` +
|
||||||
|
` targetType=${plan.target.type} needsReview=${plan.targetNeedsReview}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private rulesFilePath(): string {
|
||||||
|
return path.join(this.userDataPath(), STORAGE_DIR, RULES_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
private persist(): void {
|
||||||
|
try {
|
||||||
|
const filePath = this.rulesFilePath()
|
||||||
|
fs.ensureDirSync(path.dirname(filePath))
|
||||||
|
fs.writeJsonSync(filePath, this.state, { spaces: 2 })
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`[Automation] 保存规则失败: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const automationRuleStore = new AutomationRuleStore()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,266 @@
|
|||||||
|
import {
|
||||||
|
LEAVE_NOTIFICATION_TARGET_OPTIONS,
|
||||||
|
SCHEDULED_REPORT_TARGET_OPTIONS,
|
||||||
|
leaveNotificationTargetLabel,
|
||||||
|
scheduledReportTargetLabel
|
||||||
|
} from '../../shared/automation'
|
||||||
|
import { WECHAT_FILE_HELPER_USERNAME, isFileHelperUsername } from '../../shared/wechat-identities'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **中性的微信发送目标解析**。
|
||||||
|
*
|
||||||
|
* 退群通知与定时日报的目标语义完全一致(来源会话 / 自己 / 文件传输助手 / 指定好友),
|
||||||
|
* 差的只是"来源会话"从哪来:
|
||||||
|
* - 退群通知 → 事件所在群(`event.conversationId`);
|
||||||
|
* - 定时日报 → 该规则配置的日报来源群(`config.report.sourceConversationId`)。
|
||||||
|
*
|
||||||
|
* ⇒ 所以**只允许有一份实现**(只保留一套 resolve)。
|
||||||
|
* 两个业务各自只有一层薄适配(把参数摊平后交给这里),不重复任何判定逻辑。
|
||||||
|
*
|
||||||
|
* 五条规则(两边共用):
|
||||||
|
* 1. 目标解析失败 → **报错**,绝不 fallback 到别的目标(尤其不许偷偷发文件助手 / 来源群);
|
||||||
|
* 2. 只允许出现在选项表里的目标类型;
|
||||||
|
* 3. 显示名不带 wxid / roomId:日志与界面只出现用户能看懂的称呼;
|
||||||
|
* 4. 联系人必须**可发送**(排除群聊 / 公众号 / 文件传输助手 / 自己);
|
||||||
|
* 5. 只有 `source_chat` 用得到来源会话,其余目标完全不看它。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 解析所需的最小联系人结构(main 侧 `FormattedContact` 结构上可赋值给它)。 */
|
||||||
|
export interface AutomationTargetContact {
|
||||||
|
m_nsUsrName: string
|
||||||
|
m_nsNickName?: string
|
||||||
|
md5?: string
|
||||||
|
type: 'user' | 'group'
|
||||||
|
isOfficialAccount?: boolean
|
||||||
|
wechatNickname?: string
|
||||||
|
remark?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 目标类型(两个业务共用同一组字面量)。 */
|
||||||
|
export type AutomationTargetType = 'source_chat' | 'self' | 'file_transfer' | 'contact'
|
||||||
|
|
||||||
|
export interface ResolvedAutomationTarget {
|
||||||
|
recipient: { type: 'group' | 'contact'; id: string; name: string }
|
||||||
|
/** 用户可读的目标名,直接进执行日志的 `detail`。 */
|
||||||
|
displayName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AutomationTargetResolution =
|
||||||
|
| { ok: true; target: ResolvedAutomationTarget }
|
||||||
|
| { ok: false; error: string }
|
||||||
|
|
||||||
|
/** 联系人的展示名:备注 → 微信昵称 → 会话昵称 → 兜底称呼。**永不回落到 wxid**。 */
|
||||||
|
export function automationContactDisplayName(contact: AutomationTargetContact): string {
|
||||||
|
return (
|
||||||
|
contact.remark?.trim() ||
|
||||||
|
contact.wechatNickname?.trim() ||
|
||||||
|
contact.m_nsNickName?.trim() ||
|
||||||
|
'指定好友'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这个联系人能不能作为「指定好友」的发送目标。
|
||||||
|
*
|
||||||
|
* 排除:群聊、公众号(`gh_`)、文件传输助手、自己。
|
||||||
|
* UI 的联系人选择器与运行时的目标解析**共用这一条判定**,
|
||||||
|
* 否则会出现"能选但发不出去"。
|
||||||
|
*/
|
||||||
|
export function isSendableFriendContact(
|
||||||
|
contact: AutomationTargetContact,
|
||||||
|
selfWxid?: string
|
||||||
|
): boolean {
|
||||||
|
if (contact.type !== 'user') return false
|
||||||
|
if (contact.isOfficialAccount) return false
|
||||||
|
const username = String(contact.m_nsUsrName || '').trim()
|
||||||
|
if (!username) return false
|
||||||
|
if (isFileHelperUsername(username)) return false
|
||||||
|
const self = String(selfWxid || '').trim()
|
||||||
|
if (self && username === self) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 供 UI 用的「可选好友」过滤(与运行时同一套判定)。 */
|
||||||
|
export function filterSendableFriendContacts<T extends AutomationTargetContact>(
|
||||||
|
contacts: T[],
|
||||||
|
selfWxid?: string
|
||||||
|
): T[] {
|
||||||
|
return contacts.filter((contact) => isSendableFriendContact(contact, selfWxid))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 目标类型是否在(该业务的)选项表里。 */
|
||||||
|
function isKnownTargetType(
|
||||||
|
type: string,
|
||||||
|
options: ReadonlyArray<{ type: string }>
|
||||||
|
): boolean {
|
||||||
|
return options.some((option) => option.type === type)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把群标识解析成**稳定会话 id**(`xxx@chatroom`)。
|
||||||
|
*
|
||||||
|
* 兼容三种历史形态:roomId 本身 / 会话 md5 / 群名。
|
||||||
|
* 解析不出来返回 `undefined` —— 调用方必须如实报错,**不许回落**。
|
||||||
|
*/
|
||||||
|
export function resolveGroupConversationId(
|
||||||
|
raw: string,
|
||||||
|
contacts: AutomationTargetContact[]
|
||||||
|
): string | undefined {
|
||||||
|
const value = String(raw || '').trim()
|
||||||
|
if (!value) return undefined
|
||||||
|
if (value.endsWith('@chatroom')) return value
|
||||||
|
const matched = contacts.find(
|
||||||
|
(contact) =>
|
||||||
|
(contact.type === 'group' || contact.m_nsUsrName.endsWith('@chatroom')) &&
|
||||||
|
(contact.md5 === value ||
|
||||||
|
contact.m_nsUsrName === value ||
|
||||||
|
contact.m_nsNickName?.trim() === value)
|
||||||
|
)
|
||||||
|
const conversationId = String(matched?.m_nsUsrName || '').trim()
|
||||||
|
return conversationId.endsWith('@chatroom') ? conversationId : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 群标识 → 可读群名(解析不到时返回空串,由调用方决定兜底文案)。 */
|
||||||
|
export function resolveGroupDisplayName(
|
||||||
|
raw: string,
|
||||||
|
contacts: AutomationTargetContact[]
|
||||||
|
): string {
|
||||||
|
const value = String(raw || '').trim()
|
||||||
|
if (!value) return ''
|
||||||
|
const matched = contacts.find(
|
||||||
|
(contact) =>
|
||||||
|
contact.m_nsUsrName === value ||
|
||||||
|
contact.md5 === value ||
|
||||||
|
contact.m_nsNickName?.trim() === value
|
||||||
|
)
|
||||||
|
return String(matched?.m_nsNickName || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolveAutomationTargetInput {
|
||||||
|
targetType: AutomationTargetType
|
||||||
|
/**
|
||||||
|
* `source_chat` 时使用:来源会话(退群事件所在群 / 日报来源群)。
|
||||||
|
* 允许是 roomId / 会话 md5 / 群名 —— 一律经 `resolveGroupConversationId` 收敛。
|
||||||
|
*/
|
||||||
|
sourceConversationId?: string
|
||||||
|
/** 来源会话的可读名(拿不到时留空,由这里给兜底称呼)。 */
|
||||||
|
sourceDisplayName?: string
|
||||||
|
/** `contact` 时使用:稳定 id(wxid / username)。 */
|
||||||
|
contactId?: string
|
||||||
|
contacts: AutomationTargetContact[]
|
||||||
|
/** 当前登录账号的 wxid;拿不到时传空串。 */
|
||||||
|
selfWxid?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 目标解析的**文案与类型表**(按业务注入)。
|
||||||
|
*
|
||||||
|
* 为什么把文案参数化而不是各写一份 switch:两个业务的**判定逻辑必须一模一样**,
|
||||||
|
* 只有"主语"不同。文案集中放在这里,判定仍然只有一份。
|
||||||
|
*/
|
||||||
|
export interface AutomationTargetMessages {
|
||||||
|
/** 目标类型表的单一来源(与 UI 选项同源)。 */
|
||||||
|
options: ReadonlyArray<{ type: string }>
|
||||||
|
/** 类型非法(伪造值 / 未知值)。 */
|
||||||
|
invalidTarget: string
|
||||||
|
/** `source_chat` 但来源会话拿不到。 */
|
||||||
|
sourceMissing: string
|
||||||
|
/** `source_chat` 且来源会话没有可读名时,`recipient.name` 的兜底。 */
|
||||||
|
sourceRecipientName: string
|
||||||
|
/** `source_chat` 且来源会话没有可读名时,`displayName` 的兜底。 */
|
||||||
|
sourceDisplayName: string
|
||||||
|
/** `self` 但拿不到自身身份。 */
|
||||||
|
selfMissing: string
|
||||||
|
/** `contact` 但没选联系人。 */
|
||||||
|
contactNotChosen: string
|
||||||
|
/** `contact` 但联系人失效 / 不可发送。 */
|
||||||
|
contactUnavailable: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 中性目标解析。**唯一的实现**。
|
||||||
|
*/
|
||||||
|
export function resolveAutomationTarget(
|
||||||
|
input: ResolveAutomationTargetInput,
|
||||||
|
messages: AutomationTargetMessages
|
||||||
|
): AutomationTargetResolution {
|
||||||
|
const type = input.targetType
|
||||||
|
if (!type || !isKnownTargetType(type, messages.options)) {
|
||||||
|
return { ok: false, error: messages.invalidTarget }
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'source_chat': {
|
||||||
|
const conversationId = resolveGroupConversationId(
|
||||||
|
String(input.sourceConversationId || ''),
|
||||||
|
input.contacts
|
||||||
|
)
|
||||||
|
if (!conversationId) return { ok: false, error: messages.sourceMissing }
|
||||||
|
const explicitName = String(input.sourceDisplayName || '').trim()
|
||||||
|
const resolvedName = resolveGroupDisplayName(conversationId, input.contacts)
|
||||||
|
const name = explicitName || resolvedName || messages.sourceRecipientName
|
||||||
|
const displayName = explicitName || resolvedName || messages.sourceDisplayName
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
target: {
|
||||||
|
recipient: { type: 'group', id: conversationId, name },
|
||||||
|
displayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'self': {
|
||||||
|
const selfWxid = String(input.selfWxid || '').trim()
|
||||||
|
// 明确报错,不 fallback。
|
||||||
|
if (!selfWxid) return { ok: false, error: messages.selfMissing }
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
target: {
|
||||||
|
recipient: { type: 'contact', id: selfWxid, name: '我' },
|
||||||
|
displayName: '我'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'file_transfer':
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
target: {
|
||||||
|
recipient: {
|
||||||
|
type: 'contact',
|
||||||
|
id: WECHAT_FILE_HELPER_USERNAME,
|
||||||
|
name: '文件传输助手'
|
||||||
|
},
|
||||||
|
displayName: '文件传输助手'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'contact': {
|
||||||
|
const contactId = String(input.contactId || '').trim()
|
||||||
|
if (!contactId) return { ok: false, error: messages.contactNotChosen }
|
||||||
|
const contact = input.contacts.find(
|
||||||
|
(item) => String(item.m_nsUsrName || '').trim() === contactId
|
||||||
|
)
|
||||||
|
if (!contact || !isSendableFriendContact(contact, input.selfWxid)) {
|
||||||
|
// 之前选择的联系人被删除 / 不可发送 / 找不到时,规则不偷偷改发别处。
|
||||||
|
return { ok: false, error: messages.contactUnavailable }
|
||||||
|
}
|
||||||
|
const displayName = automationContactDisplayName(contact)
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
target: {
|
||||||
|
recipient: { type: 'contact', id: contactId, name: displayName },
|
||||||
|
displayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 退群通知的已知目标类型表(与文案同源)。 */
|
||||||
|
export const LEAVE_NOTIFICATION_TARGET_TYPES = LEAVE_NOTIFICATION_TARGET_OPTIONS
|
||||||
|
|
||||||
|
/** 定时日报的已知目标类型表(与文案同源)。 */
|
||||||
|
export const SCHEDULED_REPORT_TARGET_TYPES = SCHEDULED_REPORT_TARGET_OPTIONS
|
||||||
|
|
||||||
|
export { leaveNotificationTargetLabel, scheduledReportTargetLabel }
|
||||||
@@ -1036,6 +1036,20 @@ export async function getGroupSnapshotAsync(userMd5: string): Promise<GroupSnaps
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把群会话 md5 解析成原生接口真正需要的 roomId(`xxx@chatroom`)。
|
||||||
|
*
|
||||||
|
* 存在的理由:群员统计从界面拿到的是会话 md5(与 `getGroupSnapshot(userMd5)` 同口径),
|
||||||
|
* 而成员相关的原生接口吃的是 username。没有这层导出,调用方就得自己去碰
|
||||||
|
* `dbRef.getWcdb4Client()`,等于让服务层绕开 chat-service 的封装。
|
||||||
|
* 非群会话或解析不到时返回 null。
|
||||||
|
*/
|
||||||
|
export function resolveGroupRoomId(userMd5: string): string | null {
|
||||||
|
if (!dbRef) return null
|
||||||
|
const roomId = dbRef.getWcdb4Client().getUsernameByMd5(userMd5)
|
||||||
|
return roomId && roomId.endsWith('@chatroom') ? roomId : null
|
||||||
|
}
|
||||||
|
|
||||||
/** 退群检测专用轻量读取,不执行成员名称或头像 hydration。 */
|
/** 退群检测专用轻量读取,不执行成员名称或头像 hydration。 */
|
||||||
export async function getGroupMemberIdsAsync(
|
export async function getGroupMemberIdsAsync(
|
||||||
roomId: string
|
roomId: string
|
||||||
|
|||||||
@@ -2,19 +2,17 @@ import { app, BrowserWindow } from 'electron'
|
|||||||
import fs from 'fs-extra'
|
import fs from 'fs-extra'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import * as chat from './chat-service'
|
import * as chat from './chat-service'
|
||||||
import { wechatActionGateway, type WechatActionGateway } from './wechat-action-gateway'
|
|
||||||
import {
|
import {
|
||||||
buildMemberLeftNotification,
|
|
||||||
findRemovedGroupMembers,
|
findRemovedGroupMembers,
|
||||||
groupExitMemberName,
|
groupExitMemberName,
|
||||||
normalizeGroupExitNotificationTemplate,
|
|
||||||
validateGroupExitNotificationTemplate,
|
|
||||||
type GroupExitMonitorEvent,
|
type GroupExitMonitorEvent,
|
||||||
type GroupExitMonitorMember,
|
type GroupExitMonitorMember,
|
||||||
type GroupExitNotificationStatus,
|
|
||||||
type GroupExitNotificationState,
|
|
||||||
type GroupExitMonitorState
|
type GroupExitMonitorState
|
||||||
} from '../../shared/group-exit-monitor'
|
} from '../../shared/group-exit-monitor'
|
||||||
|
import {
|
||||||
|
toGroupMemberExitedEvent,
|
||||||
|
type GroupMemberExitedEvent
|
||||||
|
} from '../../shared/group-exit-event'
|
||||||
|
|
||||||
type StoredState = {
|
type StoredState = {
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
@@ -23,9 +21,14 @@ type StoredState = {
|
|||||||
lastReadAt?: number
|
lastReadAt?: number
|
||||||
monitorSelectionConfigured?: boolean
|
monitorSelectionConfigured?: boolean
|
||||||
monitoredRoomIds?: string[]
|
monitoredRoomIds?: string[]
|
||||||
notificationRoomIds?: string[]
|
|
||||||
notificationTemplate?: unknown
|
|
||||||
snapshots?: Partial<StoredGroupSnapshot>[]
|
snapshots?: Partial<StoredGroupSnapshot>[]
|
||||||
|
/*
|
||||||
|
* 历史遗留字段(`notificationRoomIds` / `notificationTemplate`)**刻意不再声明**。
|
||||||
|
*
|
||||||
|
* 它们已经迁到「自动化 → 退群通知」规则里,本服务运行期不再读、不再写。
|
||||||
|
* 旧状态文件原样保留在磁盘上(迁移时另有一份 backup),只是没有任何读取方 ——
|
||||||
|
* 这是"不双读"的落地方式。迁移逻辑在 `automation-rule-store.ts`。
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
type GroupSnapshotRecord = {
|
type GroupSnapshotRecord = {
|
||||||
@@ -57,19 +60,30 @@ type StoredGroupSnapshot = Pick<
|
|||||||
|
|
||||||
const DB_CHANGE_DEBOUNCE_MS = 350
|
const DB_CHANGE_DEBOUNCE_MS = 350
|
||||||
const DUPLICATE_WINDOW_MS = 2 * 60 * 1000
|
const DUPLICATE_WINDOW_MS = 2 * 60 * 1000
|
||||||
|
/**
|
||||||
|
* **只约束「一次状态回传带多少条」**,不是存储上限。
|
||||||
|
*
|
||||||
|
* 事件现在写在 append-only 的 JSONL 里(`eventsPath()`),**永久保留、不做截断**。
|
||||||
|
* 之所以必须把「存储」和「回传」分开:状态文件是整体重写的(`save()`),
|
||||||
|
* 事件留在里面时每来一条新事件都要重写整个文件 —— 越写越慢。
|
||||||
|
* 而 `getState()` 每次都把结果推过 IPC,也不能无上限。
|
||||||
|
*/
|
||||||
const MAX_EVENTS = 500
|
const MAX_EVENTS = 500
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退群事件处理器。
|
||||||
|
*
|
||||||
|
* 由主进程注入成 `automationService.handleGroupExit` —— 本服务**不再自己发送**。
|
||||||
|
* 契约:**必须自己吞掉异常**(实现方负责),本服务也会再兜一层。
|
||||||
|
*/
|
||||||
|
export type GroupExitEventHandler = (event: GroupMemberExitedEvent) => void | Promise<void>
|
||||||
|
|
||||||
export interface GroupExitMonitorServiceDependencies {
|
export interface GroupExitMonitorServiceDependencies {
|
||||||
actionGateway?: GroupExitActionGateway
|
onGroupExit?: GroupExitEventHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
type GroupExitActionGateway = Pick<WechatActionGateway, 'execute'> &
|
|
||||||
Partial<
|
|
||||||
Pick<WechatActionGateway, 'registerMemberEvent' | 'registerMemberEvents' | 'clearMemberEvents'>
|
|
||||||
>
|
|
||||||
|
|
||||||
class GroupExitMonitorService {
|
class GroupExitMonitorService {
|
||||||
private readonly actionGateway: GroupExitActionGateway
|
private onGroupExit: GroupExitEventHandler | undefined
|
||||||
private active = false
|
private active = false
|
||||||
private enabled = true
|
private enabled = true
|
||||||
private nativeMonitorActive = false
|
private nativeMonitorActive = false
|
||||||
@@ -87,7 +101,6 @@ class GroupExitMonitorService {
|
|||||||
private events: GroupExitMonitorEvent[] = []
|
private events: GroupExitMonitorEvent[] = []
|
||||||
private monitorSelectionConfigured = true
|
private monitorSelectionConfigured = true
|
||||||
private monitoredRoomIds = new Set<string>()
|
private monitoredRoomIds = new Set<string>()
|
||||||
private notificationRoomIds = new Set<string>()
|
|
||||||
private loaded = false
|
private loaded = false
|
||||||
private accountRoot = ''
|
private accountRoot = ''
|
||||||
private groupNamesByRoomId = new Map<string, string>()
|
private groupNamesByRoomId = new Map<string, string>()
|
||||||
@@ -95,24 +108,34 @@ class GroupExitMonitorService {
|
|||||||
private eventSequence = 0
|
private eventSequence = 0
|
||||||
private scopeGeneration = 0
|
private scopeGeneration = 0
|
||||||
private legacyFallbackLogged = false
|
private legacyFallbackLogged = false
|
||||||
private notificationTemplate = normalizeGroupExitNotificationTemplate(undefined)
|
|
||||||
|
|
||||||
constructor(deps: GroupExitMonitorServiceDependencies = {}) {
|
constructor(deps: GroupExitMonitorServiceDependencies = {}) {
|
||||||
this.actionGateway = deps.actionGateway || wechatActionGateway
|
this.onGroupExit = deps.onGroupExit
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注入退群事件处理器(主进程在 AutomationService 初始化后调用)。
|
||||||
|
*
|
||||||
|
* 用 setter 而不是构造参数:AutomationService 依赖 `Wcdb4Client`,
|
||||||
|
* 要等数据库解锁后才存在,而本服务是模块级单例。
|
||||||
|
*/
|
||||||
|
setGroupExitHandler(handler: GroupExitEventHandler | undefined): void {
|
||||||
|
this.onGroupExit = handler
|
||||||
}
|
}
|
||||||
|
|
||||||
getState(): GroupExitMonitorState {
|
getState(): GroupExitMonitorState {
|
||||||
this.ensureLoaded()
|
this.ensureLoaded()
|
||||||
return {
|
return {
|
||||||
events: [...this.events],
|
// this.events 恒为「新在前」,所以这里取到的就是**最新**的 MAX_EVENTS 条。
|
||||||
|
events: this.events.slice(0, MAX_EVENTS),
|
||||||
|
/** 永久保留的事件总数(`events` 只是最新的一批)。 */
|
||||||
|
totalEventCount: this.events.length,
|
||||||
enabled: this.enabled,
|
enabled: this.enabled,
|
||||||
running: this.enabled && this.active && chat.isReady(),
|
running: this.enabled && this.active && chat.isReady(),
|
||||||
nativeMonitorActive: this.nativeMonitorActive,
|
nativeMonitorActive: this.nativeMonitorActive,
|
||||||
monitoredGroupCount: this.snapshots.size,
|
monitoredGroupCount: this.snapshots.size,
|
||||||
monitorSelectionConfigured: this.monitorSelectionConfigured,
|
monitorSelectionConfigured: this.monitorSelectionConfigured,
|
||||||
monitoredRoomIds: Array.from(this.monitoredRoomIds),
|
monitoredRoomIds: Array.from(this.monitoredRoomIds),
|
||||||
notificationRoomIds: Array.from(this.notificationRoomIds),
|
|
||||||
notificationTemplate: this.notificationTemplate,
|
|
||||||
lastCheckedAt: this.lastCheckedAt,
|
lastCheckedAt: this.lastCheckedAt,
|
||||||
lastReadAt: this.lastReadAt,
|
lastReadAt: this.lastReadAt,
|
||||||
unreadCount: this.events.filter((event) => event.detectedAt > this.lastReadAt).length
|
unreadCount: this.events.filter((event) => event.detectedAt > this.lastReadAt).length
|
||||||
@@ -132,12 +155,11 @@ class GroupExitMonitorService {
|
|||||||
(this.accountRoot && !sameAccountRoot(currentRoot, this.accountRoot)) ||
|
(this.accountRoot && !sameAccountRoot(currentRoot, this.accountRoot)) ||
|
||||||
(!this.accountRoot && this.snapshots.size > 0)
|
(!this.accountRoot && this.snapshots.size > 0)
|
||||||
) {
|
) {
|
||||||
this.actionGateway.clearMemberEvents?.()
|
|
||||||
this.events = []
|
this.events = []
|
||||||
|
this.rewriteEventsToDisk([])
|
||||||
this.lastReadAt = 0
|
this.lastReadAt = 0
|
||||||
this.monitorSelectionConfigured = true
|
this.monitorSelectionConfigured = true
|
||||||
this.monitoredRoomIds.clear()
|
this.monitoredRoomIds.clear()
|
||||||
this.notificationRoomIds.clear()
|
|
||||||
this.snapshots.clear()
|
this.snapshots.clear()
|
||||||
this.groupNamesByRoomId.clear()
|
this.groupNamesByRoomId.clear()
|
||||||
}
|
}
|
||||||
@@ -208,10 +230,13 @@ class GroupExitMonitorService {
|
|||||||
return this.getState()
|
return this.getState()
|
||||||
}
|
}
|
||||||
|
|
||||||
async setMonitoredRoomIds(
|
/**
|
||||||
roomIds: string[],
|
* 保存**监控范围**。
|
||||||
notificationRoomIds: string[] = []
|
*
|
||||||
): Promise<GroupExitMonitorState> {
|
* 参数只剩监控目标 —— 旧版第二个参数(通知群聊)已经迁到自动化规则里,
|
||||||
|
* 本服务不再持有任何通知配置。
|
||||||
|
*/
|
||||||
|
async setMonitoredRoomIds(roomIds: string[]): Promise<GroupExitMonitorState> {
|
||||||
this.ensureLoaded()
|
this.ensureLoaded()
|
||||||
this.eventSequence += 1
|
this.eventSequence += 1
|
||||||
this.scopeGeneration += 1
|
this.scopeGeneration += 1
|
||||||
@@ -225,10 +250,6 @@ class GroupExitMonitorService {
|
|||||||
}
|
}
|
||||||
this.monitoredRoomIds = nextMonitoredRoomIds
|
this.monitoredRoomIds = nextMonitoredRoomIds
|
||||||
this.groupNamesRefreshPending = true
|
this.groupNamesRefreshPending = true
|
||||||
const requestedNotifications = normalizeRoomIds(notificationRoomIds)
|
|
||||||
this.notificationRoomIds = new Set(
|
|
||||||
Array.from(requestedNotifications).filter((roomId) => this.monitoredRoomIds.has(roomId))
|
|
||||||
)
|
|
||||||
this.lastCheckedAt = undefined
|
this.lastCheckedAt = undefined
|
||||||
this.save()
|
this.save()
|
||||||
this.broadcast()
|
this.broadcast()
|
||||||
@@ -261,22 +282,41 @@ class GroupExitMonitorService {
|
|||||||
return this.getState()
|
return this.getState()
|
||||||
}
|
}
|
||||||
|
|
||||||
setNotificationTemplate(value: unknown): GroupExitMonitorState {
|
/**
|
||||||
|
* 按群 / 时间范围查退群事件(档案合并展示用)。
|
||||||
|
*
|
||||||
|
* 与 `getState()` 的分工:后者只带回最近 `MAX_EVENTS` 条、且是**给管理页**看的概览;
|
||||||
|
* 档案要的是「某个群在这段时间里的全部事件」,所以单独开一个查询入口,
|
||||||
|
* 直接打在内存里的完整历史上(事件是永久保留的)。
|
||||||
|
*
|
||||||
|
* 返回**按时间升序**(旧 → 新),与档案消息流的顺序一致。
|
||||||
|
*/
|
||||||
|
listEvents(
|
||||||
|
query: { roomId?: string; sinceMs?: number; untilMs?: number; limit?: number } = {}
|
||||||
|
): GroupExitMonitorEvent[] {
|
||||||
this.ensureLoaded()
|
this.ensureLoaded()
|
||||||
const result = validateGroupExitNotificationTemplate(value)
|
const roomId = String(query.roomId || '').trim()
|
||||||
if (!result.valid || !result.template) {
|
const since = Number(query.sinceMs)
|
||||||
throw new Error(result.error || '退群监测模板无效')
|
const until = Number(query.untilMs)
|
||||||
|
const limit = Number(query.limit)
|
||||||
|
|
||||||
|
// this.events 是倒序(新在前)。
|
||||||
|
let result = [...this.events].reverse()
|
||||||
|
if (roomId) result = result.filter((event) => event.roomId === roomId)
|
||||||
|
if (Number.isFinite(since)) result = result.filter((event) => event.detectedAt >= since)
|
||||||
|
if (Number.isFinite(until)) result = result.filter((event) => event.detectedAt <= until)
|
||||||
|
// 超量时保留**最近**的一批(尾部即最新)。
|
||||||
|
if (Number.isFinite(limit) && limit > 0 && result.length > limit) {
|
||||||
|
result = result.slice(-limit)
|
||||||
}
|
}
|
||||||
this.notificationTemplate = result.template
|
return result
|
||||||
this.save()
|
|
||||||
this.broadcast()
|
|
||||||
return this.getState()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clearEvents(): GroupExitMonitorState {
|
clearEvents(): GroupExitMonitorState {
|
||||||
this.ensureLoaded()
|
this.ensureLoaded()
|
||||||
this.events = []
|
this.events = []
|
||||||
this.actionGateway.clearMemberEvents?.()
|
// 磁盘上的 append-only 历史也要清掉,否则下次启动又读回来了。
|
||||||
|
this.rewriteEventsToDisk([])
|
||||||
this.lastReadAt = Date.now()
|
this.lastReadAt = Date.now()
|
||||||
this.save()
|
this.save()
|
||||||
this.broadcast()
|
this.broadcast()
|
||||||
@@ -399,7 +439,7 @@ class GroupExitMonitorService {
|
|||||||
groups: GroupMembershipRecord[],
|
groups: GroupMembershipRecord[],
|
||||||
scopeGeneration: number
|
scopeGeneration: number
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const notifications: Array<{ group: GroupSnapshotRecord; event: GroupExitMonitorEvent }> = []
|
const exits: GroupExitMonitorEvent[] = []
|
||||||
let changedGroups = 0
|
let changedGroups = 0
|
||||||
for (const membership of groups) {
|
for (const membership of groups) {
|
||||||
if (!this.enabled || !this.active || scopeGeneration !== this.scopeGeneration) {
|
if (!this.enabled || !this.active || scopeGeneration !== this.scopeGeneration) {
|
||||||
@@ -430,10 +470,9 @@ class GroupExitMonitorService {
|
|||||||
if (previous && next.members.length < previous.members.length) {
|
if (previous && next.members.length < previous.members.length) {
|
||||||
const removed = findRemovedGroupMembers(previous.members, next.members)
|
const removed = findRemovedGroupMembers(previous.members, next.members)
|
||||||
for (const member of removed) {
|
for (const member of removed) {
|
||||||
|
// 一人一条事件、一条通知 —— 保持既有产品语义,不聚合。
|
||||||
const event = this.recordExit(next, member, previous.members.length, next.members.length)
|
const event = this.recordExit(next, member, previous.members.length, next.members.length)
|
||||||
if (event && this.notificationRoomIds.has(next.roomId)) {
|
if (event) exits.push(event)
|
||||||
notifications.push({ group: next, event })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,12 +487,11 @@ class GroupExitMonitorService {
|
|||||||
return changedGroups
|
return changedGroups
|
||||||
}
|
}
|
||||||
this.lastCheckedAt = Date.now()
|
this.lastCheckedAt = Date.now()
|
||||||
// 先把事件和新基线作为同一检查点落盘,再执行可失败的通知动作。
|
// 先把事件和新基线作为同一检查点落盘,再交给自动化。
|
||||||
|
// 「退群事实已记录」与「通知发送成功」是两件独立的事。
|
||||||
this.save()
|
this.save()
|
||||||
this.broadcast()
|
this.broadcast()
|
||||||
if (notifications.length) {
|
for (const event of exits) this.emitGroupExit(event)
|
||||||
await Promise.all(notifications.map(({ group, event }) => this.notifyGroup(group, event)))
|
|
||||||
}
|
|
||||||
return changedGroups
|
return changedGroups
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,129 +627,145 @@ class GroupExitMonitorService {
|
|||||||
currentCount,
|
currentCount,
|
||||||
delta: currentCount - previousCount,
|
delta: currentCount - previousCount,
|
||||||
message,
|
message,
|
||||||
detectedAt,
|
detectedAt
|
||||||
notificationStatus: 'not_requested'
|
|
||||||
}
|
}
|
||||||
this.actionGateway.registerMemberEvent?.(event)
|
// 内存按时间倒序(新事件在前);磁盘**只追加这一条**,不重写历史。
|
||||||
this.events = [event, ...this.events].slice(0, MAX_EVENTS)
|
// 这里不再有 `.slice(0, MAX_EVENTS)` —— 事件是永久保留的。
|
||||||
|
this.events = [event, ...this.events]
|
||||||
|
this.appendEventsToDisk([event])
|
||||||
console.log(
|
console.log(
|
||||||
`[GroupMonitor] detected member exit roomId=${group.roomId} member=${member.wxid} ${previousCount}->${currentCount}`
|
`[GroupMonitor] detected member exit roomId=${group.roomId} member=${member.wxid} ${previousCount}->${currentCount}`
|
||||||
)
|
)
|
||||||
return event
|
return event
|
||||||
}
|
}
|
||||||
|
|
||||||
private async notifyGroup(
|
/**
|
||||||
group: GroupSnapshotRecord,
|
* 把退群事件交给自动化 —— **不等待**。
|
||||||
event: GroupExitMonitorEvent,
|
*
|
||||||
idempotencyKey = `member_left_notification:${event.id}`
|
* 三条约束:
|
||||||
): Promise<void> {
|
* 1. **绝不 await**:快照扫描与成员 diff 不能被微信发送耗时拖住;
|
||||||
event.notificationStatus = 'pending'
|
* 2. **异常必须被捕获**:`void promise` 漏掉 `.catch` 会变成 unhandled rejection;
|
||||||
event.notification = { status: 'pending' }
|
* 3. **不影响退群事实**:事件与快照在同一检查点已经先落盘,通知失败不回滚记录。
|
||||||
this.save()
|
*/
|
||||||
this.broadcast()
|
private emitGroupExit(event: GroupExitMonitorEvent): void {
|
||||||
|
const handler = this.onGroupExit
|
||||||
|
if (!handler) return
|
||||||
try {
|
try {
|
||||||
const result = await this.actionGateway.execute({
|
void Promise.resolve(
|
||||||
idempotencyKey,
|
handler(toGroupMemberExitedEvent(event))
|
||||||
origin: 'member_monitor',
|
).catch((error) => {
|
||||||
purpose: 'member_left_notification',
|
|
||||||
triggerType: 'automation',
|
|
||||||
sourceId: event.id,
|
|
||||||
recipient: {
|
|
||||||
type: 'group',
|
|
||||||
id: group.roomId,
|
|
||||||
name: group.groupName
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
type: 'text',
|
|
||||||
text: buildMemberLeftNotification(event, this.notificationTemplate)
|
|
||||||
},
|
|
||||||
metadata: {
|
|
||||||
memberId: event.memberWxid,
|
|
||||||
memberName: event.memberName,
|
|
||||||
detectedAt: event.detectedAt,
|
|
||||||
eventType: 'member_left',
|
|
||||||
eventRoomId: event.roomId
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const notification: GroupExitNotificationState = {
|
|
||||||
status: result.status,
|
|
||||||
actionId: result.actionId,
|
|
||||||
decision: result.decision,
|
|
||||||
...(result.errorCode ? { errorCode: result.errorCode } : {}),
|
|
||||||
...(result.reason ? { reason: result.reason } : {}),
|
|
||||||
startedAt: result.startedAt,
|
|
||||||
finishedAt: result.finishedAt
|
|
||||||
}
|
|
||||||
event.notificationStatus = result.status
|
|
||||||
event.notification = notification
|
|
||||||
if (result.status !== 'sent') {
|
|
||||||
console.warn(
|
console.warn(
|
||||||
`[GroupMonitor] 群聊通知未发送 roomId=${group.roomId} status=${result.status} code=${result.errorCode || ''}`
|
`[GroupMonitor] 退群通知处理失败 eventId=${event.id}: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
event.notificationStatus = 'failed'
|
// handler 同步抛出的情况(`handleGroupExit` 本身不抛,这里是防御性兜底)。
|
||||||
event.notification = {
|
|
||||||
status: 'failed',
|
|
||||||
errorCode: 'UNKNOWN',
|
|
||||||
reason: error instanceof Error ? error.message : String(error)
|
|
||||||
}
|
|
||||||
console.warn(
|
console.warn(
|
||||||
`[GroupMonitor] 群聊通知异常 roomId=${group.roomId}:`,
|
`[GroupMonitor] 退群通知处理异常 eventId=${event.id}: ${
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
)
|
)
|
||||||
} finally {
|
|
||||||
this.save()
|
|
||||||
this.broadcast()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async resendEvent(eventId: string): Promise<GroupExitMonitorState> {
|
|
||||||
const event = this.events.find((item) => item.id === eventId)
|
|
||||||
if (!event) throw new Error('退群动态不存在')
|
|
||||||
await this.notifyGroup(
|
|
||||||
{ roomId: event.roomId, groupName: event.groupName } as GroupSnapshotRecord,
|
|
||||||
event,
|
|
||||||
`member_left_notification:${event.id}:retry:${Date.now()}`
|
|
||||||
)
|
|
||||||
return this.getState()
|
|
||||||
}
|
|
||||||
|
|
||||||
private filePath(): string {
|
private filePath(): string {
|
||||||
return path.join(app.getPath('userData'), 'group-exit-monitor.json')
|
return path.join(app.getPath('userData'), 'group-exit-monitor.json')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退群事件的 append-only 存储(每行一条 JSON)。
|
||||||
|
*
|
||||||
|
* 与状态文件分开,因为两者的写入模式完全不同:
|
||||||
|
* - **状态**(开关 / 监控范围 / 快照 / 模板)小、且总是整体重写;
|
||||||
|
* - **事件**只增不改,且要求**永久保留**。
|
||||||
|
* 混在一个文件里时,每来一条事件都要把整部历史重新序列化写一遍 —— 越写越慢。
|
||||||
|
*/
|
||||||
|
private eventsPath(): string {
|
||||||
|
return path.join(app.getPath('userData'), 'group-exit-monitor-events.jsonl')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读全量历史事件。单行损坏只跳过该行,不让整部历史读不出来。 */
|
||||||
|
private readEventsFromDisk(): GroupExitMonitorEvent[] {
|
||||||
|
let raw = ''
|
||||||
|
try {
|
||||||
|
raw = fs.readFileSync(this.eventsPath(), 'utf8')
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const events: GroupExitMonitorEvent[] = []
|
||||||
|
for (const line of raw.split('\n')) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed) continue
|
||||||
|
try {
|
||||||
|
events.push(JSON.parse(trimmed) as GroupExitMonitorEvent)
|
||||||
|
} catch {
|
||||||
|
// 跳过坏行
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只追加新增的那几行,不重写历史。 */
|
||||||
|
private appendEventsToDisk(events: GroupExitMonitorEvent[]): void {
|
||||||
|
if (!events.length) return
|
||||||
|
try {
|
||||||
|
fs.ensureDirSync(path.dirname(this.eventsPath()))
|
||||||
|
const payload = events.map((event) => `${JSON.stringify(event)}\n`).join('')
|
||||||
|
fs.appendFileSync(this.eventsPath(), payload, 'utf8')
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[GroupMonitor] 追加退群事件失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 整体重写事件文件(清空、切换账号、老数据迁移时使用)。 */
|
||||||
|
private rewriteEventsToDisk(events: GroupExitMonitorEvent[]): void {
|
||||||
|
try {
|
||||||
|
fs.ensureDirSync(path.dirname(this.eventsPath()))
|
||||||
|
const payload = events.map((event) => `${JSON.stringify(event)}\n`).join('')
|
||||||
|
fs.writeFileSync(this.eventsPath(), payload, 'utf8')
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[GroupMonitor] 重写退群事件失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private ensureLoaded(): void {
|
private ensureLoaded(): void {
|
||||||
if (this.loaded) return
|
if (this.loaded) return
|
||||||
this.loaded = true
|
this.loaded = true
|
||||||
try {
|
try {
|
||||||
const stored = fs.readJsonSync(this.filePath()) as StoredState
|
const stored = fs.readJsonSync(this.filePath()) as StoredState
|
||||||
this.enabled = stored.enabled !== false
|
this.enabled = stored.enabled !== false
|
||||||
this.events = normalizeEvents(stored.events)
|
// 事件从 append-only 文件读;状态文件不再承载它们。
|
||||||
this.actionGateway.registerMemberEvents?.(this.events)
|
const fromDisk = this.readEventsFromDisk()
|
||||||
|
const legacy = normalizeEvents(stored.events)
|
||||||
|
if (legacy.length && !fromDisk.length) {
|
||||||
|
// 老版本把事件塞在状态文件里 —— 一次性迁移过去,避免这批历史丢失。
|
||||||
|
// 落盘按时间**升序**(旧 → 新),与之后 append 的方向一致,避免在
|
||||||
|
// append-only 文件开头留下一段方向相反的旧历史(历史行序错乱的来源)。
|
||||||
|
this.rewriteEventsToDisk(
|
||||||
|
[...legacy].sort((left, right) => left.detectedAt - right.detectedAt)
|
||||||
|
)
|
||||||
|
this.events = sortEventsNewestFirst(legacy)
|
||||||
|
} else {
|
||||||
|
// 磁盘行序不保证时间有序(迁移段与追加段方向相反),读回后必须显式重建
|
||||||
|
// 「新在前」这个内存不变量,否则列表顶部会恒为最旧的一批。
|
||||||
|
this.events = sortEventsNewestFirst(normalizeEvents(fromDisk))
|
||||||
|
}
|
||||||
this.lastReadAt = Number(stored.lastReadAt) || 0
|
this.lastReadAt = Number(stored.lastReadAt) || 0
|
||||||
this.accountRoot = String(stored.accountRoot || '')
|
this.accountRoot = String(stored.accountRoot || '')
|
||||||
// 没有显式范围时按空范围处理,保留已有选择。
|
// 没有显式范围时按空范围处理,保留已有选择。
|
||||||
this.monitorSelectionConfigured = true
|
this.monitorSelectionConfigured = true
|
||||||
this.monitoredRoomIds = normalizeRoomIds(stored.monitoredRoomIds || [])
|
this.monitoredRoomIds = normalizeRoomIds(stored.monitoredRoomIds || [])
|
||||||
this.notificationRoomIds = new Set(
|
|
||||||
Array.from(normalizeRoomIds(stored.notificationRoomIds || [])).filter((roomId) =>
|
|
||||||
this.monitoredRoomIds.has(roomId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
this.notificationTemplate = normalizeGroupExitNotificationTemplate(
|
|
||||||
stored.notificationTemplate
|
|
||||||
)
|
|
||||||
this.snapshots = normalizeSnapshots(stored.snapshots, this.monitoredRoomIds)
|
this.snapshots = normalizeSnapshots(stored.snapshots, this.monitoredRoomIds)
|
||||||
} catch {
|
} catch {
|
||||||
// 首次启动或文件损坏时从空记录开始。
|
// 首次启动或状态文件损坏时从空记录开始 —— 但事件在独立文件里,
|
||||||
this.events = []
|
// 不该被状态文件的问题连累,仍然读回来。
|
||||||
|
this.events = sortEventsNewestFirst(normalizeEvents(this.readEventsFromDisk()))
|
||||||
this.enabled = true
|
this.enabled = true
|
||||||
this.lastReadAt = 0
|
this.lastReadAt = 0
|
||||||
this.monitorSelectionConfigured = true
|
this.monitorSelectionConfigured = true
|
||||||
this.monitoredRoomIds.clear()
|
this.monitoredRoomIds.clear()
|
||||||
this.notificationRoomIds.clear()
|
|
||||||
this.notificationTemplate = normalizeGroupExitNotificationTemplate(undefined)
|
|
||||||
this.snapshots.clear()
|
this.snapshots.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -725,12 +779,11 @@ class GroupExitMonitorService {
|
|||||||
{
|
{
|
||||||
accountRoot: this.accountRoot,
|
accountRoot: this.accountRoot,
|
||||||
enabled: this.enabled,
|
enabled: this.enabled,
|
||||||
events: this.events,
|
// 事件**不在这里**:它们走 append-only 的 JSONL(见 `eventsPath()`)。
|
||||||
|
// 放进状态文件会让每新增一条事件都把整部历史重写一遍。
|
||||||
lastReadAt: this.lastReadAt,
|
lastReadAt: this.lastReadAt,
|
||||||
monitorSelectionConfigured: this.monitorSelectionConfigured,
|
monitorSelectionConfigured: this.monitorSelectionConfigured,
|
||||||
monitoredRoomIds: Array.from(this.monitoredRoomIds),
|
monitoredRoomIds: Array.from(this.monitoredRoomIds),
|
||||||
notificationRoomIds: Array.from(this.notificationRoomIds),
|
|
||||||
notificationTemplate: this.notificationTemplate,
|
|
||||||
snapshots: Array.from(this.snapshots.values(), toStoredSnapshot)
|
snapshots: Array.from(this.snapshots.values(), toStoredSnapshot)
|
||||||
},
|
},
|
||||||
{ spaces: 2 }
|
{ spaces: 2 }
|
||||||
@@ -927,39 +980,28 @@ function normalizeEvents(
|
|||||||
? Number(value.delta)
|
? Number(value.delta)
|
||||||
: currentCount - previousCount,
|
: currentCount - previousCount,
|
||||||
message: String(value.message || `${memberName}退出了${groupName}`),
|
message: String(value.message || `${memberName}退出了${groupName}`),
|
||||||
detectedAt,
|
detectedAt
|
||||||
...(value.notificationStatus
|
|
||||||
? { notificationStatus: normalizeNotificationStatus(value.notificationStatus) }
|
|
||||||
: {}),
|
|
||||||
...(value.notification && typeof value.notification === 'object'
|
|
||||||
? { notification: normalizeNotification(value.notification) }
|
|
||||||
: {})
|
|
||||||
})
|
})
|
||||||
if (normalized.length >= MAX_EVENTS) break
|
// 不再按 MAX_EVENTS 截断:事件是永久保留的,截在这里等于每次启动都丢掉历史。
|
||||||
|
// 历史上的 `notificationStatus` / `notification` 字段被**丢弃**:
|
||||||
|
// 通知状态已归 Automation 执行日志,退群监控不再持有它。
|
||||||
}
|
}
|
||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeNotificationStatus(value: unknown): GroupExitNotificationStatus {
|
/**
|
||||||
const status = String(value || '').trim()
|
* 事件在内存里恒定保持「**新在前**」。
|
||||||
return status === 'pending' || status === 'sent' || status === 'blocked' || status === 'failed'
|
*
|
||||||
? status
|
* 这个不变量有三个依赖方:`recordExit` 的 `[event, ...this.events]` 写入方向、
|
||||||
: 'not_requested'
|
* `listEvents()` 的 `.reverse()`(它假定内存是倒序,反转后得到升序)、
|
||||||
}
|
* 以及 `getState()` 的 `slice(0, MAX_EVENTS)`(要求取到的是**最新**的一批)。
|
||||||
|
*
|
||||||
function normalizeNotification(value: object): GroupExitNotificationState {
|
* 必须显式重建它:磁盘是 append-only,行序由「迁移写入的历史 + 之后追加的新事件」
|
||||||
const input = value as Partial<GroupExitNotificationState>
|
* 决定,两段方向相反,整体不保证时间有序。直接信任文件行序会让列表顶部恒为最旧的
|
||||||
return {
|
* 一批,并让 `slice(0, MAX_EVENTS)` 恰好把最新的事件截掉。
|
||||||
status: normalizeNotificationStatus(input.status),
|
*/
|
||||||
...(input.actionId ? { actionId: String(input.actionId) } : {}),
|
function sortEventsNewestFirst(events: GroupExitMonitorEvent[]): GroupExitMonitorEvent[] {
|
||||||
...(input.decision === 'allow' || input.decision === 'block'
|
return [...events].sort((left, right) => right.detectedAt - left.detectedAt)
|
||||||
? { decision: input.decision }
|
|
||||||
: {}),
|
|
||||||
...(input.errorCode ? { errorCode: String(input.errorCode) } : {}),
|
|
||||||
...(input.reason ? { reason: String(input.reason) } : {}),
|
|
||||||
...(input.startedAt ? { startedAt: String(input.startedAt) } : {}),
|
|
||||||
...(input.finishedAt ? { finishedAt: String(input.finishedAt) } : {})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isContactEvent(rawPayload: string): boolean {
|
function isContactEvent(rawPayload: string): boolean {
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { isKnowledgeFresh } from '../../shared/knowledge'
|
||||||
|
import {
|
||||||
|
GROUP_STATS_LIMITATION,
|
||||||
|
GROUP_STATS_STALE_LIMITATION,
|
||||||
|
type GroupMemberStatsActiveMember,
|
||||||
|
type GroupMemberStatsQuery,
|
||||||
|
type GroupMemberStatsResult,
|
||||||
|
type GroupMemberStatsSilentMember,
|
||||||
|
type GroupStatsFreshness
|
||||||
|
} from '../../shared/group-stats'
|
||||||
|
import type { KnowledgeSearchService } from '../knowledge/knowledge-search-service'
|
||||||
|
import * as chat from './chat-service'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发一次追赶同步的最小间隔。
|
||||||
|
*
|
||||||
|
* 面板是可以反复开关的交互入口,而追赶同步跑的是真实增量 pass(会读 WCDB)。
|
||||||
|
* 没有这个节流,连续点击就等于连续触发索引。
|
||||||
|
*/
|
||||||
|
const CATCH_UP_MIN_INTERVAL_MS = 30_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追赶的有界等待预算。
|
||||||
|
*
|
||||||
|
* 与 Query 侧同一取舍:全量追赶可能以分钟计,交互查询绝不能无限等。
|
||||||
|
* 预算用完之后**如实**把结果标成不完整,而不是假装完整。
|
||||||
|
*/
|
||||||
|
const FRESHNESS_WAIT_BUDGET_MS = 2_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群员统计。
|
||||||
|
*
|
||||||
|
* 数据两路来源,职责严格分开:
|
||||||
|
* - **当前成员名单** 走 WCDB 的轻路径(只取 wxid + 显示名,**不** hydrate 头像);
|
||||||
|
* - **发言聚合** 走 Knowledge 派生库的 `GROUP BY sender_id`,不读 WCDB 原始消息。
|
||||||
|
*
|
||||||
|
* 「未发言」= 当前成员 ∖ 窗口内有发言的 sender。这个定义**不能**简化成
|
||||||
|
* 「整段窗口都没说话」—— 入群/退群时间在源头就不存在(整个 DB 没有该字段),
|
||||||
|
* 所以结论必须带上 limitation。
|
||||||
|
*/
|
||||||
|
export class GroupStatsService {
|
||||||
|
constructor(private readonly knowledge: KnowledgeSearchService) {}
|
||||||
|
|
||||||
|
async getMemberStats(query: GroupMemberStatsQuery): Promise<GroupMemberStatsResult> {
|
||||||
|
const { userMd5, startTime, endTime } = query
|
||||||
|
const limitations = [GROUP_STATS_LIMITATION]
|
||||||
|
|
||||||
|
const roomId = chat.resolveGroupRoomId(userMd5)
|
||||||
|
if (!roomId) {
|
||||||
|
// 不是群 / md5 解析不到:如实返回空,不编造统计。
|
||||||
|
return this.build({
|
||||||
|
userMd5,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
freshness: 'unknown',
|
||||||
|
limitations,
|
||||||
|
members: [],
|
||||||
|
activeMembers: [],
|
||||||
|
silentMembers: [],
|
||||||
|
unattributedMessages: 0,
|
||||||
|
excludedSystemMessages: 0,
|
||||||
|
firstMessageTime: null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) 当前群成员 —— 轻路径。只取 wxid,再按需解析显示名;绝不走
|
||||||
|
// `getGroupSnapshotAsync`(它会 materialize 整群并 hydrate 头像,实测 8 群 ≈ 42s)。
|
||||||
|
const membership = await chat.getGroupMemberIdsAsync(roomId)
|
||||||
|
const memberIds = membership?.memberIds ?? []
|
||||||
|
const members = await chat.getGroupMemberNamesAsync(userMd5, memberIds)
|
||||||
|
|
||||||
|
// 2) 发言聚合(只统计当前群;系统消息在 SQL 层就被排除)
|
||||||
|
let { result, sourceLatestAt } = await this.knowledge.memberStats({
|
||||||
|
conversationId: userMd5,
|
||||||
|
startTime,
|
||||||
|
endTime
|
||||||
|
})
|
||||||
|
let freshness = judgeFreshness(result?.indexLatestAt ?? null, sourceLatestAt)
|
||||||
|
|
||||||
|
// 3) 索引没追平 → 有界追赶一次再判。追不上就如实标 stale,
|
||||||
|
// **不允许**把落后索引算出来的「未发言」包装成完整结论。
|
||||||
|
if (freshness !== 'fresh') {
|
||||||
|
this.knowledge.requestCatchUp(CATCH_UP_MIN_INTERVAL_MS)
|
||||||
|
const settled = await this.knowledge.waitForIndexingComplete(FRESHNESS_WAIT_BUDGET_MS)
|
||||||
|
if (settled) {
|
||||||
|
const retried = await this.knowledge.memberStats({
|
||||||
|
conversationId: userMd5,
|
||||||
|
startTime,
|
||||||
|
endTime
|
||||||
|
})
|
||||||
|
result = retried.result
|
||||||
|
sourceLatestAt = retried.sourceLatestAt
|
||||||
|
freshness = judgeFreshness(result?.indexLatestAt ?? null, sourceLatestAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) 差集:当前成员 × 窗口内发言
|
||||||
|
const statBySender = new Map<string, { messageCount: number; lastMessageTime: number }>()
|
||||||
|
for (const row of result?.senders ?? []) {
|
||||||
|
if (row.senderId) statBySender.set(row.senderId, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
const knownSenderIds = new Set<string>()
|
||||||
|
const activeMembers: GroupMemberStatsActiveMember[] = []
|
||||||
|
const silentMembers: GroupMemberStatsSilentMember[] = []
|
||||||
|
|
||||||
|
for (const member of members) {
|
||||||
|
const wxid = String(member.wxid || '').trim()
|
||||||
|
if (!wxid) continue
|
||||||
|
knownSenderIds.add(wxid)
|
||||||
|
// 与 Knowledge 的 Join key 必须是 wxid,不是昵称 —— 昵称会变。
|
||||||
|
// 显示名沿用项目既有优先级(nickname 已内含 `wechatNickname || groupNickname || username`)。
|
||||||
|
const displayName = member.nickname || member.remark || wxid
|
||||||
|
const groupNickname = member.groupNickname || ''
|
||||||
|
const stat = statBySender.get(wxid)
|
||||||
|
if (stat && stat.messageCount > 0) {
|
||||||
|
activeMembers.push({
|
||||||
|
senderId: wxid,
|
||||||
|
displayName,
|
||||||
|
groupNickname,
|
||||||
|
messageCount: stat.messageCount,
|
||||||
|
lastMessageTime: stat.lastMessageTime
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
silentMembers.push({ senderId: wxid, displayName, groupNickname })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 窗口内有发言、但已不在当前成员名单里的人(退群者)。
|
||||||
|
// 默认**不**进活跃榜:那份名单读作「当前群成员」,混入已退群的人会误导。
|
||||||
|
let formerSenderCount = 0
|
||||||
|
for (const senderId of statBySender.keys()) {
|
||||||
|
if (!knownSenderIds.has(senderId)) formerSenderCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
activeMembers.sort(
|
||||||
|
(a, b) => b.messageCount - a.messageCount || b.lastMessageTime - a.lastMessageTime
|
||||||
|
)
|
||||||
|
silentMembers.sort((a, b) => a.displayName.localeCompare(b.displayName, 'zh-Hans-CN'))
|
||||||
|
|
||||||
|
if (freshness !== 'fresh') limitations.push(GROUP_STATS_STALE_LIMITATION)
|
||||||
|
if (formerSenderCount > 0) {
|
||||||
|
limitations.push(
|
||||||
|
`另有 ${formerSenderCount} 位窗口内发言者已不在当前群成员名单中,未计入活跃榜。`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.build({
|
||||||
|
userMd5,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
freshness,
|
||||||
|
limitations,
|
||||||
|
members,
|
||||||
|
activeMembers,
|
||||||
|
silentMembers,
|
||||||
|
unattributedMessages: result?.unattributedMessages ?? 0,
|
||||||
|
excludedSystemMessages: result?.excludedSystemMessages ?? 0,
|
||||||
|
firstMessageTime: result?.earliestMessageTime ?? null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private build(input: {
|
||||||
|
userMd5: string
|
||||||
|
startTime: number
|
||||||
|
endTime: number
|
||||||
|
freshness: GroupStatsFreshness
|
||||||
|
limitations: string[]
|
||||||
|
members: Array<{ wxid: string }>
|
||||||
|
activeMembers: GroupMemberStatsActiveMember[]
|
||||||
|
silentMembers: GroupMemberStatsSilentMember[]
|
||||||
|
unattributedMessages: number
|
||||||
|
excludedSystemMessages: number
|
||||||
|
firstMessageTime: number | null
|
||||||
|
}): GroupMemberStatsResult {
|
||||||
|
return {
|
||||||
|
conversationId: input.userMd5,
|
||||||
|
startTime: input.startTime,
|
||||||
|
endTime: input.endTime,
|
||||||
|
freshness: input.freshness,
|
||||||
|
// 只有索引确实追平,才允许调用方把「未发言」当作完整结论。
|
||||||
|
complete: input.freshness === 'fresh',
|
||||||
|
memberCount: input.members.length,
|
||||||
|
activeMemberCount: input.activeMembers.length,
|
||||||
|
silentMemberCount: input.silentMembers.length,
|
||||||
|
activeMembers: input.activeMembers,
|
||||||
|
silentMembers: input.silentMembers,
|
||||||
|
unattributedMessages: input.unattributedMessages,
|
||||||
|
excludedSystemMessages: input.excludedSystemMessages,
|
||||||
|
firstMessageTime: input.firstMessageTime,
|
||||||
|
limitations: input.limitations
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `isKnowledgeFresh` 返回 `boolean | null`(任一侧口径缺失即 null)。
|
||||||
|
* 三态都要能表达:`null` 绝不能当成「新鲜」。
|
||||||
|
*/
|
||||||
|
function judgeFreshness(
|
||||||
|
indexLatestAt: number | null,
|
||||||
|
sourceLatestAt: number | null
|
||||||
|
): GroupStatsFreshness {
|
||||||
|
const fresh = isKnowledgeFresh({ indexLatestAt, sourceLatestAt })
|
||||||
|
if (fresh === true) return 'fresh'
|
||||||
|
if (fresh === false) return 'stale'
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import {
|
||||||
|
normalizeLeaveNotificationRoomIds,
|
||||||
|
normalizeLeaveNotificationTemplate,
|
||||||
|
type LeaveNotificationNotifyScope,
|
||||||
|
type LeaveNotificationTarget
|
||||||
|
} from '../../shared/automation'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 旧「退群监控 → 通知群聊 / 模板」配置 → 新「自动化 → 退群通知」规则的**纯映射器**。
|
||||||
|
*
|
||||||
|
* 单独成文件是为了能直接单测:这是整轮迁移里**唯一会改变用户既有行为**的地方,
|
||||||
|
* 不允许只靠"跑一遍看看"来验证。
|
||||||
|
*
|
||||||
|
* ## 旧语义(实测自 `group-exit-monitor-service.ts`)
|
||||||
|
*
|
||||||
|
* 旧版是**两层**配置,缺一不可:
|
||||||
|
* 1. **管理群聊**(`monitoredRoomIds`)= 监测哪些群有人退出;
|
||||||
|
* 2. **通知群聊**(`notificationRoomIds`)= 上面这批群里,**哪些要真的发通知**
|
||||||
|
* —— 即对第 1 层的二次勾选。
|
||||||
|
*
|
||||||
|
* `notificationRoomIds` **不是**"统一通知接收目标列表",而是
|
||||||
|
* **"哪些被监控群要在自己群里通报成员退出"**(per-group 多值):
|
||||||
|
* - `applyCurrentMemberships()` 只在 `notificationRoomIds.has(roomId)` 时通知;
|
||||||
|
* - `notifyGroup()` 的收件人**恒等于事件所在群**。
|
||||||
|
*
|
||||||
|
* ## 新语义
|
||||||
|
*
|
||||||
|
* 拆成**两个正交维度**,恰好一一对应旧版那两层:
|
||||||
|
* - `target` ← 旧版第 2 层里"发回本群"这个事实 ⇒ 恒为 `source_chat`;
|
||||||
|
* - `notifyScope` / `notifyRoomIds` ← 旧版第 2 层是"哪些群",逐字保留。
|
||||||
|
*
|
||||||
|
* ⇒ **迁移现在是无损的**。早期版本把目标压成单值、丢掉了第 2 层的"哪些群",
|
||||||
|
* 于是部分勾选只能判成 `needs_review` 并强制停用;恢复 `notifyScope` 之后
|
||||||
|
* 这个特例整体消失,用户不会再有"规则被无辜停用"的体验。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LegacyLeaveNotificationState {
|
||||||
|
monitoredRoomIds: string[]
|
||||||
|
notificationRoomIds: string[]
|
||||||
|
notificationTemplate?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LeaveNotificationMigrationOutcome =
|
||||||
|
/** 旧配置从不通知任何人 ⇒ 迁移成"关闭",行为完全一致。 */
|
||||||
|
| 'lossless_off'
|
||||||
|
/** 旧配置在**每个**被监控群里通报 ⇒ `notifyScope: 'all'`,行为完全一致。 */
|
||||||
|
| 'lossless_all_groups'
|
||||||
|
/** 旧配置只在**部分**群里通报 ⇒ `notifyScope: 'selected'` + 原样保留那份勾选。 */
|
||||||
|
| 'lossless_selected_groups'
|
||||||
|
|
||||||
|
export interface LeaveNotificationMigrationPlan {
|
||||||
|
outcome: LeaveNotificationMigrationOutcome
|
||||||
|
enabled: boolean
|
||||||
|
target: LeaveNotificationTarget
|
||||||
|
notifyScope: LeaveNotificationNotifyScope
|
||||||
|
notifyRoomIds: string[]
|
||||||
|
targetNeedsReview: boolean
|
||||||
|
template: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueRoomIds(values: unknown): string[] {
|
||||||
|
return normalizeLeaveNotificationRoomIds(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function planLeaveNotificationMigration(
|
||||||
|
legacy: LegacyLeaveNotificationState
|
||||||
|
): LeaveNotificationMigrationPlan {
|
||||||
|
const monitored = uniqueRoomIds(legacy.monitoredRoomIds)
|
||||||
|
const monitoredSet = new Set(monitored)
|
||||||
|
// 再夹一次:防止脏状态文件把范围外的群带进来。
|
||||||
|
const notifications = uniqueRoomIds(legacy.notificationRoomIds).filter((roomId) =>
|
||||||
|
monitoredSet.has(roomId)
|
||||||
|
)
|
||||||
|
// 模板永远是无损迁移的那一项:它就是用户写的那段文字。
|
||||||
|
const template = normalizeLeaveNotificationTemplate(legacy.notificationTemplate)
|
||||||
|
// 旧版收件人恒为事件所在群 ⇒ 无论勾了几个群,目标都是 source_chat。
|
||||||
|
const target: LeaveNotificationTarget = { type: 'source_chat' }
|
||||||
|
|
||||||
|
if (!notifications.length) {
|
||||||
|
// 旧语义是"从不通知" ⇒ 关掉,避免迁移后突然开始发消息。
|
||||||
|
// 同时把范围写成 `selected` + 空集:配置本身也精确表达"一个都不通知"。
|
||||||
|
return {
|
||||||
|
outcome: 'lossless_off',
|
||||||
|
enabled: false,
|
||||||
|
target,
|
||||||
|
notifyScope: 'selected',
|
||||||
|
notifyRoomIds: [],
|
||||||
|
targetNeedsReview: false,
|
||||||
|
template
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (monitored.length > 0 && notifications.length === monitored.length) {
|
||||||
|
// 每个被监控群都在自己群里通报 ⇒ 等价于"全部已监控群聊"。
|
||||||
|
return {
|
||||||
|
outcome: 'lossless_all_groups',
|
||||||
|
enabled: true,
|
||||||
|
target,
|
||||||
|
notifyScope: 'all',
|
||||||
|
notifyRoomIds: [],
|
||||||
|
targetNeedsReview: false,
|
||||||
|
template
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 部分勾选:**无损**保留那份子集,不再降级成 needs_review。
|
||||||
|
return {
|
||||||
|
outcome: 'lossless_selected_groups',
|
||||||
|
enabled: true,
|
||||||
|
target,
|
||||||
|
notifyScope: 'selected',
|
||||||
|
notifyRoomIds: notifications,
|
||||||
|
targetNeedsReview: false,
|
||||||
|
template
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import {
|
||||||
|
LEAVE_NOTIFICATION_TARGET_OPTIONS,
|
||||||
|
leaveNotificationTargetLabel,
|
||||||
|
type LeaveNotificationConfig
|
||||||
|
} from '../../shared/automation'
|
||||||
|
import type { GroupMemberExitedEvent } from '../../shared/group-exit-event'
|
||||||
|
import {
|
||||||
|
automationContactDisplayName,
|
||||||
|
filterSendableFriendContacts as filterSendable,
|
||||||
|
isSendableFriendContact as isSendable,
|
||||||
|
resolveAutomationTarget,
|
||||||
|
type AutomationTargetContact,
|
||||||
|
type AutomationTargetResolution,
|
||||||
|
type ResolvedAutomationTarget
|
||||||
|
} from './automation-wechat-target'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退群通知的**目标解析**(薄适配层)。
|
||||||
|
*
|
||||||
|
* 真正的判定在 `automation-wechat-target.ts` —— 那里才是**唯一实现**,
|
||||||
|
* 定时日报与退群通知共用它。这里只有两件事:
|
||||||
|
* 1. 把 `LeaveNotificationConfig` + 退群事件摊平成中性输入;
|
||||||
|
* 2. 提供退群通知专属文案,保持既有调用方 / 测试不变。
|
||||||
|
*
|
||||||
|
* ⚠️ 这里**不允许**再出现一份 switch / 判定逻辑 —— 两套并存迟早分叉。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 解析所需的最小联系人结构(`FormattedContact` 结构上可赋值给它)。 */
|
||||||
|
export type LeaveNotificationCandidateContact = AutomationTargetContact
|
||||||
|
|
||||||
|
export type ResolvedLeaveNotificationTarget = ResolvedAutomationTarget
|
||||||
|
|
||||||
|
export type LeaveNotificationTargetResolution = AutomationTargetResolution
|
||||||
|
|
||||||
|
/** 退群通知的目标文案(唯一一份)。 */
|
||||||
|
export const LEAVE_NOTIFICATION_TARGET_MESSAGES = {
|
||||||
|
options: LEAVE_NOTIFICATION_TARGET_OPTIONS,
|
||||||
|
invalidTarget: '退群通知的发送目标无效,请重新选择',
|
||||||
|
sourceMissing: '无法确定发生退群的群聊,本次通知未发送',
|
||||||
|
sourceRecipientName: '当前群聊',
|
||||||
|
sourceDisplayName: '发生退群事件的群聊',
|
||||||
|
selfMissing: '无法确定当前登录的微信账号,本次通知未发送',
|
||||||
|
contactNotChosen: '还没有选择通知联系人,请重新选择',
|
||||||
|
contactUnavailable: '通知联系人已不存在或当前无法发送,请重新选择'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/** 联系人显示名:备注 → 微信昵称 → 会话昵称 → 兜底称呼。**永不回落到 wxid**。 */
|
||||||
|
export function leaveNotificationContactDisplayName(contact: AutomationTargetContact): string {
|
||||||
|
return automationContactDisplayName(contact)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 这个联系人能不能作为「指定好友」的发送目标(与定时日报共用同一条判定)。 */
|
||||||
|
export function isSendableFriendContact(
|
||||||
|
contact: AutomationTargetContact,
|
||||||
|
selfWxid?: string
|
||||||
|
): boolean {
|
||||||
|
return isSendable(contact, selfWxid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 供 UI 用的「可选好友」过滤(与运行时同一套判定)。 */
|
||||||
|
export function filterSendableFriendContacts<T extends AutomationTargetContact>(
|
||||||
|
contacts: T[],
|
||||||
|
selfWxid?: string
|
||||||
|
): T[] {
|
||||||
|
return filterSendable(contacts, selfWxid)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveLeaveNotificationTarget(input: {
|
||||||
|
config: LeaveNotificationConfig | undefined
|
||||||
|
event: GroupMemberExitedEvent
|
||||||
|
contacts: LeaveNotificationCandidateContact[]
|
||||||
|
/** 当前登录账号的 wxid;拿不到时传空串。 */
|
||||||
|
selfWxid?: string
|
||||||
|
}): LeaveNotificationTargetResolution {
|
||||||
|
const config = input.config
|
||||||
|
if (!config) return { ok: false, error: '退群通知尚未配置发送目标' }
|
||||||
|
return resolveAutomationTarget(
|
||||||
|
{
|
||||||
|
targetType: config.target?.type as never,
|
||||||
|
sourceConversationId: String(input.event.conversationId || '').trim(),
|
||||||
|
sourceDisplayName: String(input.event.groupName || '').trim(),
|
||||||
|
...(config.target?.contactId ? { contactId: config.target.contactId } : {}),
|
||||||
|
contacts: input.contacts,
|
||||||
|
...(input.selfWxid ? { selfWxid: input.selfWxid } : {})
|
||||||
|
},
|
||||||
|
LEAVE_NOTIFICATION_TARGET_MESSAGES
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 目标类型 → 界面短标签(保持既有导出面)。 */
|
||||||
|
export { leaveNotificationTargetLabel }
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { existsSync, readFileSync, statSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
import type { MacWechatRuntimeManifest } from '../../shared/personal-wechat-mac-runtime'
|
||||||
|
|
||||||
|
const REQUIRED_CAPABILITIES = ['text', 'image', 'voice'] as const
|
||||||
|
|
||||||
|
function assertFile(path: string, label: string): void {
|
||||||
|
if (!existsSync(path) || !statSync(path).isFile()) {
|
||||||
|
throw new Error(`${label} missing: ${path}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertMachOArm64(path: string, label: string): void {
|
||||||
|
const binary = readFileSync(path)
|
||||||
|
if (binary.length < 8 || binary.readUInt32LE(0) !== 0xfeedfacf) {
|
||||||
|
throw new Error(`${label} is not a 64-bit Mach-O binary`)
|
||||||
|
}
|
||||||
|
if (binary.readUInt32LE(4) !== 0x0100000c) {
|
||||||
|
throw new Error(`${label} is not arm64`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readMacWechatRuntimeManifest(
|
||||||
|
runtimeDir: string,
|
||||||
|
architecture = process.arch
|
||||||
|
): MacWechatRuntimeManifest {
|
||||||
|
const manifestPath = join(runtimeDir, 'runtime-manifest.json')
|
||||||
|
const hostPath = join(runtimeDir, 'tm-wechat-host')
|
||||||
|
const dylibPath = join(runtimeDir, 'libtmwechat.dylib')
|
||||||
|
assertFile(manifestPath, 'runtime manifest')
|
||||||
|
assertFile(hostPath, 'runtime host')
|
||||||
|
assertFile(dylibPath, 'runtime dylib')
|
||||||
|
|
||||||
|
let manifest: MacWechatRuntimeManifest
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as MacWechatRuntimeManifest
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`runtime manifest is unreadable: ${String(error)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest.runtime !== 'tm-wechat-native') {
|
||||||
|
throw new Error(`unexpected runtime name: ${String(manifest.runtime)}`)
|
||||||
|
}
|
||||||
|
if (manifest.platform !== 'darwin-arm64') {
|
||||||
|
throw new Error(`runtime platform must be darwin-arm64: ${String(manifest.platform)}`)
|
||||||
|
}
|
||||||
|
if (manifest.architecture !== 'arm64' || architecture !== 'arm64') {
|
||||||
|
throw new Error(
|
||||||
|
`runtime architecture mismatch: artifact=${String(manifest.architecture)} process=${architecture}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (manifest.protocolVersion !== 1) {
|
||||||
|
throw new Error(`unsupported runtime protocolVersion: ${String(manifest.protocolVersion)}`)
|
||||||
|
}
|
||||||
|
if (!/^[a-f0-9]{64}$/.test(manifest.tmSendSourceSha256 || '')) {
|
||||||
|
throw new Error('runtime manifest has no valid tmSendSourceSha256')
|
||||||
|
}
|
||||||
|
if (!/^[a-f0-9]{64}$/.test(manifest.addressProfileSha256 || '')) {
|
||||||
|
throw new Error('runtime manifest has no valid addressProfileSha256')
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Array.isArray(manifest.supportedWechatVersions) ||
|
||||||
|
manifest.supportedWechatVersions.length === 0
|
||||||
|
) {
|
||||||
|
throw new Error('runtime manifest has no supported WeChat versions')
|
||||||
|
}
|
||||||
|
const capabilities = new Set(manifest.capabilities || [])
|
||||||
|
for (const capability of REQUIRED_CAPABILITIES) {
|
||||||
|
if (!capabilities.has(capability)) {
|
||||||
|
throw new Error(`runtime manifest capability missing: ${capability}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertMachOArm64(hostPath, 'runtime host')
|
||||||
|
assertMachOArm64(dylibPath, 'runtime dylib')
|
||||||
|
if (!readFileSync(dylibPath).includes(Buffer.from(manifest.tmSendSourceSha256, 'utf8'))) {
|
||||||
|
throw new Error('runtime dylib embedded agent hash does not match manifest')
|
||||||
|
}
|
||||||
|
if (!readFileSync(dylibPath).includes(Buffer.from(manifest.addressProfileSha256, 'utf8'))) {
|
||||||
|
throw new Error('runtime dylib embedded address profile hash does not match manifest')
|
||||||
|
}
|
||||||
|
return manifest
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindingMatchesRuntimeManifest(
|
||||||
|
binding: {
|
||||||
|
hostPid?: number
|
||||||
|
runtimeVersion?: string
|
||||||
|
embeddedAgentSha256?: string
|
||||||
|
embeddedAddressProfileSha256?: string
|
||||||
|
},
|
||||||
|
manifest: MacWechatRuntimeManifest
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
Number.isInteger(binding.hostPid) &&
|
||||||
|
Number(binding.hostPid) > 0 &&
|
||||||
|
binding.runtimeVersion === `tm-wechat-runtime/${manifest.version}` &&
|
||||||
|
binding.embeddedAgentSha256 === manifest.tmSendSourceSha256 &&
|
||||||
|
binding.embeddedAddressProfileSha256 === manifest.addressProfileSha256
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,724 @@
|
|||||||
|
import { spawn, type ChildProcess } from 'child_process'
|
||||||
|
import { chmodSync, existsSync } from 'fs'
|
||||||
|
import { get } from 'http'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
import { app } from 'electron'
|
||||||
|
|
||||||
|
import { appLogger } from '../app-logger'
|
||||||
|
import { isPackagedRuntime } from '../runtime-mode'
|
||||||
|
import type {
|
||||||
|
MacWechatBindResult,
|
||||||
|
MacWechatBindingStatus,
|
||||||
|
MacWechatRuntimeManifest,
|
||||||
|
MacWechatRuntimeLifecycle,
|
||||||
|
MacWechatRuntimeSnapshot
|
||||||
|
} from '../../shared/personal-wechat-mac-runtime'
|
||||||
|
import type { PersonalWechatSenderStatus } from '../../shared/personal-wechat'
|
||||||
|
import {
|
||||||
|
bindingMatchesRuntimeManifest,
|
||||||
|
readMacWechatRuntimeManifest
|
||||||
|
} from './mac-wechat-runtime-artifact'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* MacWechatRuntimeManager — lifecycle owner of the built-in macOS native
|
||||||
|
* WeChat runtime (tm-wechat-host + libtmwechat.dylib).
|
||||||
|
*
|
||||||
|
* Process isolation is a hard requirement: Electron NEVER dlopens the dylib.
|
||||||
|
* It spawns tm-wechat-host, which is the ONLY binding owner, and talks to it
|
||||||
|
* over 127.0.0.1 HTTP. A native crash therefore cannot take Electron down.
|
||||||
|
*
|
||||||
|
* Binding lifecycle is "bind once per WeChat process":
|
||||||
|
* - starting the host NEVER attaches to WeChat (the host is lazy)
|
||||||
|
* - bind happens only on an explicit user action
|
||||||
|
* - a host crash while bound leaves an orphaned frida agent inside WeChat
|
||||||
|
* that frida cannot remove from the outside; the only recovery is
|
||||||
|
* restarting WeChat. Surfaced as wechatRestartRequired. This was verified
|
||||||
|
* experimentally (2026-09-21) — do not "fix" it by auto-restarting and
|
||||||
|
* auto-binding, that path dead-ends in a 20s attach timeout every time.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PORT = 4290
|
||||||
|
const HOST = '127.0.0.1'
|
||||||
|
const START_TIMEOUT_MS = 15_000
|
||||||
|
const REQUEST_TIMEOUT_MS = 30_000
|
||||||
|
const CRASH_RESTART_DELAY_MS = 1_500
|
||||||
|
|
||||||
|
interface MacWechatSenderStatusInput {
|
||||||
|
snapshot: MacWechatRuntimeSnapshot
|
||||||
|
binding: MacWechatBindingStatus | null
|
||||||
|
manifest: MacWechatRuntimeManifest
|
||||||
|
arch?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldClearWechatRestartRequired(
|
||||||
|
orphanWechatPid: number,
|
||||||
|
currentWechatPid: number
|
||||||
|
): boolean {
|
||||||
|
return orphanWechatPid > 0 && currentWechatPid > 0 && orphanWechatPid !== currentWechatPid
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveMacWechatSenderStatus({
|
||||||
|
snapshot,
|
||||||
|
binding,
|
||||||
|
manifest,
|
||||||
|
arch = process.arch
|
||||||
|
}: MacWechatSenderStatusInput): PersonalWechatSenderStatus {
|
||||||
|
const identityMatches = binding !== null && bindingMatchesRuntimeManifest(binding, manifest)
|
||||||
|
const stale = binding?.bindingState === 'stale'
|
||||||
|
|
||||||
|
let state: PersonalWechatSenderStatus['state'] = 'stopped'
|
||||||
|
let message = '正在初始化微信发送能力'
|
||||||
|
if (snapshot.wechatRestartRequired) {
|
||||||
|
state = 'error'
|
||||||
|
message = '微信发送组件异常退出。由于当前微信进程中可能残留上一发送会话,请重启微信后重新绑定。'
|
||||||
|
} else if (binding && !identityMatches) {
|
||||||
|
state = 'error'
|
||||||
|
message = '运行中的微信发送 host 与当前 Native Runtime 不一致,请退出旧 host 后重启 TraceMemo。'
|
||||||
|
} else if (binding && !binding.wechatRunning) {
|
||||||
|
state = 'wechat_not_running'
|
||||||
|
message = '微信未运行'
|
||||||
|
} else if (binding?.bindingState === 'ready') {
|
||||||
|
state = 'online'
|
||||||
|
message = '个人微信已准备好发送日报'
|
||||||
|
} else if (binding?.bindingState === 'bound' || binding?.bindingState === 'binding') {
|
||||||
|
state = 'hook_not_ready'
|
||||||
|
message = '已绑定,等待捕获微信发送上下文'
|
||||||
|
} else if (stale) {
|
||||||
|
state = 'stopped'
|
||||||
|
message = '检测到微信已重启,请重新绑定'
|
||||||
|
} else if (binding?.bindingState === 'failed') {
|
||||||
|
state = 'error'
|
||||||
|
message = '微信绑定失败'
|
||||||
|
} else {
|
||||||
|
state = 'stopped'
|
||||||
|
message = '尚未绑定当前微信,可点击"绑定微信"'
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeCapabilities = new Set(manifest.capabilities)
|
||||||
|
const bindingReady = identityMatches && state === 'online' && binding?.sendContextReady === true
|
||||||
|
const canSendText =
|
||||||
|
bindingReady && runtimeCapabilities.has('text') && binding?.canSendText === true
|
||||||
|
const canSendImage =
|
||||||
|
bindingReady && runtimeCapabilities.has('image') && binding?.canSendImage === true
|
||||||
|
const canSendVoice =
|
||||||
|
bindingReady && runtimeCapabilities.has('voice') && binding?.canSendVoice === true
|
||||||
|
const sessionAttached = identityMatches && (binding?.sessionAttached ?? false)
|
||||||
|
const scriptLoaded = identityMatches && (binding?.scriptLoaded ?? false)
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
platform: 'darwin',
|
||||||
|
arch,
|
||||||
|
// 当前 gadget runtime 不依赖宿主 SIP 状态;该字段仅用于兼容旧 sender contract。
|
||||||
|
sipDisabled: true,
|
||||||
|
wechatRunning: binding?.wechatRunning ?? false,
|
||||||
|
wechatPid: binding?.wechatPid ?? 0,
|
||||||
|
boundWechatPid: binding?.boundWechatPid ?? 0,
|
||||||
|
endpoint: snapshot.endpoint,
|
||||||
|
endpointReady: snapshot.lifecycle === 'online',
|
||||||
|
wechatVersion: binding?.wechatVersion,
|
||||||
|
runtimeReady: snapshot.lifecycle === 'online' && identityMatches,
|
||||||
|
attachReady: sessionAttached,
|
||||||
|
baseAddressReady: sessionAttached,
|
||||||
|
textHookInstalled: scriptLoaded,
|
||||||
|
textHookReady: identityMatches && (binding?.sendContextReady ?? false),
|
||||||
|
imageHookInstalled: identityMatches && (binding?.canSendImage ?? false),
|
||||||
|
imageHookReady: identityMatches && (binding?.canSendImage ?? false),
|
||||||
|
messageListenerReady: scriptLoaded,
|
||||||
|
canSend: canSendText || canSendImage || canSendVoice,
|
||||||
|
canSendText,
|
||||||
|
canSendImage,
|
||||||
|
canSendVoice,
|
||||||
|
message,
|
||||||
|
error: state === 'error' ? message : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MacWechatRuntimeManager {
|
||||||
|
private child: ChildProcess | null = null
|
||||||
|
private lifecycle: MacWechatRuntimeLifecycle = 'stopped'
|
||||||
|
private wechatRestartRequired = false
|
||||||
|
/* The WeChat pid that was bound when the host died / bind hit the orphan
|
||||||
|
* wall. The orphan agent lives INSIDE that process; once WeChat is restarted
|
||||||
|
* the pid changes and the orphan is gone, so the flag must auto-clear. */
|
||||||
|
private orphanWechatPid = 0
|
||||||
|
private lastBinding: MacWechatBindingStatus | null = null
|
||||||
|
private manifest: MacWechatRuntimeManifest | null = null
|
||||||
|
private lastError: string | null = null
|
||||||
|
private restartTimer: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
|
getPort(): number {
|
||||||
|
return PORT
|
||||||
|
}
|
||||||
|
|
||||||
|
getSnapshot(): MacWechatRuntimeSnapshot {
|
||||||
|
return {
|
||||||
|
lifecycle: this.lifecycle,
|
||||||
|
wechatRestartRequired: this.wechatRestartRequired,
|
||||||
|
endpoint: `http://${HOST}:${PORT}`,
|
||||||
|
port: PORT,
|
||||||
|
runtimeDir: this.resolveRuntimeDir(),
|
||||||
|
manifest: this.manifest ?? undefined,
|
||||||
|
error: this.lastError ?? undefined,
|
||||||
|
binding: this.lastBinding ?? undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveRuntimeDir(): string {
|
||||||
|
const base = isPackagedRuntime() ? process.resourcesPath : app.getAppPath()
|
||||||
|
return join(base, 'resources', 'runtime', 'darwin-arm64')
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveHostPath(): string {
|
||||||
|
return join(this.resolveRuntimeDir(), 'tm-wechat-host')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runtime binaries present and executable? Cheap check for the settings UI. */
|
||||||
|
isRuntimePresent(): boolean {
|
||||||
|
try {
|
||||||
|
this.manifest = readMacWechatRuntimeManifest(this.resolveRuntimeDir())
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the host if it is not already running. Deliberately lazy: the host
|
||||||
|
* comes up WITHOUT touching WeChat (lazy init inside the host), so "runtime
|
||||||
|
* online" and "WeChat bound" stay two independent states.
|
||||||
|
*/
|
||||||
|
async ensureStarted(): Promise<MacWechatRuntimeSnapshot> {
|
||||||
|
if (this.lifecycle === 'starting') {
|
||||||
|
return this.getSnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeDir = this.resolveRuntimeDir()
|
||||||
|
try {
|
||||||
|
this.manifest = readMacWechatRuntimeManifest(runtimeDir)
|
||||||
|
appLogger.write({
|
||||||
|
level: 'info',
|
||||||
|
scope: 'mac-wechat-runtime',
|
||||||
|
message: 'runtime_artifact_validated',
|
||||||
|
details: {
|
||||||
|
runtime: this.manifest.runtime,
|
||||||
|
version: this.manifest.version,
|
||||||
|
protocolVersion: this.manifest.protocolVersion,
|
||||||
|
agentSha256: this.manifest.tmSendSourceSha256,
|
||||||
|
addressProfileSha256: this.manifest.addressProfileSha256
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
this.manifest = null
|
||||||
|
const detail = error instanceof Error ? error.message : String(error)
|
||||||
|
appLogger.write({
|
||||||
|
level: 'warn',
|
||||||
|
scope: 'mac-wechat-runtime',
|
||||||
|
message: 'runtime_artifact_unavailable',
|
||||||
|
details: { detail }
|
||||||
|
})
|
||||||
|
this.lastError = '暂无发送能力'
|
||||||
|
this.lifecycle = 'stopped'
|
||||||
|
return this.getSnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.lifecycle === 'online') {
|
||||||
|
await this.refreshBinding().catch(() => undefined)
|
||||||
|
if (
|
||||||
|
this.lastBinding &&
|
||||||
|
this.manifest &&
|
||||||
|
bindingMatchesRuntimeManifest(this.lastBinding, this.manifest)
|
||||||
|
) {
|
||||||
|
return this.getSnapshot()
|
||||||
|
}
|
||||||
|
this.lifecycle = 'stopped'
|
||||||
|
this.lastError = '运行中的 host 与 runtime manifest 不一致'
|
||||||
|
return this.getSnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Adopt-first: the host is designed to OUTLIVE TraceMemo ("keep the send
|
||||||
|
* capability process"), so a previous app session may have left it running
|
||||||
|
* with a live, bound session. If something is already answering on our
|
||||||
|
* port, adopt it instead of spawning a second one (which would just die on
|
||||||
|
* the occupied port). Adopting is pure HTTP — the running host keeps its
|
||||||
|
* frida session, so the app is immediately ready with NO re-bind, exactly
|
||||||
|
* the "keep the send capability process" behaviour the user asked to carry over.
|
||||||
|
*/
|
||||||
|
if (await this.ping()) {
|
||||||
|
this.lifecycle = 'online'
|
||||||
|
this.child = null
|
||||||
|
this.wechatRestartRequired = false
|
||||||
|
await this.refreshBinding().catch(() => undefined)
|
||||||
|
if (
|
||||||
|
!this.lastBinding ||
|
||||||
|
!this.manifest ||
|
||||||
|
!bindingMatchesRuntimeManifest(this.lastBinding, this.manifest)
|
||||||
|
) {
|
||||||
|
this.lifecycle = 'stopped'
|
||||||
|
this.lastError =
|
||||||
|
'检测到无法确认身份的微信发送 host。请先退出旧 host,再重新启动 TraceMemo。'
|
||||||
|
return this.getSnapshot()
|
||||||
|
}
|
||||||
|
return this.getSnapshot()
|
||||||
|
}
|
||||||
|
if (this.lifecycle === 'crashed_bound' || this.wechatRestartRequired) {
|
||||||
|
// Restarting the host is safe (it does not touch WeChat), but binding is
|
||||||
|
// not: the orphaned agent from the previous session is still inside the
|
||||||
|
// current WeChat process. Start anyway so the UI can show live status.
|
||||||
|
appLogger.write({
|
||||||
|
level: 'warn',
|
||||||
|
scope: 'mac-wechat-runtime',
|
||||||
|
message: 'restart_host_after_bound_crash',
|
||||||
|
details: { note: 'bind stays blocked until WeChat restarts' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostPath = this.resolveHostPath()
|
||||||
|
if (!existsSync(hostPath)) {
|
||||||
|
this.lastError = `runtime host missing: ${hostPath}`
|
||||||
|
this.lifecycle = 'stopped'
|
||||||
|
return this.getSnapshot()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
chmodSync(hostPath, 0o755)
|
||||||
|
} catch {
|
||||||
|
/* best effort — dev checkouts usually keep the exec bit */
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lifecycle = 'starting'
|
||||||
|
this.lastError = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
/* Host stderr goes to /dev/null (stdio ignored, see note above), so the
|
||||||
|
* host writes its own log file via TM_WECHAT_LOG_FILE. Without this the
|
||||||
|
* agent's stage beacons (send-enter / cdn-enter / …) are invisible and a
|
||||||
|
* media/CDN stall is undiagnosable. info level so stage beacons show. */
|
||||||
|
const hostLogFile = join(app.getPath('logs'), 'tm-wechat-host.log')
|
||||||
|
this.child = spawn(
|
||||||
|
hostPath,
|
||||||
|
[
|
||||||
|
'--port',
|
||||||
|
String(PORT),
|
||||||
|
'--manifest',
|
||||||
|
join(runtimeDir, 'runtime-manifest.json'),
|
||||||
|
'--log-level',
|
||||||
|
'info'
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: runtimeDir,
|
||||||
|
stdio: 'ignore',
|
||||||
|
detached: false,
|
||||||
|
env: { ...process.env, TM_WECHAT_LOG_FILE: hostLogFile }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
this.lifecycle = 'stopped'
|
||||||
|
this.lastError = String(error)
|
||||||
|
return this.getSnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.child.on('exit', (code) => {
|
||||||
|
const wasBound =
|
||||||
|
this.lastBinding?.bound === true ||
|
||||||
|
this.lastBinding?.sessionAttached === true ||
|
||||||
|
this.lastBinding?.bindingState === 'binding'
|
||||||
|
const orphanPid = this.lastBinding?.boundWechatPid || this.lastBinding?.wechatPid || 0
|
||||||
|
this.child = null
|
||||||
|
this.lastBinding = null
|
||||||
|
if (this.lifecycle === 'stopped') return // deliberate shutdown
|
||||||
|
|
||||||
|
if (wasBound) {
|
||||||
|
this.lifecycle = 'crashed_bound'
|
||||||
|
this.wechatRestartRequired = true
|
||||||
|
this.orphanWechatPid = orphanPid
|
||||||
|
appLogger.write({
|
||||||
|
level: 'error',
|
||||||
|
scope: 'mac-wechat-runtime',
|
||||||
|
message: 'host_died_while_bound',
|
||||||
|
details: { code: code ?? null, orphanWechatPid: orphanPid }
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.lifecycle = 'crashed_unbound'
|
||||||
|
appLogger.write({
|
||||||
|
level: 'warn',
|
||||||
|
scope: 'mac-wechat-runtime',
|
||||||
|
message: 'host_died_while_unbound_auto_restart',
|
||||||
|
details: { code: code ?? null }
|
||||||
|
})
|
||||||
|
if (this.restartTimer) clearTimeout(this.restartTimer)
|
||||||
|
this.restartTimer = setTimeout(() => {
|
||||||
|
void this.ensureStarted()
|
||||||
|
}, CRASH_RESTART_DELAY_MS)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wait for /healthz
|
||||||
|
const deadline = Date.now() + START_TIMEOUT_MS
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (await this.ping()) {
|
||||||
|
this.lifecycle = 'online'
|
||||||
|
await this.refreshBinding().catch(() => undefined)
|
||||||
|
if (
|
||||||
|
!this.lastBinding ||
|
||||||
|
!this.manifest ||
|
||||||
|
!bindingMatchesRuntimeManifest(this.lastBinding, this.manifest)
|
||||||
|
) {
|
||||||
|
this.lifecycle = 'crashed_unbound'
|
||||||
|
this.lastError = '运行中的 host 与 runtime manifest 不一致'
|
||||||
|
return this.getSnapshot()
|
||||||
|
}
|
||||||
|
return this.getSnapshot()
|
||||||
|
}
|
||||||
|
if (this.child === null) break // died during startup
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.lifecycle === 'starting') {
|
||||||
|
this.lifecycle = 'crashed_unbound'
|
||||||
|
this.lastError = 'host did not answer /healthz in time'
|
||||||
|
}
|
||||||
|
return this.getSnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
private request(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
timeoutMs = REQUEST_TIMEOUT_MS
|
||||||
|
): Promise<{ status: number | null; body: string }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const request = get(
|
||||||
|
{ host: HOST, port: PORT, path, method, timeout: timeoutMs },
|
||||||
|
(response) => {
|
||||||
|
let body = ''
|
||||||
|
response.on('data', (chunk) => {
|
||||||
|
body += chunk
|
||||||
|
})
|
||||||
|
response.on('end', () => {
|
||||||
|
resolve({ status: response.statusCode ?? null, body })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
request.on('timeout', () => {
|
||||||
|
request.destroy()
|
||||||
|
resolve({ status: null, body: '' })
|
||||||
|
})
|
||||||
|
request.on('error', () => {
|
||||||
|
resolve({ status: null, body: '' })
|
||||||
|
})
|
||||||
|
request.end()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async ping(): Promise<boolean> {
|
||||||
|
const { status } = await this.request('GET', '/healthz', 3_000)
|
||||||
|
if (status === 200) return true
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Adopted hosts have no child handle, so their death is only observable
|
||||||
|
* through failed probes. Classify here: dying while bound means WeChat
|
||||||
|
* keeps an orphaned agent (restart required); dying while unbound is
|
||||||
|
* auto-recoverable.
|
||||||
|
*/
|
||||||
|
if (this.lifecycle === 'online') {
|
||||||
|
const wasBound =
|
||||||
|
this.lastBinding?.bound === true ||
|
||||||
|
this.lastBinding?.sessionAttached === true ||
|
||||||
|
this.lastBinding?.bindingState === 'binding'
|
||||||
|
const orphanPid = this.lastBinding?.boundWechatPid || this.lastBinding?.wechatPid || 0
|
||||||
|
this.lastBinding = null
|
||||||
|
if (wasBound) {
|
||||||
|
this.lifecycle = 'crashed_bound'
|
||||||
|
this.wechatRestartRequired = true
|
||||||
|
this.orphanWechatPid = orphanPid
|
||||||
|
appLogger.write({
|
||||||
|
level: 'error',
|
||||||
|
scope: 'mac-wechat-runtime',
|
||||||
|
message: 'adopted_host_died_while_bound',
|
||||||
|
details: { note: 'WeChat restart required', orphanWechatPid: orphanPid }
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.lifecycle = 'crashed_unbound'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull /bindingStatus and cache it for crash classification. */
|
||||||
|
async refreshBinding(): Promise<MacWechatBindingStatus | null> {
|
||||||
|
const { status, body } = await this.request('GET', '/bindingStatus')
|
||||||
|
if (status !== 200) return null
|
||||||
|
try {
|
||||||
|
this.lastBinding = JSON.parse(body) as MacWechatBindingStatus
|
||||||
|
/*
|
||||||
|
* A 20s bind timeout is NOT proof of the orphan-agent wall: in the
|
||||||
|
* stale-rebind path the host worker waits up to 45s for the old session
|
||||||
|
* teardown before attaching, so the HTTP response times out while the
|
||||||
|
* attach quietly succeeds afterwards. When the binding is later observed
|
||||||
|
* alive and matching, the restart-required verdict was a false positive
|
||||||
|
* and must be withdrawn — otherwise the manager keeps rejecting every
|
||||||
|
* further bind locally while WeChat is actually bound and working.
|
||||||
|
*/
|
||||||
|
if (
|
||||||
|
this.wechatRestartRequired &&
|
||||||
|
(this.lastBinding.bound === true || this.lastBinding.bindingState === 'ready') &&
|
||||||
|
this.lastBinding.boundWechatPid === this.lastBinding.wechatPid
|
||||||
|
) {
|
||||||
|
this.wechatRestartRequired = false
|
||||||
|
appLogger.write({
|
||||||
|
level: 'warn',
|
||||||
|
scope: 'mac-wechat-runtime',
|
||||||
|
message: 'restart_required_withdrawn',
|
||||||
|
details: { note: 'binding observed alive after bind timeout' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return this.lastBinding
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBindingStatus(): Promise<MacWechatBindingStatus | null> {
|
||||||
|
if (this.lifecycle !== 'online') return this.lastBinding
|
||||||
|
await this.refreshBinding().catch(() => null)
|
||||||
|
return this.lastBinding
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Build the legacy PersonalWechatSenderStatus contract from the live mac
|
||||||
|
* runtime state. Lives here (not in the capability service) so BOTH the
|
||||||
|
* capability mapping and the send path report the same facts without a
|
||||||
|
* circular import.
|
||||||
|
*/
|
||||||
|
async buildSenderStatus(): Promise<PersonalWechatSenderStatus> {
|
||||||
|
/*
|
||||||
|
* Lazy-start the host from status reads.
|
||||||
|
*
|
||||||
|
* Every UI surface discovers the runtime through this method, and the
|
||||||
|
* setup guide renders "正在检查 发送运行时…" while runtimeReady is false —
|
||||||
|
* where runtimeReady means lifecycle === 'online'. If nothing here brings a
|
||||||
|
* stopped host up, opening the app (with WeChat already logged in, so no
|
||||||
|
* send is ever attempted) leaves that step spinning forever: the check can
|
||||||
|
* never succeed because the check itself is what should start the host.
|
||||||
|
* Starting it never touches WeChat, so doing it from a read is safe.
|
||||||
|
*/
|
||||||
|
if (this.lifecycle === 'stopped') {
|
||||||
|
await this.ensureStarted().catch(() => undefined)
|
||||||
|
}
|
||||||
|
/* Self-heal the status card too: if WeChat was restarted, drop the stale
|
||||||
|
* restartRequired before it is read into the UI as an error. */
|
||||||
|
try {
|
||||||
|
this.manifest = readMacWechatRuntimeManifest(this.resolveRuntimeDir())
|
||||||
|
} catch (error) {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error)
|
||||||
|
appLogger.write({
|
||||||
|
level: 'warn',
|
||||||
|
scope: 'mac-wechat-runtime',
|
||||||
|
message: 'runtime_artifact_unavailable',
|
||||||
|
details: { detail }
|
||||||
|
})
|
||||||
|
const message = '暂无发送能力'
|
||||||
|
this.manifest = null
|
||||||
|
this.lastError = message
|
||||||
|
return {
|
||||||
|
state: 'runtime_missing',
|
||||||
|
platform: 'darwin',
|
||||||
|
arch: process.arch,
|
||||||
|
sipDisabled: true,
|
||||||
|
wechatRunning: false,
|
||||||
|
endpoint: `http://${HOST}:${PORT}`,
|
||||||
|
endpointReady: false,
|
||||||
|
runtimeReady: false,
|
||||||
|
attachReady: false,
|
||||||
|
baseAddressReady: false,
|
||||||
|
textHookInstalled: false,
|
||||||
|
textHookReady: false,
|
||||||
|
imageHookInstalled: false,
|
||||||
|
imageHookReady: false,
|
||||||
|
messageListenerReady: false,
|
||||||
|
canSend: false,
|
||||||
|
canSendText: false,
|
||||||
|
canSendImage: false,
|
||||||
|
canSendVoice: false,
|
||||||
|
message,
|
||||||
|
error: message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.clearRestartRequiredIfWechatRestarted()
|
||||||
|
const snapshot = this.getSnapshot()
|
||||||
|
const binding = await this.getBindingStatus()
|
||||||
|
return deriveMacWechatSenderStatus({ snapshot, binding, manifest: this.manifest })
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The orphan agent lives inside ONE specific WeChat process. If the WeChat
|
||||||
|
* pid we recorded when restartRequired was raised no longer matches the
|
||||||
|
* WeChat pid the host currently sees, WeChat has been restarted and the
|
||||||
|
* orphan is gone — the flag must clear or every later bind is rejected
|
||||||
|
* forever even though the user DID restart WeChat (the reported bug).
|
||||||
|
*/
|
||||||
|
private async clearRestartRequiredIfWechatRestarted(): Promise<void> {
|
||||||
|
if (!this.wechatRestartRequired) return
|
||||||
|
await this.refreshBinding().catch(() => null)
|
||||||
|
const currentPid = this.lastBinding?.wechatPid ?? 0
|
||||||
|
if (shouldClearWechatRestartRequired(this.orphanWechatPid, currentPid)) {
|
||||||
|
this.wechatRestartRequired = false
|
||||||
|
this.orphanWechatPid = 0
|
||||||
|
if (this.lifecycle === 'crashed_bound') this.lifecycle = 'online'
|
||||||
|
appLogger.write({
|
||||||
|
level: 'info',
|
||||||
|
scope: 'mac-wechat-runtime',
|
||||||
|
message: 'restart_required_cleared_wechat_restarted',
|
||||||
|
details: { currentWechatPid: currentPid }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit user action only. Idempotent on the host side: binding the same
|
||||||
|
* live WeChat pid again returns attached=false and never double-attaches.
|
||||||
|
*/
|
||||||
|
async bind(): Promise<MacWechatBindResult> {
|
||||||
|
if (this.lifecycle !== 'online') {
|
||||||
|
const snapshot = await this.ensureStarted()
|
||||||
|
if (snapshot.lifecycle !== 'online') {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
attached: false,
|
||||||
|
state: 'runtime_offline',
|
||||||
|
message: snapshot.error ?? 'Runtime 未在线,无法绑定'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auto-clear if WeChat was restarted since the flag was raised —
|
||||||
|
* otherwise this branch rejects every bind forever (the reported bug). */
|
||||||
|
await this.clearRestartRequiredIfWechatRestarted()
|
||||||
|
|
||||||
|
if (this.wechatRestartRequired) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
attached: false,
|
||||||
|
state: 'wechat_restart_required',
|
||||||
|
message:
|
||||||
|
'Previous WeChat send session was not fully released. ' + 'Restart WeChat and bind again.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { status, body } = await this.request('POST', '/bindWeChat')
|
||||||
|
await this.refreshBinding().catch(() => null)
|
||||||
|
|
||||||
|
if (status !== 200) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
attached: false,
|
||||||
|
state: 'host_error',
|
||||||
|
message: `host 返回 HTTP ${status ?? '无响应'}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(body) as MacWechatBindResult
|
||||||
|
if (!parsed.ok && parsed.message?.includes('not fully released')) {
|
||||||
|
this.wechatRestartRequired = true
|
||||||
|
this.orphanWechatPid = this.lastBinding?.boundWechatPid || this.lastBinding?.wechatPid || 0
|
||||||
|
appLogger.write({
|
||||||
|
level: 'error',
|
||||||
|
scope: 'mac-wechat-runtime',
|
||||||
|
message: 'bind_hit_orphan_agent_wall',
|
||||||
|
details: { note: 'WeChat restart required', orphanWechatPid: this.orphanWechatPid }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
} catch {
|
||||||
|
return { ok: false, attached: false, state: 'bad_response', message: body.slice(0, 200) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 重新加载发送组件:优雅停掉当前 host 进程,再启动一个新实例。
|
||||||
|
* 用于:更新二进制后换新代码;或 host 卡死时的手动恢复。
|
||||||
|
* 注意:若当前 host 已绑定微信,此操作会让微信里留下孤儿 agent,
|
||||||
|
* 需要重启微信后重新绑定(UI 会提示)。
|
||||||
|
*/
|
||||||
|
async reload(): Promise<MacWechatRuntimeSnapshot> {
|
||||||
|
await this.shutdown().catch(() => undefined)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2_000))
|
||||||
|
return this.ensureStarted()
|
||||||
|
}
|
||||||
|
|
||||||
|
private isProcessAlive(pid: number): boolean {
|
||||||
|
if (!Number.isInteger(pid) || pid <= 0) return false
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Graceful stop only. A bound host must never be force-killed: doing so can
|
||||||
|
* leave its injected agent inside WeChat and make the next attach hang.
|
||||||
|
*/
|
||||||
|
async shutdown(): Promise<void> {
|
||||||
|
if (this.restartTimer) {
|
||||||
|
clearTimeout(this.restartTimer)
|
||||||
|
this.restartTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.lifecycle === 'stopped' && this.child === null) return
|
||||||
|
|
||||||
|
const reachable = await this.ping()
|
||||||
|
if (!reachable && this.child === null) {
|
||||||
|
this.lifecycle = 'stopped'
|
||||||
|
this.lastBinding = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.refreshBinding().catch(() => null)
|
||||||
|
const child = this.child
|
||||||
|
const hostPid = this.lastBinding?.hostPid ?? 0
|
||||||
|
this.lifecycle = 'stopped'
|
||||||
|
this.lastError = null
|
||||||
|
|
||||||
|
const response = await this.request('POST', '/shutdown', 5_000)
|
||||||
|
if (response.status !== 200) {
|
||||||
|
this.lifecycle = 'online'
|
||||||
|
this.lastError = 'Native Runtime 未确认退出请求,已保留 host 以避免微信残留会话。'
|
||||||
|
throw new Error(this.lastError)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deadline = Date.now() + 55_000
|
||||||
|
let transportClosedAt = 0
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const childExited = child !== null && child.exitCode !== null
|
||||||
|
const pidExited = hostPid > 0 && !this.isProcessAlive(hostPid)
|
||||||
|
if (childExited || pidExited) {
|
||||||
|
this.child = null
|
||||||
|
this.lastBinding = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (child === null && hostPid === 0) {
|
||||||
|
const transportOpen = await this.ping()
|
||||||
|
if (!transportOpen) {
|
||||||
|
if (transportClosedAt === 0) transportClosedAt = Date.now()
|
||||||
|
/* Compatibility with a pre-hostPid runtime during one upgrade:
|
||||||
|
* its teardown waits at most 45s after closing the listen socket. */
|
||||||
|
if (Date.now() - transportClosedAt >= 46_000) {
|
||||||
|
this.lastBinding = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
transportClosedAt = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastError = 'Native Runtime 安全退出超时;未强制结束绑定态 host,请先退出微信后再重试。'
|
||||||
|
throw new Error(this.lastError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const macWechatRuntimeManager = new MacWechatRuntimeManager()
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
import { decompress as zstdDecompress } from 'fzstd'
|
||||||
|
import type { Wcdb4Client, Wcdb4Message, Wcdb4MonitorEvent } from '../wcdb4-client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MessageListener —— 实时消息回读底座。
|
||||||
|
*
|
||||||
|
* **职责边界(刻意很窄)**:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* WCDB native change
|
||||||
|
* ↓
|
||||||
|
* coalesce
|
||||||
|
* ↓
|
||||||
|
* bounded DB readback
|
||||||
|
* ↓
|
||||||
|
* dedup
|
||||||
|
* ↓
|
||||||
|
* NormalizedIncomingMessage → onMessage(callback)
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* **不负责**(这些属于下一层,本模块不许碰):关键词匹配、@我业务判断、日报、
|
||||||
|
* 自动回复、AI 调用、Agent 调度、发送消息。
|
||||||
|
*
|
||||||
|
* 设计约束:一次写入会触发**一连串** native event(十几条),
|
||||||
|
* 所以**绝不能**一个事件触发一次业务动作。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** zstd 帧魔数(微信 `source` 列是 zstd 压缩)。 */
|
||||||
|
const ZSTD_MAGIC = [0x28, 0xb5, 0x2f, 0xfd]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* coalesce 窗口。
|
||||||
|
*
|
||||||
|
* 实测同一批 native event 会**同时**到达(同一毫秒内十几个),所以只需要一个极短的
|
||||||
|
* 合并窗口就能把它们收敛成一次回读。取 120ms:
|
||||||
|
* - 足够吃掉同一批事件(实测事件间隔 < 5ms);
|
||||||
|
* - 相对「event → 可读」本身就有 ~1.2s 的落库延迟,这点等待**不构成额外延迟**;
|
||||||
|
* - 与项目既有 `wcdb-change` 消费端的 350ms debounce 相比更短,不会叠加成明显卡顿。
|
||||||
|
*/
|
||||||
|
const DEFAULT_COALESCE_MS = 120
|
||||||
|
|
||||||
|
/** 回读窗口:只看最近这么久,绝不扫历史。 */
|
||||||
|
const DEFAULT_LOOKBACK_SEC = 30
|
||||||
|
/** 未来容忍(时钟漂移 + 秒级取整)。 */
|
||||||
|
const DEFAULT_LOOKAHEAD_SEC = 2
|
||||||
|
/** 单次回读条数上限。 */
|
||||||
|
const DEFAULT_READ_LIMIT = 50
|
||||||
|
|
||||||
|
/** dedup 条目存活时间:超过它就可以被淘汰(同一条消息不会在窗口外再被读到)。 */
|
||||||
|
const DEFAULT_DEDUP_TTL_MS = 5 * 60 * 1000
|
||||||
|
/** dedup 容量上限,防止长时间运行后无限增长。 */
|
||||||
|
const DEFAULT_DEDUP_MAX_ENTRIES = 5_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范化后的入站消息。
|
||||||
|
*
|
||||||
|
* 刻意**克制**:只包含 Trigger 后续真正需要的字段,不做「万能 Message DTO」。
|
||||||
|
* 原始行(`raw`)、未解压的 Buffer、头像等一律不往外传。
|
||||||
|
*/
|
||||||
|
export interface NormalizedIncomingMessage {
|
||||||
|
sessionId: string
|
||||||
|
/** 会话内序号。**单独不保证跨 shard 唯一**,去重必须带上 sessionId。 */
|
||||||
|
localId: string
|
||||||
|
/** 服务器侧全局标识。**可能缺失**,所以只作强标识、不作必需字段。 */
|
||||||
|
serverId?: string
|
||||||
|
/**
|
||||||
|
* Unix **epoch 秒** —— 与 WCDB `create_time` / `getMessages` 参数同口径。
|
||||||
|
*
|
||||||
|
* ⚠️ 本项目另有模块使用 epoch 毫秒。**换算只允许集中在本层**:
|
||||||
|
* 凡是需要毫秒的消费者,自己明确 `/1000` 的**唯一**位置就是这里之后的调用点,
|
||||||
|
* 不许在任意函数里散落 `* 1000` / `/ 1000`。
|
||||||
|
*/
|
||||||
|
createTime: number
|
||||||
|
messageType: number
|
||||||
|
/** `mesDes === 0` 表示自己发送(字段语义与直觉相反)。 */
|
||||||
|
isSelf: boolean
|
||||||
|
senderId?: string
|
||||||
|
senderNickname?: string
|
||||||
|
content?: string
|
||||||
|
/** **已解压**的 source XML;原始 zstd Buffer 不往外传。 */
|
||||||
|
source?: string
|
||||||
|
isGroup: boolean
|
||||||
|
/**
|
||||||
|
* 从 `source` 解析出的 @ 目标(**微信 username,不是昵称**)。
|
||||||
|
*
|
||||||
|
* 本轮只产出数据,**不做「是否 @ 我」的业务判断** —— 那需要与
|
||||||
|
* `getMyUsernameCandidates()` 求交集,属于下一层。
|
||||||
|
*/
|
||||||
|
mentionTargets: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 监听统计(不含任何身份信息,可安全记录)。 */
|
||||||
|
export interface MessageListenerStats {
|
||||||
|
/** 收到的 native change event 数。 */
|
||||||
|
nativeEvents: number
|
||||||
|
/** 被 coalesce 合并掉的事件数(未单独触发回读)。 */
|
||||||
|
coalescedEvents: number
|
||||||
|
/** 实际执行的回读次数。 */
|
||||||
|
readbacks: number
|
||||||
|
/** 通过 dedup 并投递出去的消息数。 */
|
||||||
|
delivered: number
|
||||||
|
/** 因 dedup 被丢弃的重复投递数。 */
|
||||||
|
deduped: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageListenerOptions {
|
||||||
|
coalesceMs?: number
|
||||||
|
lookbackSec?: number
|
||||||
|
lookaheadSec?: number
|
||||||
|
readLimit?: number
|
||||||
|
dedupTtlMs?: number
|
||||||
|
dedupMaxEntries?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 `source`(**已解压的 XML 文本**)提取 @ 目标。
|
||||||
|
*
|
||||||
|
* 纯函数:不依赖任何运行时状态,便于单测。
|
||||||
|
*
|
||||||
|
* 实测两种形式都真实存在,**都必须支持**:
|
||||||
|
* ```xml
|
||||||
|
* <atuserlist><![CDATA[SELF_USERNAME]]></atuserlist>
|
||||||
|
* <atuserlist>SELF_USERNAME</atuserlist>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ⚠️ 三条硬规则:
|
||||||
|
* 1. **CDATA 外壳必须剥掉**,否则拿到的是 `<![CDATA[xxx]]>` 字面量;
|
||||||
|
* 2. **值就是微信 username,不保证以 `wxid_` 开头** —— 绝不能用前缀做过滤/校验;
|
||||||
|
* 3. **不允许多人分隔符的猜测** —— 多人 @ 的格式尚未在真实样本中验证过,
|
||||||
|
* 这里按「单个值原样返回」处理;一旦拿到真实多人样本需补解析与测试
|
||||||
|
* (当前形状已经预留为 `string[]`)。
|
||||||
|
*/
|
||||||
|
export function extractMentionTargets(source: string | undefined): string[] {
|
||||||
|
const text = String(source ?? '')
|
||||||
|
if (!text) return []
|
||||||
|
const match = /<atuserlist>([\s\S]*?)<\/atuserlist>/i.exec(text)
|
||||||
|
if (!match) return []
|
||||||
|
const inner = match[1].trim()
|
||||||
|
const unwrapped = inner.replace(/^<!\[CDATA\[/, '').replace(/\]\]>$/, '').trim()
|
||||||
|
return unwrapped ? [unwrapped] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 WCDB 的 `source` 列(zstd 压缩的 Buffer)还原成 XML 文本。 */
|
||||||
|
export function decodeSourcePayload(value: unknown): string | undefined {
|
||||||
|
let buffer: Buffer | null = null
|
||||||
|
if (Buffer.isBuffer(value)) {
|
||||||
|
buffer = value
|
||||||
|
} else {
|
||||||
|
const candidate = value as { type?: string; data?: unknown } | null
|
||||||
|
if (candidate?.type === 'Buffer' && Array.isArray(candidate.data)) {
|
||||||
|
buffer = Buffer.from(candidate.data as number[])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!buffer || buffer.length === 0) return undefined
|
||||||
|
const isZstd = ZSTD_MAGIC.every((byte, index) => buffer![index] === byte)
|
||||||
|
if (!isZstd) return buffer.toString('utf8')
|
||||||
|
try {
|
||||||
|
return Buffer.from(zstdDecompress(buffer)).toString('utf8')
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实时消息监听。
|
||||||
|
*
|
||||||
|
* ⚠️ **已知 BLOCKER(必须如实标注)**:
|
||||||
|
* 本实现**无法从 native event 判断是哪个会话发生了变化** —— 事件 payload 只有
|
||||||
|
* `{db, table, action}`(native 侧 `MonitorEvent` 结构就这两个字段)。
|
||||||
|
* 因此当前策略是**回读「最近活跃会话」**(`sessions[0]`,按 `last_timestamp` 排序),
|
||||||
|
* 这在「刚刚收到消息的会话」这一场景下成立,但:
|
||||||
|
*
|
||||||
|
* - **不是**「全局监听所有微信会话」;
|
||||||
|
* - 若同一窗口内有**多个会话**同时来消息,只会回读到其中最近的那个;
|
||||||
|
* - 正式扩展需要解决「如何从 table event 推断需要回读哪些会话」。
|
||||||
|
*
|
||||||
|
* 详见 `NEXT_STEPS` 注释与 Spike 报告。
|
||||||
|
*/
|
||||||
|
export class MessageListenerService {
|
||||||
|
private readonly listeners = new Set<(message: NormalizedIncomingMessage) => void>()
|
||||||
|
/**
|
||||||
|
* dedup 缓存:`sessionId:localId` → 首次见到的时间戳。
|
||||||
|
*
|
||||||
|
* 必须有界 —— Spike 里用的是无上限 `Set`,长时间运行会持续吃内存。
|
||||||
|
*/
|
||||||
|
private readonly seen = new Map<string, number>()
|
||||||
|
/**
|
||||||
|
* 本批 coalesce 窗口内**出现过精确会话**的事件收集到的 session 集合。
|
||||||
|
*
|
||||||
|
* v2 事件(Native Monitor Event v2)会带上发生变化的 sessionId,这里用 **Set** 收集 ——
|
||||||
|
* 120ms 内收到 `A B A C B` 必须回读 A/B/C 三个,绝不能「最后一个 wins」。
|
||||||
|
*/
|
||||||
|
private readonly pendingSessions = new Set<string>()
|
||||||
|
private coalesceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
private readbackInFlight = false
|
||||||
|
private disposed = false
|
||||||
|
|
||||||
|
private readonly coalesceMs: number
|
||||||
|
private readonly lookbackSec: number
|
||||||
|
private readonly lookaheadSec: number
|
||||||
|
private readonly readLimit: number
|
||||||
|
private readonly dedupTtlMs: number
|
||||||
|
private readonly dedupMaxEntries: number
|
||||||
|
|
||||||
|
private counters: MessageListenerStats = {
|
||||||
|
nativeEvents: 0,
|
||||||
|
coalescedEvents: 0,
|
||||||
|
readbacks: 0,
|
||||||
|
delivered: 0,
|
||||||
|
deduped: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly client: Wcdb4Client,
|
||||||
|
options: MessageListenerOptions = {}
|
||||||
|
) {
|
||||||
|
this.coalesceMs = options.coalesceMs ?? DEFAULT_COALESCE_MS
|
||||||
|
this.lookbackSec = options.lookbackSec ?? DEFAULT_LOOKBACK_SEC
|
||||||
|
this.lookaheadSec = options.lookaheadSec ?? DEFAULT_LOOKAHEAD_SEC
|
||||||
|
this.readLimit = options.readLimit ?? DEFAULT_READ_LIMIT
|
||||||
|
this.dedupTtlMs = options.dedupTtlMs ?? DEFAULT_DEDUP_TTL_MS
|
||||||
|
this.dedupMaxEntries = options.dedupMaxEntries ?? DEFAULT_DEDUP_MAX_ENTRIES
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅新消息。返回取消订阅函数。
|
||||||
|
*
|
||||||
|
* 刻意**只提供回调**:当前规模不需要 EventEmitter / RxJS / 内部队列。
|
||||||
|
*/
|
||||||
|
onMessage(listener: (message: NormalizedIncomingMessage) => void): () => void {
|
||||||
|
this.listeners.add(listener)
|
||||||
|
return () => this.listeners.delete(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
stats(): MessageListenerStats {
|
||||||
|
return { ...this.counters }
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.disposed = true
|
||||||
|
if (this.coalesceTimer) {
|
||||||
|
clearTimeout(this.coalesceTimer)
|
||||||
|
this.coalesceTimer = null
|
||||||
|
}
|
||||||
|
this.listeners.clear()
|
||||||
|
this.seen.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 接 native change event。
|
||||||
|
*
|
||||||
|
* 只做计数、收集会话与 coalesce —— **绝不**在这里回读,更不触发任何业务。
|
||||||
|
*
|
||||||
|
* `event.protocol === 2` 时把 `sessionId` 收进本批的集合;legacy 事件(v1,不带会话)
|
||||||
|
* 走到回读时仍然只能退化成「最近活跃会话」。
|
||||||
|
*/
|
||||||
|
handleNativeChange(event?: Wcdb4MonitorEvent): void {
|
||||||
|
if (this.disposed) return
|
||||||
|
this.counters.nativeEvents += 1
|
||||||
|
if (event?.protocol === 2 && event.sessionId) {
|
||||||
|
this.pendingSessions.add(event.sessionId)
|
||||||
|
}
|
||||||
|
if (this.coalesceTimer) {
|
||||||
|
// 已经在同一个窗口里:直接合并掉,这就是 17:1 那个比值的收敛点。
|
||||||
|
this.counters.coalescedEvents += 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.coalesceTimer = setTimeout(() => {
|
||||||
|
this.coalesceTimer = null
|
||||||
|
void this.readback()
|
||||||
|
}, this.coalesceMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 有界回读 → normalize → dedup → 投递。
|
||||||
|
*
|
||||||
|
* 回读范围严格受限:**按会话** + **小时间窗口** + **条数上限**。不遍历会话、不扫全库。
|
||||||
|
*
|
||||||
|
* 目标会话的来源有两种:
|
||||||
|
* - **precise(protocol v2)**:本批事件收集到的 `sessionId` 集合,逐个回读
|
||||||
|
* (多个会话同时来消息时,A/B/C 都会被读到);
|
||||||
|
* - **legacy(protocol v1,旧 runtime)**:只能退回「最近活跃会话」`getSessions()[0]`。
|
||||||
|
*/
|
||||||
|
private async readback(): Promise<void> {
|
||||||
|
if (this.disposed || this.readbackInFlight) return
|
||||||
|
this.readbackInFlight = true
|
||||||
|
const startedAt = Date.now()
|
||||||
|
|
||||||
|
const preciseSessions = Array.from(this.pendingSessions)
|
||||||
|
this.pendingSessions.clear()
|
||||||
|
|
||||||
|
try {
|
||||||
|
let delivered = 0
|
||||||
|
let sessions = 0
|
||||||
|
|
||||||
|
if (preciseSessions.length > 0) {
|
||||||
|
for (const sessionId of preciseSessions) {
|
||||||
|
if (this.disposed) return
|
||||||
|
delivered += await this.readbackSession(sessionId)
|
||||||
|
sessions += 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// legacy:旧 runtime 的 payload 不带会话,只能回读「最近活跃会话」。
|
||||||
|
const session = this.client.getSessions()[0]
|
||||||
|
if (!session?.username) return
|
||||||
|
delivered = await this.readbackSession(session.username)
|
||||||
|
sessions = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delivered > 0) {
|
||||||
|
// 日志只记录协议版本、数量与耗时 —— **不含** wxid / 群名 / 昵称 / 正文 / source / sessionId。
|
||||||
|
console.log(
|
||||||
|
`[MessageListener] protocol=${preciseSessions.length > 0 ? 'v2' : 'v1'}` +
|
||||||
|
` sessions=${sessions} delivered=${delivered} readbackMs=${Date.now() - startedAt}` +
|
||||||
|
` events=${this.counters.nativeEvents} coalesced=${this.counters.coalescedEvents}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`[MessageListener] readback failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
this.readbackInFlight = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 回读**单个**会话并投递。返回本次真正投递出去的条数。 */
|
||||||
|
private async readbackSession(sessionId: string): Promise<number> {
|
||||||
|
const nowSec = Math.floor(Date.now() / 1000)
|
||||||
|
this.counters.readbacks += 1
|
||||||
|
const messages = await this.client.getMessagesAsync(
|
||||||
|
sessionId,
|
||||||
|
nowSec - this.lookbackSec,
|
||||||
|
nowSec + this.lookaheadSec,
|
||||||
|
{ limit: this.readLimit }
|
||||||
|
)
|
||||||
|
if (this.disposed) return 0
|
||||||
|
|
||||||
|
let delivered = 0
|
||||||
|
for (const message of messages) {
|
||||||
|
const normalized = this.normalize(sessionId, message)
|
||||||
|
if (!normalized) continue
|
||||||
|
// dedup:同一条消息可能在多个读回窗口中反复出现(native 事件本身也可能重复)。
|
||||||
|
if (!this.markSeen(normalized)) {
|
||||||
|
this.counters.deduped += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
this.counters.delivered += 1
|
||||||
|
delivered += 1
|
||||||
|
this.deliver(normalized)
|
||||||
|
}
|
||||||
|
return delivered
|
||||||
|
}
|
||||||
|
|
||||||
|
private deliver(message: NormalizedIncomingMessage): void {
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
try {
|
||||||
|
listener(message)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`[MessageListener] listener threw: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 WCDB 行规范化为克制的 DTO。返回 null 表示这条行缺关键标识,无法投递。 */
|
||||||
|
private normalize(
|
||||||
|
sessionUsername: string,
|
||||||
|
message: Wcdb4Message
|
||||||
|
): NormalizedIncomingMessage | null {
|
||||||
|
const localId = message.mesLocalID === undefined ? '' : String(message.mesLocalID)
|
||||||
|
if (!localId) return null
|
||||||
|
|
||||||
|
const createTime = Number(message.msgCreateTime) || 0
|
||||||
|
if (!createTime) return null
|
||||||
|
|
||||||
|
const raw = (message.raw ?? {}) as Record<string, unknown>
|
||||||
|
const source = decodeSourcePayload(raw.source)
|
||||||
|
const senderId = message.sender ? String(message.sender) : undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId: sessionUsername,
|
||||||
|
localId,
|
||||||
|
serverId: message.serverId ? String(message.serverId) : undefined,
|
||||||
|
createTime,
|
||||||
|
messageType: Number(message.messageType) || 0,
|
||||||
|
// `mesDes === 0` 是自己发送。不用昵称 / sender 文本 / content / username 前缀判断。
|
||||||
|
isSelf: Number(message.mesDes) === 0,
|
||||||
|
senderId,
|
||||||
|
senderNickname: message.senderNickname ? String(message.senderNickname) : undefined,
|
||||||
|
content: message.msgContent ? String(message.msgContent) : undefined,
|
||||||
|
source,
|
||||||
|
isGroup: sessionUsername.endsWith('@chatroom'),
|
||||||
|
mentionTargets: extractMentionTargets(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登记并返回「是否是第一次见到」。同时做 TTL + 容量淘汰。 */
|
||||||
|
private markSeen(message: NormalizedIncomingMessage): boolean {
|
||||||
|
const key = `${message.sessionId}:${message.localId}`
|
||||||
|
const now = Date.now()
|
||||||
|
if (this.seen.has(key)) return false
|
||||||
|
|
||||||
|
this.seen.set(key, now)
|
||||||
|
if (this.seen.size > this.dedupMaxEntries) this.evict(now)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 淘汰策略:先按 TTL 清理过期条目;若仍然超限,再按插入顺序丢掉最旧的一批
|
||||||
|
* (Map 保持插入顺序,所以从头删就是删最旧)。
|
||||||
|
*/
|
||||||
|
private evict(now: number): void {
|
||||||
|
for (const [key, seenAt] of this.seen) {
|
||||||
|
if (now - seenAt > this.dedupTtlMs) this.seen.delete(key)
|
||||||
|
}
|
||||||
|
if (this.seen.size <= this.dedupMaxEntries) return
|
||||||
|
const overflow = this.seen.size - this.dedupMaxEntries
|
||||||
|
let removed = 0
|
||||||
|
for (const key of this.seen.keys()) {
|
||||||
|
this.seen.delete(key)
|
||||||
|
removed += 1
|
||||||
|
if (removed >= overflow) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NEXT STEPS(不在本轮范围)
|
||||||
|
*
|
||||||
|
* 1. **会话定位(BLOCKER)**:目前只能回读「最近活跃会话」。要真正监听全部会话,
|
||||||
|
* 需要解决「从 table event 推断变更会话」。可能的路线:
|
||||||
|
* - 观察 native 侧能否在事件里补上 table 所属的会话标识(需改 dll);
|
||||||
|
* - 或改成按 `Session` 表的 `last_timestamp` 变化做「有界的多会话回读」。
|
||||||
|
* 2. **多人 @**:`extractMentionTargets` 目前按单值返回,多人格式**未验证**,不得猜分隔符。
|
||||||
|
* 3. **isMentionedMe**:`mentionTargets` ∩ `getMyUsernameCandidates()`,再接 Trigger。
|
||||||
|
* 4. 本轮**不做**:关键词 / @触发 / 自动回复 / 日报 / AI / Agent / 发送。
|
||||||
|
*/
|
||||||
@@ -7,15 +7,28 @@ import {
|
|||||||
personalWechatSendService,
|
personalWechatSendService,
|
||||||
type PersonalWechatSendService
|
type PersonalWechatSendService
|
||||||
} from './personal-wechat-send-service'
|
} from './personal-wechat-send-service'
|
||||||
|
import { macWechatRuntimeManager } from './mac-wechat-runtime-manager'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts the detailed sender diagnostics into a small contract that other
|
* Converts the detailed sender diagnostics into a small contract that other
|
||||||
* features can consume without knowing about OneBot, Hook or platform details.
|
* features can consume without knowing about the macOS native runtime, the
|
||||||
|
* Windows hook transport, or platform details.
|
||||||
|
*
|
||||||
|
* On macOS the capability is derived only from MacWechatRuntimeManager and its
|
||||||
|
* validated runtime manifest. Windows continues to use the existing sender.
|
||||||
*/
|
*/
|
||||||
export class PersonalWechatCapabilityService {
|
export class PersonalWechatCapabilityService {
|
||||||
constructor(private readonly sender: Pick<PersonalWechatSendService, 'getStatus'>) {}
|
constructor(
|
||||||
|
private readonly sender: Pick<PersonalWechatSendService, 'getStatus'>,
|
||||||
|
private readonly platform: NodeJS.Platform = process.platform,
|
||||||
|
private readonly getMacStatus: () => Promise<PersonalWechatSenderStatus> = () =>
|
||||||
|
macWechatRuntimeManager.buildSenderStatus()
|
||||||
|
) {}
|
||||||
|
|
||||||
async getPersonalWechatSendCapability(): Promise<PersonalWechatSendCapability> {
|
async getPersonalWechatSendCapability(): Promise<PersonalWechatSendCapability> {
|
||||||
|
if (this.platform === 'darwin') {
|
||||||
|
return this.fromMacRuntime()
|
||||||
|
}
|
||||||
const senderStatus = await this.sender.getStatus()
|
const senderStatus = await this.sender.getStatus()
|
||||||
return this.fromSenderStatus(senderStatus)
|
return this.fromSenderStatus(senderStatus)
|
||||||
}
|
}
|
||||||
@@ -49,6 +62,16 @@ export class PersonalWechatCapabilityService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* macOS capability from the native runtime. Builds a senderStatus snapshot
|
||||||
|
* aligned with the mac binding state and reuses the shared mapping, so the
|
||||||
|
* settings header and the binding card can never disagree.
|
||||||
|
*/
|
||||||
|
private async fromMacRuntime(): Promise<PersonalWechatSendCapability> {
|
||||||
|
const senderStatus = await this.getMacStatus()
|
||||||
|
return this.fromSenderStatus(senderStatus)
|
||||||
|
}
|
||||||
|
|
||||||
private mapState(senderStatus: PersonalWechatSenderStatus): PersonalWechatSendCapabilityState {
|
private mapState(senderStatus: PersonalWechatSenderStatus): PersonalWechatSendCapabilityState {
|
||||||
if (senderStatus.platform === 'win32') {
|
if (senderStatus.platform === 'win32') {
|
||||||
if (senderStatus.canSend) return 'ready'
|
if (senderStatus.canSend) return 'ready'
|
||||||
@@ -60,6 +83,7 @@ export class PersonalWechatCapabilityService {
|
|||||||
return 'unsupported'
|
return 'unsupported'
|
||||||
}
|
}
|
||||||
if (senderStatus.state === 'error') return 'error'
|
if (senderStatus.state === 'error') return 'error'
|
||||||
|
if (senderStatus.state === 'runtime_missing') return 'unconfigured'
|
||||||
const hasCurrentBinding = Boolean(
|
const hasCurrentBinding = Boolean(
|
||||||
senderStatus.endpointReady &&
|
senderStatus.endpointReady &&
|
||||||
senderStatus.attachReady &&
|
senderStatus.attachReady &&
|
||||||
|
|||||||
@@ -1,603 +0,0 @@
|
|||||||
import { createHash } from 'crypto'
|
|
||||||
import { execFile } from 'child_process'
|
|
||||||
import { app, net } from 'electron'
|
|
||||||
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
|
||||||
import { chmod, copyFile, cp, mkdir, mkdtemp, open, rename, rm, stat } from 'fs/promises'
|
|
||||||
import { tmpdir } from 'os'
|
|
||||||
import { dirname, join } from 'path'
|
|
||||||
import { promisify } from 'util'
|
|
||||||
import type {
|
|
||||||
PersonalWechatRuntimeDownloadResult,
|
|
||||||
PersonalWechatRuntimeStatus
|
|
||||||
} from '../../shared/personal-wechat-runtime'
|
|
||||||
import { findPersonalWechatRuntime } from './personal-wechat-send-service'
|
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile)
|
|
||||||
const RUNTIME_VERSION = 'v0.0.18'
|
|
||||||
const ARCHIVE_NAME = 'onebot_mac_arm64.tar.gz'
|
|
||||||
const ARCHIVE_URL = `https://github.com/yincongcyincong/wechat_chatter/releases/download/${RUNTIME_VERSION}/${ARCHIVE_NAME}`
|
|
||||||
const ARCHIVE_SIZE = 66_599_785
|
|
||||||
const ARCHIVE_SHA256 = 'ee1e11bccef7cec1cf944cd8b2ac3fadaadb9376ba24cd823e3409143e107dab'
|
|
||||||
|
|
||||||
function patchPerSendPayload(scriptPath: string): void {
|
|
||||||
let source = readFileSync(scriptPath, 'utf8')
|
|
||||||
if (!source.includes('var activeTriggerX1Payload = ptr(0);')) return
|
|
||||||
|
|
||||||
const activeSend = ` const payloadData = hexToByteArray(payloadHex);
|
|
||||||
activeTriggerX1Payload = Memory.alloc(payloadData.length);
|
|
||||||
activeTriggerX1Payload.writeByteArray(payloadData);
|
|
||||||
activeTriggerX1Payload.add(0x18).writePointer(info.cgiAddr);
|
|
||||||
activeTriggerX1Payload.add(0xb8).writePointer(activeTriggerX1Payload.add(0xc0));
|
|
||||||
activeTriggerX1Payload.add(0x190).writePointer(activeTriggerX1Payload.add(0x198));`
|
|
||||||
const upstreamSend = ` const payloadData = hexToByteArray(payloadHex);
|
|
||||||
triggerX1Payload.writeByteArray(payloadData);
|
|
||||||
triggerX1Payload.add(0x18).writePointer(info.cgiAddr);
|
|
||||||
triggerX1Payload.add(0xb8).writePointer(triggerX1Payload.add(0xc0));
|
|
||||||
triggerX1Payload.add(0x190).writePointer(triggerX1Payload.add(0x198));`
|
|
||||||
if (!source.includes(activeSend)) {
|
|
||||||
throw new Error('下载的发送组件与当前应用不兼容')
|
|
||||||
}
|
|
||||||
source = source.replace('var triggerX1Payload;\nvar activeTriggerX1Payload = ptr(0);\nvar triggerX0;', 'var triggerX1Payload;\nvar triggerX0;')
|
|
||||||
.replace(activeSend, upstreamSend)
|
|
||||||
.replace(' MMStartTask(triggerX0, activeTriggerX1Payload);', ' MMStartTask(triggerX0, triggerX1Payload);')
|
|
||||||
.replace(' activeTriggerX1Payload = ptr(0);\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);', ' console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);')
|
|
||||||
.replace('\t\t\t\tpendingSendMsgType = "";\n\t\t\t\tactiveTriggerX1Payload = ptr(0);\n\t\t\t\treturn', '\t\t\t\tpendingSendMsgType = "";\n\t\t\t\treturn')
|
|
||||||
writeFileSync(scriptPath, source)
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchSendContextCapture(scriptPath: string, strict = true): void {
|
|
||||||
let source = readFileSync(scriptPath, 'utf8')
|
|
||||||
if (source.includes('function isLikelySendContext(')) return
|
|
||||||
const original = `function AttachSendFunc() {
|
|
||||||
Interceptor.attach(sendFuncAddr.add(0x10), {
|
|
||||||
onEnter: function (args) {
|
|
||||||
|
|
||||||
if (triggerX1Payload) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
triggerX0 = this.context.x0;
|
|
||||||
triggerX1Payload = this.context.x1;
|
|
||||||
console.log(\`[+] 捕获到 StartTask 调用,X0:\${triggerX0}, Payload: \${triggerX1Payload}\`);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}`
|
|
||||||
const patched = `function isLikelySendContext(candidateX0, candidateX1) {
|
|
||||||
try {
|
|
||||||
if (!isReadablePointer(candidateX0) || !isReadablePointer(candidateX1)) return false;
|
|
||||||
var manager = readPointerIfReadable(candidateX0.add(0x18));
|
|
||||||
var cgi = readUtf8StringIfReadable(readPointerIfReadable(candidateX1.add(0x18)));
|
|
||||||
console.log("[debug] StartTask candidate x0=" + candidateX0 + " x1=" + candidateX1 + " x0+0x18=" + manager + " cgi=" + cgi);
|
|
||||||
return !manager.equals(ptr(0));
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[debug] StartTask candidate inspect failed: " + e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function AttachSendFunc() {
|
|
||||||
Interceptor.attach(sendFuncAddr.add(0x10), {
|
|
||||||
onEnter: function (args) {
|
|
||||||
if (triggerX1Payload) return;
|
|
||||||
var candidateX0 = this.context.x0;
|
|
||||||
var candidateX1 = this.context.x1;
|
|
||||||
if (!isLikelySendContext(candidateX0, candidateX1)) return;
|
|
||||||
triggerX0 = candidateX0;
|
|
||||||
triggerX1Payload = candidateX1;
|
|
||||||
console.log(\`[+] 捕获到有效 StartTask 上下文,X0:\${triggerX0}, Payload: \${triggerX1Payload}\`);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}`
|
|
||||||
if (!source.includes(original)) {
|
|
||||||
if (strict) throw new Error('下载的微信版本与当前应用不兼容')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
source = source.replace(original, patched)
|
|
||||||
writeFileSync(scriptPath, source)
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchVoiceAudioBuffer(scriptPath: string, strict = true): void {
|
|
||||||
let source = readFileSync(scriptPath, 'utf8')
|
|
||||||
if (source.includes('voiceAudioDataAddr = Memory.alloc(audioLen + 1);')) return
|
|
||||||
|
|
||||||
const staticAllocation = 'voiceAudioDataAddr = Memory.alloc(5 * 1024 * 1024); // 预分配5MB'
|
|
||||||
if (!source.includes(staticAllocation)) {
|
|
||||||
if (strict) throw new Error('下载的语音组件与当前应用不兼容')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
source = source.replace(
|
|
||||||
staticAllocation,
|
|
||||||
'voiceAudioDataAddr = Memory.alloc(1); // 上传前按语音长度重新分配'
|
|
||||||
)
|
|
||||||
const audioLengthMarker = ' const audioLen = audioBytes.length;\n'
|
|
||||||
if (!source.includes(audioLengthMarker)) {
|
|
||||||
if (strict) throw new Error('下载的语音组件与当前应用不兼容')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
source = source.replace(
|
|
||||||
audioLengthMarker,
|
|
||||||
`${audioLengthMarker} voiceAudioDataAddr = Memory.alloc(audioLen + 1);\n`
|
|
||||||
)
|
|
||||||
writeFileSync(scriptPath, source)
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchImageHookReadiness(scriptPath: string): void {
|
|
||||||
let source = readFileSync(scriptPath, 'utf8')
|
|
||||||
if (
|
|
||||||
source.includes('捕获到图片上传上下文,uploadGlobalX0') &&
|
|
||||||
source.includes('图片上传 Hook Setup Complete')
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const original = `\t\t\tuploadGlobalX0 = this.context.x0;`
|
|
||||||
const patched = `\t\t\tconst capturedUploadX0 = this.context.x0;
|
|
||||||
\t\t\tif (uploadGlobalX0.equals(ptr(0)) && !capturedUploadX0.equals(ptr(0))) {
|
|
||||||
\t\t\t\tconsole.log("[+] 捕获到图片上传上下文,uploadGlobalX0:" + capturedUploadX0);
|
|
||||||
\t\t\t}
|
|
||||||
\t\t\tuploadGlobalX0 = capturedUploadX0;`
|
|
||||||
if (!source.includes(original)) throw new Error('下载的媒体组件与当前应用不兼容')
|
|
||||||
source = source.replace(original, patched)
|
|
||||||
source = source.replace(
|
|
||||||
' })\n}\n\n\n\nfunction patchCdnOnComplete()',
|
|
||||||
' })\n console.log("[+] 图片上传 Hook Setup Complete.");\n}\n\n\n\nfunction patchCdnOnComplete()'
|
|
||||||
)
|
|
||||||
writeFileSync(scriptPath, source)
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchCdnColdStart(scriptPath: string, strict = true): void {
|
|
||||||
let source = readFileSync(scriptPath, 'utf8')
|
|
||||||
if (source.includes('function resolveCdnManager()')) return
|
|
||||||
|
|
||||||
const initAddresses = ` uploadImageAddr = baseAddr.add({{.uploadImageAddr}});
|
|
||||||
cndOnCompleteAddr = baseAddr.add({{.cndOnCompleteAddr}});`
|
|
||||||
const patchedInitAddresses = ` uploadImageAddr = baseAddr.add({{.uploadImageAddr}});
|
|
||||||
cndOnCompleteAddr = baseAddr.add({{.cndOnCompleteAddr}});
|
|
||||||
// 冷启动 CdnManager 解析(旧版本缺少可选键时保持 hook 捕获行为)
|
|
||||||
{{if .cdnGetServiceAddr}}cdnGetServiceAddr = baseAddr.add({{.cdnGetServiceAddr}});{{end}}
|
|
||||||
{{if .cdnManagerGetterAddr}}cdnManagerGetterAddr = baseAddr.add({{.cdnManagerGetterAddr}});{{end}}`
|
|
||||||
if (!source.includes(initAddresses)) {
|
|
||||||
if (strict) throw new Error('下载的微信版本配置与当前应用不兼容')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
source = source.replace(initAddresses, patchedInitAddresses)
|
|
||||||
|
|
||||||
const downloadChunkEnd = `}
|
|
||||||
|
|
||||||
function fillUploadX1AndStart`
|
|
||||||
const resolver = `}
|
|
||||||
|
|
||||||
// 上传和下载共用同一个 mars::cdn::CdnManager。冷启动时通过服务定位器
|
|
||||||
// 取得 [ctx + 0x40],避免必须先手动发送图片才能让 Hook 捕获上下文。
|
|
||||||
function resolveCdnManager() {
|
|
||||||
if (cdnGetServiceAddr.equals(ptr(0)) || cdnManagerGetterAddr.equals(ptr(0))) {
|
|
||||||
return ptr(0);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// libc++ SSO 短字符串:数据在 +0,长度写在 +0x17。
|
|
||||||
var strDefault = Memory.alloc(24);
|
|
||||||
strDefault.writeUtf8String("default");
|
|
||||||
strDefault.add(0x17).writeU8(7);
|
|
||||||
|
|
||||||
var getService = new NativeFunction(cdnGetServiceAddr, 'pointer', ['pointer']);
|
|
||||||
var svc = getService(strDefault);
|
|
||||||
if (!isReadablePointer(svc)) {
|
|
||||||
console.error("[!] GetService(\\"default\\") 返回不可读: " + svc);
|
|
||||||
return ptr(0);
|
|
||||||
}
|
|
||||||
var getCtx = new NativeFunction(cdnManagerGetterAddr, 'pointer', ['pointer']);
|
|
||||||
var ctx = getCtx(svc);
|
|
||||||
if (!isReadablePointer(ctx)) {
|
|
||||||
console.error("[!] CdnManager getter 返回不可读: " + ctx);
|
|
||||||
return ptr(0);
|
|
||||||
}
|
|
||||||
var mgr = readPointerIfReadable(ctx.add(0x40));
|
|
||||||
if (!isReadablePointer(mgr)) {
|
|
||||||
console.error("[!] ctx+0x40 管理器指针不可读: ctx=" + ctx);
|
|
||||||
return ptr(0);
|
|
||||||
}
|
|
||||||
return mgr;
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[!] resolveCdnManager 异常: " + e);
|
|
||||||
return ptr(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureCdnManagerX0() {
|
|
||||||
if (uploadGlobalX0.equals(ptr(0)) && downloadGlobalX0 && !downloadGlobalX0.equals(ptr(0))) {
|
|
||||||
uploadGlobalX0 = downloadGlobalX0;
|
|
||||||
console.log("[+] downloadGlobalX0 回填 uploadGlobalX0: " + uploadGlobalX0);
|
|
||||||
}
|
|
||||||
if ((!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) && !uploadGlobalX0.equals(ptr(0))) {
|
|
||||||
downloadGlobalX0 = uploadGlobalX0;
|
|
||||||
console.log("[+] uploadGlobalX0 回填 downloadGlobalX0: " + downloadGlobalX0);
|
|
||||||
}
|
|
||||||
if (uploadGlobalX0.equals(ptr(0))) {
|
|
||||||
var mgr = resolveCdnManager();
|
|
||||||
if (!mgr.equals(ptr(0))) {
|
|
||||||
uploadGlobalX0 = mgr;
|
|
||||||
if (!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) {
|
|
||||||
downloadGlobalX0 = mgr;
|
|
||||||
}
|
|
||||||
console.log("[+] 冷启动服务定位器解析 CdnManager: " + mgr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return !uploadGlobalX0.equals(ptr(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
function fillUploadX1AndStart`
|
|
||||||
if (!source.includes(downloadChunkEnd)) {
|
|
||||||
if (strict) throw new Error('无法定位 wechat_chatter 媒体上传逻辑')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
source = source.replace(downloadChunkEnd, resolver)
|
|
||||||
|
|
||||||
const declarations = 'var uploadImageAddr;\n'
|
|
||||||
const patchedDeclarations =
|
|
||||||
'var uploadImageAddr;\nvar cdnGetServiceAddr = ptr(0);\nvar cdnManagerGetterAddr = ptr(0);\n'
|
|
||||||
if (!source.includes(declarations)) {
|
|
||||||
if (strict) throw new Error('无法定位 wechat_chatter 媒体地址声明')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
source = source.replace(declarations, patchedDeclarations)
|
|
||||||
|
|
||||||
const uploadGuard = `function fillUploadX1AndStart(idAddr, pathAddr, x1Buffer, receiver, md5, filePath, payloadHex) {
|
|
||||||
if (uploadGlobalX0.equals(ptr(0))) {`
|
|
||||||
const patchedUploadGuard = `function fillUploadX1AndStart(idAddr, pathAddr, x1Buffer, receiver, md5, filePath, payloadHex) {
|
|
||||||
if (uploadGlobalX0.equals(ptr(0))) {
|
|
||||||
ensureCdnManagerX0();
|
|
||||||
}
|
|
||||||
if (uploadGlobalX0.equals(ptr(0))) {`
|
|
||||||
if (!source.includes(uploadGuard)) {
|
|
||||||
if (strict) throw new Error('无法定位 wechat_chatter 媒体上传入口')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
source = source.replace(uploadGuard, patchedUploadGuard)
|
|
||||||
|
|
||||||
const voiceGuard = `function triggerUploadVoice(receiver, voicePath, payloadHex, audioDataHex, durationMs) {
|
|
||||||
if (uploadGlobalX0.equals(ptr(0))) {`
|
|
||||||
const patchedVoiceGuard = `function triggerUploadVoice(receiver, voicePath, payloadHex, audioDataHex, durationMs) {
|
|
||||||
if (uploadGlobalX0.equals(ptr(0))) {
|
|
||||||
ensureCdnManagerX0();
|
|
||||||
}
|
|
||||||
if (uploadGlobalX0.equals(ptr(0))) {`
|
|
||||||
if (!source.includes(voiceGuard)) {
|
|
||||||
if (strict) throw new Error('无法定位 wechat_chatter 语音上传入口')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
source = source.replace(voiceGuard, patchedVoiceGuard)
|
|
||||||
|
|
||||||
const uploadHook = `\t\t\tuploadGlobalX0 = capturedUploadX0;`
|
|
||||||
const patchedUploadHook = `\t\t\tuploadGlobalX0 = capturedUploadX0;
|
|
||||||
if ((!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) && !capturedUploadX0.equals(ptr(0))) {
|
|
||||||
downloadGlobalX0 = capturedUploadX0;
|
|
||||||
console.log("[+] 上传hook回填 downloadGlobalX0: " + downloadGlobalX0);
|
|
||||||
}`
|
|
||||||
if (!source.includes(uploadHook)) {
|
|
||||||
if (strict) throw new Error('无法定位 wechat_chatter 图片 Hook')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
source = source.replace(uploadHook, patchedUploadHook)
|
|
||||||
|
|
||||||
const downloadHook = ` downloadGlobalX0 = this.context.x0;`
|
|
||||||
const patchedDownloadHook = ` downloadGlobalX0 = this.context.x0;
|
|
||||||
if (uploadGlobalX0.equals(ptr(0)) && !downloadGlobalX0.equals(ptr(0))) {
|
|
||||||
uploadGlobalX0 = downloadGlobalX0;
|
|
||||||
console.log("[+] 下载hook回填 uploadGlobalX0: " + uploadGlobalX0);
|
|
||||||
}`
|
|
||||||
if (!source.includes(downloadHook)) {
|
|
||||||
if (strict) throw new Error('无法定位 wechat_chatter 下载 Hook')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
source = source.replace(downloadHook, patchedDownloadHook)
|
|
||||||
|
|
||||||
const downloadGuard = `function triggerDownload(receiver, cdnUrl, aesKey, filePath, fileType) {
|
|
||||||
if (!downloadGlobalX0) {`
|
|
||||||
const patchedDownloadGuard = `function triggerDownload(receiver, cdnUrl, aesKey, filePath, fileType) {
|
|
||||||
if (!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) {
|
|
||||||
ensureCdnManagerX0();
|
|
||||||
}
|
|
||||||
if (!downloadGlobalX0) {`
|
|
||||||
if (!source.includes(downloadGuard)) {
|
|
||||||
if (strict) throw new Error('无法定位 wechat_chatter 媒体下载入口')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
source = source.replace(downloadGuard, patchedDownloadGuard)
|
|
||||||
|
|
||||||
writeFileSync(scriptPath, source)
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchCdnColdStartConfig(configPath: string, strict = true): void {
|
|
||||||
let config: Record<string, unknown>
|
|
||||||
try {
|
|
||||||
config = JSON.parse(readFileSync(configPath, 'utf8')) as Record<string, unknown>
|
|
||||||
} catch {
|
|
||||||
if (strict) throw new Error('4.1.11.53 版本配置不是有效 JSON')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
config.cdnGetServiceAddr = '0x50a15d0'
|
|
||||||
config.cdnManagerGetterAddr = '0x5259290'
|
|
||||||
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchWechatCoreModuleBase(scriptPath: string): void {
|
|
||||||
let source = readFileSync(scriptPath, 'utf8')
|
|
||||||
if (source.includes('WeChat core module base:')) return
|
|
||||||
const initMarker = 'function initAddresses() {'
|
|
||||||
const initIndex = source.indexOf(initMarker)
|
|
||||||
if (initIndex < 0 || !source.startsWith('var targetPath = ')) {
|
|
||||||
throw new Error('下载的微信版本配置与当前应用不兼容')
|
|
||||||
}
|
|
||||||
const patchedHeader = `var targetPath = "/Applications/WeChat.app/Contents/Resources/wechat.dylib";
|
|
||||||
var module = Process.enumerateModules().find(function(m) {
|
|
||||||
return m.path === targetPath || m.path.endsWith("/Contents/Resources/wechat.dylib");
|
|
||||||
});
|
|
||||||
if (!module) {
|
|
||||||
throw new Error("[-] Cannot find WeChat core module: " + targetPath);
|
|
||||||
}
|
|
||||||
var moduleBase = module.base;
|
|
||||||
var baseAddr = moduleBase;
|
|
||||||
console.log("[+] WeChat core module base: " + baseAddr + " path=" + module.path);
|
|
||||||
setImmediate(initAddresses);
|
|
||||||
|
|
||||||
`
|
|
||||||
source = patchedHeader + source.slice(initIndex)
|
|
||||||
writeFileSync(scriptPath, source)
|
|
||||||
}
|
|
||||||
|
|
||||||
function addModifiedWorkNotice(scriptPath: string): void {
|
|
||||||
let source = readFileSync(scriptPath, 'utf8')
|
|
||||||
if (source.includes('TraceMemo wechat_chatter compatibility modifications')) return
|
|
||||||
source = `/*
|
|
||||||
* TraceMemo wechat_chatter compatibility modifications
|
|
||||||
* Modified: 2026-08-17
|
|
||||||
* Upstream: https://github.com/yincongcyincong/wechat_chatter
|
|
||||||
* Runtime version: v0.0.18
|
|
||||||
* License: GNU General Public License version 3 (GPL-3.0)
|
|
||||||
* Changes: WeChat module discovery, per-send payload isolation, dynamic voice upload buffers,
|
|
||||||
* CdnManager cold-start resolution, media hook backfill, and image Hook readiness logging.
|
|
||||||
* These modifications are not provided by the upstream author.
|
|
||||||
*/
|
|
||||||
|
|
||||||
${source}`
|
|
||||||
writeFileSync(scriptPath, source)
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PersonalWechatRuntimeManager {
|
|
||||||
private downloadController: AbortController | null = null
|
|
||||||
private downloadPromise: Promise<PersonalWechatRuntimeDownloadResult> | null = null
|
|
||||||
private downloadedBytes = 0
|
|
||||||
private lastProgressAt = 0
|
|
||||||
private progressListener: ((status: PersonalWechatRuntimeStatus) => void) | null = null
|
|
||||||
|
|
||||||
get directory(): string {
|
|
||||||
return join(app.getPath('userData'), 'connectors', 'wechat-personal', 'darwin-arm64')
|
|
||||||
}
|
|
||||||
|
|
||||||
private get archivePath(): string {
|
|
||||||
return join(
|
|
||||||
app.getPath('userData'),
|
|
||||||
'downloads',
|
|
||||||
`wechat-chatter-${RUNTIME_VERSION}-${ARCHIVE_NAME}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
setProgressListener(listener: ((status: PersonalWechatRuntimeStatus) => void) | null): void {
|
|
||||||
this.progressListener = listener
|
|
||||||
}
|
|
||||||
|
|
||||||
async getStatus(): Promise<PersonalWechatRuntimeStatus> {
|
|
||||||
if (!this.isSupported()) {
|
|
||||||
return this.buildStatus(
|
|
||||||
'unsupported',
|
|
||||||
0,
|
|
||||||
process.platform === 'darwin'
|
|
||||||
? 'Intel Mac 不支持个人微信发送组件'
|
|
||||||
: process.platform === 'win32'
|
|
||||||
? 'Windows 暂不支持个人微信发送组件'
|
|
||||||
: '当前系统不支持个人微信发送组件'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (this.downloadPromise) return this.buildStatus('downloading', this.downloadedBytes)
|
|
||||||
|
|
||||||
const runtime = findPersonalWechatRuntime()
|
|
||||||
if (runtime) {
|
|
||||||
try {
|
|
||||||
// Apply compatibility fixes to runtimes installed before this version.
|
|
||||||
patchWechatCoreModuleBase(join(runtime.workingDirectory, 'script.js'))
|
|
||||||
patchPerSendPayload(join(runtime.workingDirectory, 'script.js'))
|
|
||||||
patchSendContextCapture(join(runtime.workingDirectory, 'script.js'), false)
|
|
||||||
patchVoiceAudioBuffer(join(runtime.workingDirectory, 'script.js'), false)
|
|
||||||
patchImageHookReadiness(join(runtime.workingDirectory, 'script.js'))
|
|
||||||
patchCdnColdStart(join(runtime.workingDirectory, 'script.js'), false)
|
|
||||||
addModifiedWorkNotice(join(runtime.workingDirectory, 'script.js'))
|
|
||||||
patchCdnColdStartConfig(join(runtime.root, 'wechat_version', '4_1_11_53_mac.json'), false)
|
|
||||||
} catch {
|
|
||||||
// Status discovery should remain available even if an old runtime is read-only.
|
|
||||||
}
|
|
||||||
return this.buildStatus('ready', ARCHIVE_SIZE, undefined, runtime.root)
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasPartialInstall = await this.hasPartialInstall()
|
|
||||||
return this.buildStatus(
|
|
||||||
hasPartialInstall ? 'invalid' : 'missing',
|
|
||||||
0,
|
|
||||||
hasPartialInstall ? '发送组件文件不完整,请重新下载' : undefined,
|
|
||||||
hasPartialInstall ? this.directory : undefined
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
download(): Promise<PersonalWechatRuntimeDownloadResult> {
|
|
||||||
if (!this.isSupported()) {
|
|
||||||
return this.getStatus().then((status) => ({ success: false, status, error: status.error }))
|
|
||||||
}
|
|
||||||
if (this.downloadPromise) return this.downloadPromise
|
|
||||||
this.downloadedBytes = 0
|
|
||||||
this.downloadController = new AbortController()
|
|
||||||
this.downloadPromise = this.runDownload(this.downloadController.signal).finally(() => {
|
|
||||||
this.downloadPromise = null
|
|
||||||
this.downloadController = null
|
|
||||||
})
|
|
||||||
return this.downloadPromise
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelDownload(): boolean {
|
|
||||||
if (!this.downloadController) return false
|
|
||||||
this.downloadController.abort()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
async remove(): Promise<PersonalWechatRuntimeStatus> {
|
|
||||||
if (this.downloadPromise) return this.buildStatus('downloading', this.downloadedBytes)
|
|
||||||
await Promise.all([
|
|
||||||
rm(this.directory, { recursive: true, force: true }),
|
|
||||||
rm(this.archivePath, { force: true }),
|
|
||||||
rm(`${this.archivePath}.partial`, { force: true })
|
|
||||||
])
|
|
||||||
return this.getStatus()
|
|
||||||
}
|
|
||||||
|
|
||||||
private async runDownload(signal: AbortSignal): Promise<PersonalWechatRuntimeDownloadResult> {
|
|
||||||
const archive = this.archivePath
|
|
||||||
const downloadsDirectory = dirname(archive)
|
|
||||||
const partial = `${archive}.partial`
|
|
||||||
let extractionDirectory = ''
|
|
||||||
let stagedDirectory = ''
|
|
||||||
try {
|
|
||||||
await mkdir(downloadsDirectory, { recursive: true })
|
|
||||||
await rm(partial, { force: true })
|
|
||||||
const response = await net.fetch(ARCHIVE_URL, { signal })
|
|
||||||
if (!response.ok || !response.body) {
|
|
||||||
throw new Error(`发送组件下载失败:HTTP ${response.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handle = await open(partial, 'w')
|
|
||||||
const hash = createHash('sha256')
|
|
||||||
try {
|
|
||||||
const reader = response.body.getReader()
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read()
|
|
||||||
if (done) break
|
|
||||||
if (signal.aborted) throw new DOMException('Download cancelled', 'AbortError')
|
|
||||||
const chunk = Buffer.from(value)
|
|
||||||
await handle.write(chunk)
|
|
||||||
hash.update(chunk)
|
|
||||||
this.downloadedBytes += chunk.length
|
|
||||||
this.reportProgress(this.buildStatus('downloading', this.downloadedBytes))
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await handle.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.downloadedBytes !== ARCHIVE_SIZE || hash.digest('hex') !== ARCHIVE_SHA256) {
|
|
||||||
throw new Error('发送组件校验失败,请重新下载')
|
|
||||||
}
|
|
||||||
await rm(archive, { force: true })
|
|
||||||
await rename(partial, archive)
|
|
||||||
|
|
||||||
extractionDirectory = await mkdtemp(join(tmpdir(), 'wechat-chatter-extract-'))
|
|
||||||
await execFileAsync('/usr/bin/tar', ['-xzf', archive, '-C', extractionDirectory])
|
|
||||||
stagedDirectory = `${this.directory}.installing-${process.pid}`
|
|
||||||
await rm(stagedDirectory, { recursive: true, force: true })
|
|
||||||
await mkdir(dirname(stagedDirectory), { recursive: true })
|
|
||||||
await mkdir(join(stagedDirectory, 'onebot'), { recursive: true })
|
|
||||||
|
|
||||||
const sourceOneBot = join(extractionDirectory, 'onebot')
|
|
||||||
const sourceVersions = join(extractionDirectory, 'wechat_version')
|
|
||||||
await Promise.all([
|
|
||||||
cp(sourceVersions, join(stagedDirectory, 'wechat_version'), {
|
|
||||||
recursive: true,
|
|
||||||
force: true
|
|
||||||
}),
|
|
||||||
copyFile(join(sourceOneBot, 'onebot'), join(stagedDirectory, 'onebot', 'onebot')),
|
|
||||||
copyFile(join(sourceOneBot, 'script.js'), join(stagedDirectory, 'onebot', 'script.js'))
|
|
||||||
])
|
|
||||||
|
|
||||||
const executable = join(stagedDirectory, 'onebot', 'onebot')
|
|
||||||
const script = join(stagedDirectory, 'onebot', 'script.js')
|
|
||||||
const configPath = join(stagedDirectory, 'wechat_version', '4_1_11_53_mac.json')
|
|
||||||
patchCdnColdStartConfig(configPath)
|
|
||||||
patchWechatCoreModuleBase(script)
|
|
||||||
patchPerSendPayload(script)
|
|
||||||
patchSendContextCapture(script)
|
|
||||||
patchVoiceAudioBuffer(script)
|
|
||||||
patchImageHookReadiness(script)
|
|
||||||
patchCdnColdStart(script)
|
|
||||||
addModifiedWorkNotice(script)
|
|
||||||
await chmod(executable, 0o755)
|
|
||||||
|
|
||||||
for (const required of [
|
|
||||||
executable,
|
|
||||||
script,
|
|
||||||
join(stagedDirectory, 'wechat_version', '4_1_11_53_mac.json')
|
|
||||||
]) {
|
|
||||||
if (!existsSync(required)) throw new Error('发送组件解压后文件不完整')
|
|
||||||
}
|
|
||||||
|
|
||||||
await rm(this.directory, { recursive: true, force: true })
|
|
||||||
await rename(stagedDirectory, this.directory)
|
|
||||||
stagedDirectory = ''
|
|
||||||
const status = this.buildStatus('ready', ARCHIVE_SIZE, undefined, this.directory)
|
|
||||||
this.reportProgress(status, true)
|
|
||||||
return { success: true, status }
|
|
||||||
} catch (error) {
|
|
||||||
const cancelled = signal.aborted
|
|
||||||
const message = cancelled
|
|
||||||
? '发送组件下载已取消'
|
|
||||||
: error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: String(error)
|
|
||||||
await rm(partial, { force: true })
|
|
||||||
const status = this.buildStatus(
|
|
||||||
cancelled ? 'missing' : 'error',
|
|
||||||
this.downloadedBytes,
|
|
||||||
message
|
|
||||||
)
|
|
||||||
this.reportProgress(status, true)
|
|
||||||
return { success: false, status, error: message }
|
|
||||||
} finally {
|
|
||||||
if (extractionDirectory) await rm(extractionDirectory, { recursive: true, force: true })
|
|
||||||
if (stagedDirectory) await rm(stagedDirectory, { recursive: true, force: true })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async hasPartialInstall(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await stat(this.directory)
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private isSupported(): boolean {
|
|
||||||
return process.platform === 'darwin' && process.arch === 'arm64'
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildStatus(
|
|
||||||
state: PersonalWechatRuntimeStatus['state'],
|
|
||||||
downloadedBytes: number,
|
|
||||||
error?: string,
|
|
||||||
directory?: string
|
|
||||||
): PersonalWechatRuntimeStatus {
|
|
||||||
return {
|
|
||||||
version: RUNTIME_VERSION,
|
|
||||||
state,
|
|
||||||
downloadedBytes,
|
|
||||||
totalBytes: ARCHIVE_SIZE,
|
|
||||||
progress: ARCHIVE_SIZE ? Math.min(1, downloadedBytes / ARCHIVE_SIZE) : 0,
|
|
||||||
platform: process.platform,
|
|
||||||
architecture: process.arch,
|
|
||||||
supported: this.isSupported(),
|
|
||||||
removable: directory === this.directory,
|
|
||||||
...(directory ? { directory } : {}),
|
|
||||||
...(error ? { error } : {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private reportProgress(status: PersonalWechatRuntimeStatus, force = false): void {
|
|
||||||
const now = Date.now()
|
|
||||||
if (!force && now - this.lastProgressAt < 100) return
|
|
||||||
this.lastProgressAt = now
|
|
||||||
this.progressListener?.(status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -7,11 +7,8 @@ import type {
|
|||||||
PersonalWechatVoiceRuntimeComponent
|
PersonalWechatVoiceRuntimeComponent
|
||||||
} from '../../shared/personal-wechat-voice-runtime'
|
} from '../../shared/personal-wechat-voice-runtime'
|
||||||
import { PERSONAL_WECHAT_PILK_VERSION } from '../../shared/personal-wechat-voice-runtime'
|
import { PERSONAL_WECHAT_PILK_VERSION } from '../../shared/personal-wechat-voice-runtime'
|
||||||
import {
|
import { buildPersonalWechatRuntimeEnvironment } from './personal-wechat-send-service'
|
||||||
buildPersonalWechatRuntimeEnvironment,
|
import { macWechatRuntimeManager } from './mac-wechat-runtime-manager'
|
||||||
findPersonalWechatRuntime
|
|
||||||
} from './personal-wechat-send-service'
|
|
||||||
import type { RuntimeLayout } from './personal-wechat-send-service'
|
|
||||||
import { appLogger } from '../app-logger'
|
import { appLogger } from '../app-logger'
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile)
|
const execFileAsync = promisify(execFile)
|
||||||
@@ -37,8 +34,8 @@ type CommandRunner = (
|
|||||||
interface PersonalWechatVoiceEnvironmentServiceOptions {
|
interface PersonalWechatVoiceEnvironmentServiceOptions {
|
||||||
platform?: NodeJS.Platform
|
platform?: NodeJS.Platform
|
||||||
architecture?: string
|
architecture?: string
|
||||||
findRuntime?: () => RuntimeLayout | null
|
isRuntimePresent?: () => boolean
|
||||||
buildEnvironment?: (runtimeRoot?: string) => NodeJS.ProcessEnv
|
buildEnvironment?: () => NodeJS.ProcessEnv
|
||||||
runCommand?: CommandRunner
|
runCommand?: CommandRunner
|
||||||
now?: () => Date
|
now?: () => Date
|
||||||
}
|
}
|
||||||
@@ -100,18 +97,26 @@ function unsupportedEnvironment(): PersonalWechatVoiceEncodingEnvironment {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function defaultIsRuntimePresent(): boolean {
|
||||||
|
try {
|
||||||
|
return macWechatRuntimeManager.isRuntimePresent()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class PersonalWechatVoiceEnvironmentService {
|
export class PersonalWechatVoiceEnvironmentService {
|
||||||
private readonly platform: NodeJS.Platform
|
private readonly platform: NodeJS.Platform
|
||||||
private readonly architecture: string
|
private readonly architecture: string
|
||||||
private readonly findRuntime: () => RuntimeLayout | null
|
private readonly isRuntimePresent: () => boolean
|
||||||
private readonly buildEnvironment: (runtimeRoot?: string) => NodeJS.ProcessEnv
|
private readonly buildEnvironment: () => NodeJS.ProcessEnv
|
||||||
private readonly runCommand: CommandRunner
|
private readonly runCommand: CommandRunner
|
||||||
private readonly now: () => Date
|
private readonly now: () => Date
|
||||||
|
|
||||||
constructor(options: PersonalWechatVoiceEnvironmentServiceOptions = {}) {
|
constructor(options: PersonalWechatVoiceEnvironmentServiceOptions = {}) {
|
||||||
this.platform = options.platform || process.platform
|
this.platform = options.platform || process.platform
|
||||||
this.architecture = options.architecture || process.arch
|
this.architecture = options.architecture || process.arch
|
||||||
this.findRuntime = options.findRuntime || findPersonalWechatRuntime
|
this.isRuntimePresent = options.isRuntimePresent || defaultIsRuntimePresent
|
||||||
this.buildEnvironment = options.buildEnvironment || buildPersonalWechatRuntimeEnvironment
|
this.buildEnvironment = options.buildEnvironment || buildPersonalWechatRuntimeEnvironment
|
||||||
this.runCommand = options.runCommand || runCommand
|
this.runCommand = options.runCommand || runCommand
|
||||||
this.now = options.now || (() => new Date())
|
this.now = options.now || (() => new Date())
|
||||||
@@ -123,8 +128,8 @@ export class PersonalWechatVoiceEnvironmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logEnvironmentLine('Checking voice encoding environment')
|
logEnvironmentLine('Checking voice encoding environment')
|
||||||
const runtime = this.findRuntime()
|
const runtimeReady = this.isRuntimePresent()
|
||||||
const environment = this.buildEnvironment(runtime?.root)
|
const environment = this.buildEnvironment()
|
||||||
const python = blankComponent()
|
const python = blankComponent()
|
||||||
const pilk = blankComponent()
|
const pilk = blankComponent()
|
||||||
const ffmpeg = blankComponent()
|
const ffmpeg = blankComponent()
|
||||||
@@ -197,26 +202,25 @@ export class PersonalWechatVoiceEnvironmentService {
|
|||||||
logEnvironmentWarning('ffmpeg: unavailable', { error: ffmpeg.error })
|
logEnvironmentWarning('ffmpeg: unavailable', { error: ffmpeg.error })
|
||||||
}
|
}
|
||||||
|
|
||||||
const ready = Boolean(runtime && python.ready && pilk.ready && ffmpeg.ready)
|
const ready = Boolean(runtimeReady && python.ready && pilk.ready && ffmpeg.ready)
|
||||||
const result: PersonalWechatVoiceEncodingEnvironment = {
|
const result: PersonalWechatVoiceEncodingEnvironment = {
|
||||||
state: ready ? 'ready' : 'incomplete',
|
state: ready ? 'ready' : 'incomplete',
|
||||||
ready,
|
ready,
|
||||||
checkedAt: this.now().toISOString(),
|
checkedAt: this.now().toISOString(),
|
||||||
runtimeReady: Boolean(runtime),
|
runtimeReady,
|
||||||
...(runtime ? { runtimeRoot: runtime.root } : {}),
|
|
||||||
python,
|
python,
|
||||||
pilk,
|
pilk,
|
||||||
ffmpeg,
|
ffmpeg,
|
||||||
encoder: pilk.ready
|
encoder: pilk.ready
|
||||||
? 'pilk'
|
? 'pilk'
|
||||||
: runtime && python.ready && ffmpeg.ready
|
: runtimeReady && python.ready && ffmpeg.ready
|
||||||
? 'go-silk'
|
? 'silk'
|
||||||
: 'unavailable',
|
: 'unavailable',
|
||||||
message: ready
|
message: ready
|
||||||
? '语音编码环境正常,可以使用 pilk 编码'
|
? '语音编码环境正常,可以使用 pilk 编码'
|
||||||
: runtime
|
: runtimeReady
|
||||||
? '语音编码环境不完整,OneBot 可能回退到 go-silk'
|
? '语音编码环境不完整,语音将回退到内置 SILK 编码'
|
||||||
: '微信发送组件尚未安装,请先准备 OneBot 运行时'
|
: '当前版本暂未提供微信消息发送功能'
|
||||||
}
|
}
|
||||||
logEnvironmentLine(
|
logEnvironmentLine(
|
||||||
ready ? 'Voice encoding environment is ready' : 'Voice encoding environment is NOT ready',
|
ready ? 'Voice encoding environment is ready' : 'Voice encoding environment is NOT ready',
|
||||||
@@ -245,7 +249,7 @@ export class PersonalWechatVoiceEnvironmentService {
|
|||||||
pythonExecutable,
|
pythonExecutable,
|
||||||
['-m', 'pip', 'install', '--user', `pilk==${PERSONAL_WECHAT_PILK_VERSION}`],
|
['-m', 'pip', 'install', '--user', `pilk==${PERSONAL_WECHAT_PILK_VERSION}`],
|
||||||
{
|
{
|
||||||
env: this.buildEnvironment(before.runtimeRoot || undefined),
|
env: this.buildEnvironment(),
|
||||||
timeout: INSTALL_TIMEOUT_MS
|
timeout: INSTALL_TIMEOUT_MS
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,8 +27,7 @@ export interface ScheduledReportApiDependencies {
|
|||||||
| 'deleteTask'
|
| 'deleteTask'
|
||||||
| 'setTaskEnabled'
|
| 'setTaskEnabled'
|
||||||
| 'runScheduledReportNow'
|
| 'runScheduledReportNow'
|
||||||
> &
|
>
|
||||||
Partial<Pick<ScheduledReportService, 'retryScheduledReportSend'>>
|
|
||||||
getCapability: () => Promise<PersonalWechatSendCapability>
|
getCapability: () => Promise<PersonalWechatSendCapability>
|
||||||
listContacts: () => FormattedContact[]
|
listContacts: () => FormattedContact[]
|
||||||
isDatabaseReady: () => boolean
|
isDatabaseReady: () => boolean
|
||||||
@@ -250,16 +249,20 @@ export class ScheduledReportApiService {
|
|||||||
return result.data
|
return result.data
|
||||||
}
|
}
|
||||||
|
|
||||||
async retrySend(executionId: string): Promise<ScheduledReportExecution> {
|
/**
|
||||||
const retry = this.deps.service.retryScheduledReportSend
|
* 「重新发送」不再支持。
|
||||||
if (!retry) {
|
*
|
||||||
throw new ScheduledReportApiError(501, 'not_supported', '当前运行时不支持重新发送日报')
|
* 日报图片归日报历史所有,发送目标由规则配置决定,「把某次执行记录里那张 PNG
|
||||||
}
|
* 就地再发一遍」这个语义已经不存在 —— 与其在此伪造一个只对部分规则有效的重发
|
||||||
const result = await retry.call(this.deps.service, executionId)
|
* 语义,不如**明确返回 501**。
|
||||||
if (!result.data) {
|
* 用户的替代路径:在日报历史里手动转发,或对该规则点「立即执行」重跑一次。
|
||||||
throw new ScheduledReportApiError(404, 'not_found', result.error || '未找到定时日报执行记录')
|
*/
|
||||||
}
|
async retrySend(_executionId: string): Promise<ScheduledReportExecution> {
|
||||||
return result.data
|
throw new ScheduledReportApiError(
|
||||||
|
501,
|
||||||
|
'not_supported',
|
||||||
|
'当前运行时不支持重新发送日报;请在 TraceMemo 的自动化页面使用「立即执行」,或从日报历史手动转发。'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async executions(taskId: string): Promise<ScheduledReportApiExecution[]> {
|
async executions(taskId: string): Promise<ScheduledReportApiExecution[]> {
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import {
|
||||||
|
normalizeScheduledReportConfig,
|
||||||
|
type ScheduledReportAutomationConfig
|
||||||
|
} from '../../shared/automation'
|
||||||
|
import type {
|
||||||
|
ScheduledReportMemberNameMode,
|
||||||
|
ScheduledReportMessageType,
|
||||||
|
ScheduledReportRange
|
||||||
|
} from '../../shared/scheduled-report'
|
||||||
|
import type { SelectableReportTemplateId } from '../../shared/report-templates'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 旧「定时日报任务」→ 新「AutomationRule(ruleType='scheduled_report')」的**纯映射器**。
|
||||||
|
*
|
||||||
|
* 单独成文件是为了能直接单测:这是整轮迁移里**唯一会改变用户既有行为**的地方,
|
||||||
|
* 不允许只靠"跑一遍看看"来验证。
|
||||||
|
*
|
||||||
|
* ## 旧 target 的真实语义(实测)
|
||||||
|
*
|
||||||
|
* `ScheduledReportService.resolveTarget()`:
|
||||||
|
* - `target` 以 `@chatroom` 结尾 ⇒ **直接发到那个群**(可能是另一个群);
|
||||||
|
* - 否则 ⇒ **静默回落到来源群**。
|
||||||
|
*
|
||||||
|
* ⇒ 历史行为**事实上支持「生成 A 群日报 → 发 B 群」**,也存在一处静默 fallback。
|
||||||
|
* 新产品明确不支持"指定另一个群聊",所以:
|
||||||
|
* **能证明同群 → 无损映射成 `source_chat`;不能证明 → 停用 + 要求用户重选**,
|
||||||
|
* 绝不自动改写、绝不偷偷丢掉目标。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 旧任务里迁移真正用得到的字段(其余字段不参与,也不需要读)。 */
|
||||||
|
export interface LegacyScheduledReportTask {
|
||||||
|
id?: unknown
|
||||||
|
name?: unknown
|
||||||
|
group?: unknown
|
||||||
|
scheduleTime?: unknown
|
||||||
|
reportRange?: unknown
|
||||||
|
messageTypes?: unknown
|
||||||
|
templateId?: unknown
|
||||||
|
memberNameMode?: unknown
|
||||||
|
timeoutSeconds?: unknown
|
||||||
|
target?: unknown
|
||||||
|
enabled?: unknown
|
||||||
|
createdAt?: unknown
|
||||||
|
updatedAt?: unknown
|
||||||
|
lastRunAt?: unknown
|
||||||
|
lastScheduledSlot?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScheduledReportMigrationOutcome =
|
||||||
|
/** 目标与来源群是同一群 ⇒ 等价于 `source_chat`,行为完全一致。 */
|
||||||
|
| 'lossless_source_chat'
|
||||||
|
/** 目标是另一个群 / 无法确认是否同群 ⇒ 单值四选一表达不了,需用户重选。 */
|
||||||
|
| 'needs_review'
|
||||||
|
|
||||||
|
export interface ScheduledReportMigrationPlan {
|
||||||
|
outcome: ScheduledReportMigrationOutcome
|
||||||
|
/** 迁移后的规则 id(确定性,可重复执行且不产生新 id)。 */
|
||||||
|
ruleId: string
|
||||||
|
name: string
|
||||||
|
enabled: boolean
|
||||||
|
config: ScheduledReportAutomationConfig
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把任意输入收敛成字符串。 */
|
||||||
|
function asText(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : String(value ?? '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确定性 ruleId。
|
||||||
|
*
|
||||||
|
* 优先**原样复用旧 taskId**(旧 id 形如 `scheduled_report_<uuid>`,与自动化现有
|
||||||
|
* id 空间 —— `randomUUID()` / `builtin-*` —— 不可能冲突)。
|
||||||
|
* 旧 id 缺失时(文件被手改坏),退化成一个**由稳定字段派生**的 id,
|
||||||
|
* 保证"重复迁移不会生成新规则"。
|
||||||
|
*/
|
||||||
|
export function scheduledReportMigrationRuleId(task: LegacyScheduledReportTask): string {
|
||||||
|
const id = asText(task.id)
|
||||||
|
if (id) return id
|
||||||
|
const digest = createHash('sha1')
|
||||||
|
.update(
|
||||||
|
[asText(task.name), asText(task.group), asText(task.scheduleTime), asText(task.target)].join(
|
||||||
|
'\u0001'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.digest('hex')
|
||||||
|
return `scheduled-report:${digest.slice(0, 32)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 旧 `createdAt` / `updatedAt` 是 ISO 字符串;非法时回落到迁移时刻。 */
|
||||||
|
function parseIso(value: unknown, fallback: number): number {
|
||||||
|
const text = asText(value)
|
||||||
|
if (!text) return fallback
|
||||||
|
const parsed = Date.parse(text)
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单条旧任务 → 迁移计划。
|
||||||
|
*
|
||||||
|
* `resolveConversationId` 由调用方注入(main 侧是 `chat-service.resolveMd5`,
|
||||||
|
* 单测里是假的联系人表)。**它拿不到就说明主进程还没准备好**,
|
||||||
|
* 调用方必须**推迟整批迁移**,而不是用"解析不到"去误判成 needs_review。
|
||||||
|
*/
|
||||||
|
export function planScheduledReportMigration(
|
||||||
|
task: LegacyScheduledReportTask,
|
||||||
|
resolveConversationId: (raw: string) => string | undefined,
|
||||||
|
now: number
|
||||||
|
): ScheduledReportMigrationPlan {
|
||||||
|
const sourceRaw = asText(task.group)
|
||||||
|
const targetRaw = asText(task.target)
|
||||||
|
const sourceResolved = resolveConversationId(sourceRaw)
|
||||||
|
const targetResolved = resolveConversationId(targetRaw)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 三种"能证明是同一个群"的情况:
|
||||||
|
* 1. target 为空 —— 旧 `normalizeInput` 本就把空 target 填成 group;
|
||||||
|
* 2. 两者文本完全相等 —— 无论能否解析,用户表达的就是同一个东西;
|
||||||
|
* 3. 两者都能解析且解析结果相同 —— 旧 UI 的常态(group=会话 md5,target=roomId)。
|
||||||
|
*/
|
||||||
|
const sameGroup =
|
||||||
|
!targetRaw || targetRaw === sourceRaw || Boolean(sourceResolved && targetResolved && sourceResolved === targetResolved)
|
||||||
|
|
||||||
|
const config = normalizeScheduledReportConfig({
|
||||||
|
schedule: { time: asText(task.scheduleTime) },
|
||||||
|
report: {
|
||||||
|
// 解析得到就用稳定会话 id;解析不到**保留原值** —— 运行期会如实失败,
|
||||||
|
// 绝不在这里"猜一个群"。
|
||||||
|
sourceConversationId: sourceResolved ?? sourceRaw,
|
||||||
|
range: asText(task.reportRange) as ScheduledReportRange,
|
||||||
|
messageTypes: task.messageTypes as ScheduledReportMessageType[],
|
||||||
|
templateId: asText(task.templateId) as SelectableReportTemplateId,
|
||||||
|
memberNameMode: asText(task.memberNameMode) as ScheduledReportMemberNameMode,
|
||||||
|
timeoutSeconds: Number(task.timeoutSeconds)
|
||||||
|
},
|
||||||
|
target: { type: 'source_chat' },
|
||||||
|
...(sameGroup ? {} : { targetNeedsReview: true }),
|
||||||
|
// 旧目标原文只作展示;`sameGroup` 时不保留,避免留下看起来像"还生效"的残值。
|
||||||
|
...(sameGroup || !targetRaw ? {} : { legacyTarget: targetRaw }),
|
||||||
|
// 来源群被改写过(md5/群名 → roomId)时留下原文,便于用户核对。
|
||||||
|
...(sourceRaw && sourceResolved && sourceRaw !== sourceResolved
|
||||||
|
? { legacySourceGroup: sourceRaw }
|
||||||
|
: {}),
|
||||||
|
...(asText(task.lastRunAt) ? { lastRunAt: asText(task.lastRunAt) } : {}),
|
||||||
|
...(asText(task.lastScheduledSlot) ? { lastScheduledSlot: asText(task.lastScheduledSlot) } : {})
|
||||||
|
})
|
||||||
|
|
||||||
|
const name = asText(task.name) || '定时日报'
|
||||||
|
return {
|
||||||
|
outcome: sameGroup ? 'lossless_source_chat' : 'needs_review',
|
||||||
|
ruleId: scheduledReportMigrationRuleId(task),
|
||||||
|
name,
|
||||||
|
// 不自动改写、也不自动启用:无法确认目标的规则一律停用。
|
||||||
|
enabled: sameGroup ? task.enabled !== false : false,
|
||||||
|
config,
|
||||||
|
createdAt: parseIso(task.createdAt, now),
|
||||||
|
updatedAt: parseIso(task.updatedAt, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduledReportMigrationSummary {
|
||||||
|
total: number
|
||||||
|
migrated: number
|
||||||
|
lossless: number
|
||||||
|
needsReview: number
|
||||||
|
duplicatesSkipped: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整批任务的迁移结果:**按 id 去重**,保证幂等(同一份旧文件跑两次结果一致)。
|
||||||
|
*/
|
||||||
|
export function planScheduledReportMigrationBatch(
|
||||||
|
tasks: LegacyScheduledReportTask[],
|
||||||
|
resolveConversationId: (raw: string) => string | undefined,
|
||||||
|
now: number
|
||||||
|
): { plans: ScheduledReportMigrationPlan[]; summary: ScheduledReportMigrationSummary } {
|
||||||
|
const plans: ScheduledReportMigrationPlan[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
let duplicatesSkipped = 0
|
||||||
|
let lossless = 0
|
||||||
|
let needsReview = 0
|
||||||
|
for (const task of tasks) {
|
||||||
|
if (!task || typeof task !== 'object') continue
|
||||||
|
const plan = planScheduledReportMigration(task, resolveConversationId, now)
|
||||||
|
if (seen.has(plan.ruleId)) {
|
||||||
|
duplicatesSkipped += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen.add(plan.ruleId)
|
||||||
|
plans.push(plan)
|
||||||
|
if (plan.outcome === 'lossless_source_chat') lossless += 1
|
||||||
|
else needsReview += 1
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
plans,
|
||||||
|
summary: {
|
||||||
|
total: plans.length + duplicatesSkipped,
|
||||||
|
migrated: plans.length,
|
||||||
|
lossless,
|
||||||
|
needsReview,
|
||||||
|
duplicatesSkipped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
|||||||
|
import {
|
||||||
|
SCHEDULED_REPORT_TARGET_OPTIONS,
|
||||||
|
scheduledReportTargetLabel,
|
||||||
|
type ScheduledReportAutomationConfig
|
||||||
|
} from '../../shared/automation'
|
||||||
|
import {
|
||||||
|
resolveAutomationTarget,
|
||||||
|
resolveGroupConversationId,
|
||||||
|
resolveGroupDisplayName,
|
||||||
|
type AutomationTargetContact,
|
||||||
|
type AutomationTargetResolution
|
||||||
|
} from './automation-wechat-target'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时日报的**目标解析**(薄适配层)。
|
||||||
|
*
|
||||||
|
* 判定逻辑在 `automation-wechat-target.ts`,与退群通知**共用同一份**。
|
||||||
|
* 差异只有一处:来源会话不是"事件所在群",而是**这条规则配置的日报来源群**。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 定时日报的目标文案(唯一一份)。 */
|
||||||
|
export const SCHEDULED_REPORT_TARGET_MESSAGES = {
|
||||||
|
options: SCHEDULED_REPORT_TARGET_OPTIONS,
|
||||||
|
invalidTarget: '定时日报的发送目标无效,请重新选择',
|
||||||
|
sourceMissing: '无法确定日报来源群,本次日报未发送',
|
||||||
|
sourceRecipientName: '日报来源群',
|
||||||
|
sourceDisplayName: '日报来源群',
|
||||||
|
selfMissing: '无法确定当前登录的微信账号,本次日报未发送',
|
||||||
|
contactNotChosen: '还没有选择发送联系人,请重新选择',
|
||||||
|
contactUnavailable: '发送联系人已不存在或当前无法发送,请重新选择'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export function resolveScheduledReportTarget(input: {
|
||||||
|
config: ScheduledReportAutomationConfig | undefined
|
||||||
|
contacts: AutomationTargetContact[]
|
||||||
|
/** 当前登录账号的 wxid;拿不到时传空串。 */
|
||||||
|
selfWxid?: string
|
||||||
|
}): AutomationTargetResolution {
|
||||||
|
const config = input.config
|
||||||
|
if (!config) return { ok: false, error: '定时日报尚未配置发送目标' }
|
||||||
|
const sourceConversationId = String(config.report?.sourceConversationId || '').trim()
|
||||||
|
return resolveAutomationTarget(
|
||||||
|
{
|
||||||
|
targetType: config.target?.type as never,
|
||||||
|
sourceConversationId,
|
||||||
|
// 来源群的可读名从通讯录解析;解析不到时由中性层给兜底称呼。
|
||||||
|
sourceDisplayName: resolveGroupDisplayName(sourceConversationId, input.contacts),
|
||||||
|
...(config.target?.contactId ? { contactId: config.target.contactId } : {}),
|
||||||
|
contacts: input.contacts,
|
||||||
|
...(input.selfWxid ? { selfWxid: input.selfWxid } : {})
|
||||||
|
},
|
||||||
|
SCHEDULED_REPORT_TARGET_MESSAGES
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日报来源群的可读显示名(执行日志的 `sourceDisplayName` 用它)。
|
||||||
|
*
|
||||||
|
* **绝不回落到裸 id**:解析不到就说「日报来源群」。
|
||||||
|
*/
|
||||||
|
export function scheduledReportSourceDisplayName(
|
||||||
|
config: ScheduledReportAutomationConfig | undefined,
|
||||||
|
contacts: AutomationTargetContact[]
|
||||||
|
): string {
|
||||||
|
const raw = String(config?.report?.sourceConversationId || '').trim()
|
||||||
|
if (!raw) return '日报来源群'
|
||||||
|
return resolveGroupDisplayName(raw, contacts) || '日报来源群'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 日报来源群解析成稳定会话 id(执行前预检用;解析不到返回 undefined)。 */
|
||||||
|
export function scheduledReportSourceConversationId(
|
||||||
|
config: ScheduledReportAutomationConfig | undefined,
|
||||||
|
contacts: AutomationTargetContact[]
|
||||||
|
): string | undefined {
|
||||||
|
return resolveGroupConversationId(
|
||||||
|
String(config?.report?.sourceConversationId || ''),
|
||||||
|
contacts
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { scheduledReportTargetLabel }
|
||||||
@@ -39,9 +39,8 @@ export interface AppSettings {
|
|||||||
showStartupProgress: boolean
|
showStartupProgress: boolean
|
||||||
ttsSelectedVoiceId: string
|
ttsSelectedVoiceId: string
|
||||||
ttsModel: TextToSpeechModel
|
ttsModel: TextToSpeechModel
|
||||||
/** Keep a running personal-WeChat OneBot process across app restarts. */
|
|
||||||
keepPersonalWechatProcess?: boolean
|
|
||||||
windowsWechatPort: string
|
windowsWechatPort: string
|
||||||
|
reportImagePostfixText: string
|
||||||
/**
|
/**
|
||||||
* Query Agent 是否为桌面「问问微信」与 Agent Hub 查询类问题的主路径。
|
* Query Agent 是否为桌面「问问微信」与 Agent Hub 查询类问题的主路径。
|
||||||
* 默认开启;关闭后回退到 Legacy AI Search Pipeline(仅作 runtime regression 时的回退开关,
|
* 默认开启;关闭后回退到 Legacy AI Search Pipeline(仅作 runtime regression 时的回退开关,
|
||||||
@@ -130,8 +129,8 @@ const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
showStartupProgress: true,
|
showStartupProgress: true,
|
||||||
ttsSelectedVoiceId: '',
|
ttsSelectedVoiceId: '',
|
||||||
ttsModel: 's2.1-pro-free',
|
ttsModel: 's2.1-pro-free',
|
||||||
keepPersonalWechatProcess: false,
|
|
||||||
windowsWechatPort: '',
|
windowsWechatPort: '',
|
||||||
|
reportImagePostfixText: '今日日报',
|
||||||
queryAgentEnabled: true
|
queryAgentEnabled: true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +149,10 @@ export function loadSettings(): AppSettings {
|
|||||||
if (cache) return cache
|
if (cache) return cache
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(SETTINGS_FILE)) {
|
if (fs.existsSync(SETTINGS_FILE)) {
|
||||||
const raw = fs.readJsonSync(SETTINGS_FILE) as Partial<AppSettings>
|
const stored = fs.readJsonSync(SETTINGS_FILE) as Partial<AppSettings> & {
|
||||||
|
keepPersonalWechatProcess?: unknown
|
||||||
|
}
|
||||||
|
const { keepPersonalWechatProcess: retiredKeepProcess, ...raw } = stored
|
||||||
cache = { ...DEFAULT_SETTINGS, ...raw }
|
cache = { ...DEFAULT_SETTINGS, ...raw }
|
||||||
if (raw.autoLogin === undefined) {
|
if (raw.autoLogin === undefined) {
|
||||||
const hasSavedDatabaseKey = fs.existsSync(
|
const hasSavedDatabaseKey = fs.existsSync(
|
||||||
@@ -176,7 +178,7 @@ export function loadSettings(): AppSettings {
|
|||||||
}
|
}
|
||||||
// 防撤回已下线(设置入口已隐藏):历史版本可能把它持久化为 true。
|
// 防撤回已下线(设置入口已隐藏):历史版本可能把它持久化为 true。
|
||||||
// 这里强制收敛为 false 并回写磁盘,确保旧的撤回监听与撤回日志不会继续运行。
|
// 这里强制收敛为 false 并回写磁盘,确保旧的撤回监听与撤回日志不会继续运行。
|
||||||
if (cache.recallProtectionEnabled) {
|
if (cache.recallProtectionEnabled || retiredKeepProcess !== undefined) {
|
||||||
cache.recallProtectionEnabled = false
|
cache.recallProtectionEnabled = false
|
||||||
saveSettings(cache)
|
saveSettings(cache)
|
||||||
}
|
}
|
||||||
@@ -192,7 +194,11 @@ export function loadSettings(): AppSettings {
|
|||||||
export function saveSettings(next: AppSettings): AppSettings {
|
export function saveSettings(next: AppSettings): AppSettings {
|
||||||
// 防撤回已下线:所有写入路径(含 settings:set 补丁)统一收敛为 false,
|
// 防撤回已下线:所有写入路径(含 settings:set 补丁)统一收敛为 false,
|
||||||
// 避免遗留入口或旧版本把它重新打开。
|
// 避免遗留入口或旧版本把它重新打开。
|
||||||
cache = { ...next, recallProtectionEnabled: false }
|
const sanitized = { ...next } as AppSettings & {
|
||||||
|
keepPersonalWechatProcess?: unknown
|
||||||
|
}
|
||||||
|
delete sanitized.keepPersonalWechatProcess
|
||||||
|
cache = { ...sanitized, recallProtectionEnabled: false }
|
||||||
try {
|
try {
|
||||||
ensureDir()
|
ensureDir()
|
||||||
fs.writeJsonSync(SETTINGS_FILE, cache, { spaces: 2 })
|
fs.writeJsonSync(SETTINGS_FILE, cache, { spaces: 2 })
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ import path from 'path'
|
|||||||
import type {
|
import type {
|
||||||
PersonalWechatSendCapability,
|
PersonalWechatSendCapability,
|
||||||
PersonalWechatSendRequest,
|
PersonalWechatSendRequest,
|
||||||
PersonalWechatSendResult
|
PersonalWechatSendResult,
|
||||||
|
PersonalWechatSenderStatus
|
||||||
} from '../../shared/personal-wechat'
|
} from '../../shared/personal-wechat'
|
||||||
import type {
|
import type {
|
||||||
PolicyDecision,
|
PolicyDecision,
|
||||||
WechatActionAuditRecord,
|
WechatActionAuditRecord,
|
||||||
WechatActionContent,
|
WechatActionContent,
|
||||||
WechatActionErrorCode,
|
WechatActionErrorCode,
|
||||||
WechatActionMemberEventReference,
|
|
||||||
WechatActionRequest,
|
WechatActionRequest,
|
||||||
WechatActionResult
|
WechatActionResult
|
||||||
} from '../../shared/wechat-action'
|
} from '../../shared/wechat-action'
|
||||||
@@ -22,17 +22,40 @@ import { wechatSendGateway } from './wechat-send-gateway'
|
|||||||
const MAX_AUDIT_RECORDS = 500
|
const MAX_AUDIT_RECORDS = 500
|
||||||
const MAX_CONTENT_PREVIEW_LENGTH = 240
|
const MAX_CONTENT_PREVIEW_LENGTH = 240
|
||||||
export const AUTOMATION_SEND_INTERVAL_MS = 3_000
|
export const AUTOMATION_SEND_INTERVAL_MS = 3_000
|
||||||
const AUTOMATION_PURPOSE_ALLOWLIST = new Set(['scheduled_report', 'member_left_notification'])
|
const AUTOMATION_PURPOSE_ALLOWLIST = new Set([
|
||||||
|
'scheduled_report',
|
||||||
|
// Automation(@我生成日报)。必须与 `src/shared/automation.ts` 的
|
||||||
|
// `AUTOMATION_SEND_PURPOSE` 保持一致,否则会被下面 evaluateWechatActionPolicy
|
||||||
|
// 以 ACTION_NOT_ALLOWED 拦下 —— 那是有意的闸门,不是 bug。
|
||||||
|
'automation_reply',
|
||||||
|
'automation_report',
|
||||||
|
// 退群通知(由 Automation 发出)。目标可以是群 / 自己 / 文件传输助手 / 指定好友,
|
||||||
|
// 所以**不绑定**任何 "只能发回原群" 的作用域锁。
|
||||||
|
'automation_leave_notification',
|
||||||
|
// 定时日报(由 Automation 的 `scheduled_report` 规则发出)。目标是四选一,
|
||||||
|
// 同样**不绑定**任何作用域锁。
|
||||||
|
'automation_scheduled_report',
|
||||||
|
// 定时日报的「后置词」(图片 sent 之后补发的那条文本)。与图片是两个独立
|
||||||
|
// purpose,各自有幂等位 —— 少这一条会被 evaluateWechatActionPolicy 拦下。
|
||||||
|
'automation_scheduled_report_postfix',
|
||||||
|
'manual_report_image',
|
||||||
|
'manual_report_postfix'
|
||||||
|
])
|
||||||
|
|
||||||
|
export interface ReportImageSequenceRequest {
|
||||||
|
recipient: WechatActionRequest['recipient']
|
||||||
|
imagePath: string
|
||||||
|
postfixText: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportImageSequenceResult {
|
||||||
|
image: WechatActionResult
|
||||||
|
postfix?: WechatActionResult
|
||||||
|
}
|
||||||
|
|
||||||
export interface WechatActionGatewayDependencies {
|
export interface WechatActionGatewayDependencies {
|
||||||
getCapability?: () => Promise<PersonalWechatSendCapability>
|
getCapability?: () => Promise<PersonalWechatSendCapability>
|
||||||
send?: (request: PersonalWechatSendRequest) => Promise<PersonalWechatSendResult>
|
send?: (request: PersonalWechatSendRequest) => Promise<PersonalWechatSendResult>
|
||||||
getMemberEvent?: (
|
|
||||||
sourceId: string
|
|
||||||
) =>
|
|
||||||
| WechatActionMemberEventReference
|
|
||||||
| Promise<WechatActionMemberEventReference | undefined>
|
|
||||||
| undefined
|
|
||||||
getUserDataPath?: () => string
|
getUserDataPath?: () => string
|
||||||
now?: () => Date
|
now?: () => Date
|
||||||
wait?: (milliseconds: number) => Promise<void>
|
wait?: (milliseconds: number) => Promise<void>
|
||||||
@@ -43,10 +66,6 @@ interface LoadedAuditState {
|
|||||||
records: WechatActionAuditRecord[]
|
records: WechatActionAuditRecord[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WechatActionPolicyContext {
|
|
||||||
memberEvent?: WechatActionMemberEventReference
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultDependencies = (): Required<
|
const defaultDependencies = (): Required<
|
||||||
Pick<
|
Pick<
|
||||||
WechatActionGatewayDependencies,
|
WechatActionGatewayDependencies,
|
||||||
@@ -76,7 +95,6 @@ export class WechatActionGateway {
|
|||||||
WechatActionGatewayDependencies,
|
WechatActionGatewayDependencies,
|
||||||
'getCapability' | 'send' | 'getUserDataPath' | 'now' | 'wait'
|
'getCapability' | 'send' | 'getUserDataPath' | 'now' | 'wait'
|
||||||
>
|
>
|
||||||
private readonly memberEvents = new Map<string, WechatActionMemberEventReference>()
|
|
||||||
private readonly inFlight = new Map<string, Promise<WechatActionResult>>()
|
private readonly inFlight = new Map<string, Promise<WechatActionResult>>()
|
||||||
private auditState: LoadedAuditState | null = null
|
private auditState: LoadedAuditState | null = null
|
||||||
private automationSendTail: Promise<void> = Promise.resolve()
|
private automationSendTail: Promise<void> = Promise.resolve()
|
||||||
@@ -86,20 +104,33 @@ export class WechatActionGateway {
|
|||||||
this.deps = { ...defaultDependencies(), ...deps }
|
this.deps = { ...defaultDependencies(), ...deps }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 记录退群事件,发送通知前可确认事件所属群聊。 */
|
/**
|
||||||
registerMemberEvent(event: WechatActionMemberEventReference): void {
|
* 用户确认后的日报发送序列。两步都进入 automation 发送队列,从而复用既有 3 秒间隔;
|
||||||
const id = String(event?.id || '').trim()
|
* 后置词 action 只在图片明确 sent 后创建,图片失败/blocked 时严格短路。
|
||||||
const roomId = String(event?.roomId || '').trim()
|
*/
|
||||||
if (!id || !roomId) return
|
async executeReportImageSequence(
|
||||||
this.memberEvents.set(id, { id, roomId })
|
request: ReportImageSequenceRequest
|
||||||
}
|
): Promise<ReportImageSequenceResult> {
|
||||||
|
const image = await this.execute({
|
||||||
|
origin: 'user_manual',
|
||||||
|
purpose: 'manual_report_image',
|
||||||
|
triggerType: 'automation',
|
||||||
|
recipient: request.recipient,
|
||||||
|
content: { type: 'image', path: String(request.imagePath || '') }
|
||||||
|
})
|
||||||
|
if (image.status !== 'sent') return { image }
|
||||||
|
|
||||||
registerMemberEvents(events: WechatActionMemberEventReference[]): void {
|
const postfixText = String(request.postfixText || '').trim()
|
||||||
for (const event of events) this.registerMemberEvent(event)
|
if (!postfixText) return { image }
|
||||||
}
|
|
||||||
|
|
||||||
clearMemberEvents(): void {
|
const postfix = await this.execute({
|
||||||
this.memberEvents.clear()
|
origin: 'user_manual',
|
||||||
|
purpose: 'manual_report_postfix',
|
||||||
|
triggerType: 'automation',
|
||||||
|
recipient: request.recipient,
|
||||||
|
content: { type: 'text', text: postfixText }
|
||||||
|
})
|
||||||
|
return { image, postfix }
|
||||||
}
|
}
|
||||||
|
|
||||||
listAuditRecords(): WechatActionAuditRecord[] {
|
listAuditRecords(): WechatActionAuditRecord[] {
|
||||||
@@ -145,8 +176,7 @@ export class WechatActionGateway {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventContext = await this.resolveEventContext(request)
|
const policy = evaluateWechatActionPolicy(request)
|
||||||
const policy = evaluateWechatActionPolicy(request, eventContext)
|
|
||||||
if (policy.decision !== 'allow') {
|
if (policy.decision !== 'allow') {
|
||||||
return this.finishBlocked(
|
return this.finishBlocked(
|
||||||
actionId,
|
actionId,
|
||||||
@@ -240,28 +270,6 @@ export class WechatActionGateway {
|
|||||||
return queued
|
return queued
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveEventContext(
|
|
||||||
request: WechatActionRequest
|
|
||||||
): Promise<WechatActionPolicyContext> {
|
|
||||||
if (request.purpose !== 'member_left_notification') return {}
|
|
||||||
const sourceId = String(request.sourceId || '').trim()
|
|
||||||
if (!sourceId) return {}
|
|
||||||
let memberEvent: WechatActionMemberEventReference | undefined = this.memberEvents.get(sourceId)
|
|
||||||
if (!memberEvent && this.deps.getMemberEvent) {
|
|
||||||
let resolved: WechatActionMemberEventReference | undefined
|
|
||||||
try {
|
|
||||||
resolved = await this.deps.getMemberEvent(sourceId)
|
|
||||||
} catch {
|
|
||||||
resolved = undefined
|
|
||||||
}
|
|
||||||
if (resolved && String(resolved.id || '').trim() === sourceId) {
|
|
||||||
memberEvent = { id: String(resolved.id), roomId: String(resolved.roomId) }
|
|
||||||
this.registerMemberEvent(memberEvent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return memberEvent ? { memberEvent } : {}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async findExisting(idempotencyKey: string): Promise<WechatActionResult | undefined> {
|
private async findExisting(idempotencyKey: string): Promise<WechatActionResult | undefined> {
|
||||||
const state = this.loadAuditState()
|
const state = this.loadAuditState()
|
||||||
const existing = state.records.find((record) => record.idempotencyKey === idempotencyKey)
|
const existing = state.records.find((record) => record.idempotencyKey === idempotencyKey)
|
||||||
@@ -280,9 +288,6 @@ export class WechatActionGateway {
|
|||||||
private idempotencyKey(request: WechatActionRequest): string | undefined {
|
private idempotencyKey(request: WechatActionRequest): string | undefined {
|
||||||
const explicit = String(request.idempotencyKey || '').trim()
|
const explicit = String(request.idempotencyKey || '').trim()
|
||||||
if (explicit) return explicit
|
if (explicit) return explicit
|
||||||
if (request.purpose === 'member_left_notification' && request.sourceId) {
|
|
||||||
return `member_left_notification:${request.sourceId}`
|
|
||||||
}
|
|
||||||
if (request.origin === 'scheduled_report' && request.executionId) {
|
if (request.origin === 'scheduled_report' && request.executionId) {
|
||||||
return `scheduled_report:${request.executionId}`
|
return `scheduled_report:${request.executionId}`
|
||||||
}
|
}
|
||||||
@@ -429,10 +434,7 @@ export class WechatActionGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function evaluateWechatActionPolicy(
|
export function evaluateWechatActionPolicy(request: WechatActionRequest): PolicyDecision {
|
||||||
request: WechatActionRequest,
|
|
||||||
context: WechatActionPolicyContext = {}
|
|
||||||
): PolicyDecision {
|
|
||||||
if (!request || typeof request !== 'object') {
|
if (!request || typeof request !== 'object') {
|
||||||
return {
|
return {
|
||||||
decision: 'block',
|
decision: 'block',
|
||||||
@@ -463,29 +465,6 @@ export function evaluateWechatActionPolicy(
|
|||||||
reason: `自动化动作不允许执行 purpose=${request.purpose}`
|
reason: `自动化动作不允许执行 purpose=${request.purpose}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (request.purpose === 'member_left_notification') {
|
|
||||||
if (
|
|
||||||
!request.sourceId ||
|
|
||||||
!context.memberEvent ||
|
|
||||||
context.memberEvent.id !== request.sourceId ||
|
|
||||||
!context.memberEvent.roomId
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
decision: 'block',
|
|
||||||
source: 'deterministic',
|
|
||||||
reasonCode: 'INVALID_REQUEST',
|
|
||||||
reason: '退群通知必须关联已记录的退群事件'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (request.recipient.type !== 'group' || request.recipient.id !== context.memberEvent.roomId) {
|
|
||||||
return {
|
|
||||||
decision: 'block',
|
|
||||||
source: 'deterministic',
|
|
||||||
reasonCode: 'RECIPIENT_SCOPE_VIOLATION',
|
|
||||||
reason: '退群通知只能发送回原事件所在群聊'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { decision: 'allow', source: 'deterministic' }
|
return { decision: 'allow', source: 'deterministic' }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,6 +579,32 @@ function toPersonalWechatSendRequest(request: WechatActionRequest): PersonalWech
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把网关结果还原成既有调用方期望的 `PersonalWechatSendResult`。
|
||||||
|
*
|
||||||
|
* 为什么需要:手动发送的 IPC(`wechat-personal:send`)返回契约是
|
||||||
|
* `PersonalWechatSendResult`,界面上靠 `response.success` / `response.error` 判读。
|
||||||
|
* 改成走网关之后必须把这个契约**原样**还回去,否则「统一发送路径」会把 UI 一起改坏。
|
||||||
|
*
|
||||||
|
* 网关成功时会把底层 `sendResult` 原样挂在 `action.sendResult` 上,优先用它
|
||||||
|
* (它带着真实的 `status`);拿不到时按 `action.status` 合成一个。
|
||||||
|
*/
|
||||||
|
export function toPersonalWechatSendResult(
|
||||||
|
action: WechatActionResult,
|
||||||
|
fallbackStatus: PersonalWechatSenderStatus
|
||||||
|
): PersonalWechatSendResult {
|
||||||
|
const raw = action.sendResult
|
||||||
|
if (raw && typeof raw === 'object' && 'success' in (raw as Record<string, unknown>)) {
|
||||||
|
return raw as PersonalWechatSendResult
|
||||||
|
}
|
||||||
|
if (action.status === 'sent') return { success: true, status: fallbackStatus }
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
status: fallbackStatus,
|
||||||
|
error: action.reason || action.errorCode || '微信发送失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function contentAudit(
|
function contentAudit(
|
||||||
content: WechatActionContent
|
content: WechatActionContent
|
||||||
): Pick<WechatActionAuditRecord, 'contentPreview' | 'contentHash'> {
|
): Pick<WechatActionAuditRecord, 'contentPreview' | 'contentHash'> {
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ export interface VoiceSilkMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate the exact PCM contract consumed by the bundled OneBot encoder.
|
* Validate the exact PCM contract consumed by the bundled SILK encoder.
|
||||||
* The Go Silk encoder emits a header-only payload for sub-frame input, which
|
* The encoder emits a header-only payload for sub-frame input, which would
|
||||||
* is accepted by the old path but produces an unplayable WeChat voice.
|
* produce an unplayable WeChat voice.
|
||||||
*/
|
*/
|
||||||
export function validateVoicePcm(
|
export function validateVoicePcm(
|
||||||
pcm: Uint8Array,
|
pcm: Uint8Array,
|
||||||
|
|||||||
@@ -384,6 +384,79 @@ export function resolveWindowsNativeAccountRoot(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* native monitor 事件(pipe / socket 上的一行 JSON)。
|
||||||
|
*
|
||||||
|
* ## 两个协议版本
|
||||||
|
*
|
||||||
|
* **v1(历史)** —— 变更信号来自**文件系统通知**(Windows `ReadDirectoryChangesW` /
|
||||||
|
* macOS `kqueue`),native 只知道「哪个文件被写了」:
|
||||||
|
*
|
||||||
|
* ```json
|
||||||
|
* {"db":"message_0.db","table":"message","action":"update"}
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* 它**不携带会话**,所以上层只能回读「最近活跃会话」。`protocol` 会标成 1。
|
||||||
|
*
|
||||||
|
* **v2(Native Monitor Event v2)** —— native 侧在收到文件变化后做一次
|
||||||
|
* SessionTable watermark diff,把**每一个真正变化的会话**各发一条:
|
||||||
|
*
|
||||||
|
* ```json
|
||||||
|
* {"version":2,"kind":"message_change","session_id":"xxx@chatroom",
|
||||||
|
* "db":"message_0.db","table":"message","action":"update","observed_at_ms":1}
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* `protocol = 2` 且带 `sessionId` 时,上层可以**直接按会话回读**,不再依赖
|
||||||
|
* `getSessions()[0]`。
|
||||||
|
*
|
||||||
|
* **降级规则**:只要 `version`/`kind`/`session_id` 任一不符合 v2 契约,
|
||||||
|
* 就按 v1 处理(`protocol = 1`)—— 宁可不精确,也不能拿一个不可信的会话 id 去读。
|
||||||
|
*/
|
||||||
|
export interface Wcdb4MonitorEvent {
|
||||||
|
/** pipe 上的原始一行(原样转发给需要它的消费者,例如退群监控)。 */
|
||||||
|
raw: string
|
||||||
|
/** 语义化的动作类型。当事件语义出现时用它,比如 `'user_change'`。 */
|
||||||
|
type: string
|
||||||
|
action: string
|
||||||
|
/** `2` = 精确会话事件;`1` = legacy(不含会话)。 */
|
||||||
|
protocol: 1 | 2
|
||||||
|
/** 粗表分类:`database` / `message` / `Session` / `contact`。 */
|
||||||
|
table: string
|
||||||
|
/** `protocol === 2` 时才有:发生变化的会话(`xxx@chatroom` 或 wxid)。 */
|
||||||
|
sessionId?: string
|
||||||
|
/** native 侧观测时刻(epoch ms),仅用于诊断。 */
|
||||||
|
observedAtMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 pipe 上的一行 payload 解析成事件。纯函数,便于单测。 */
|
||||||
|
export function parseMonitorEvent(rawPayload: string): Wcdb4MonitorEvent {
|
||||||
|
const raw = rawPayload.trim()
|
||||||
|
let parsed: Record<string, unknown> = {}
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(raw) as unknown
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
parsed = value as Record<string, unknown>
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 不是 JSON:当作 legacy 事件处理,保留原始 payload。
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = typeof parsed.action === 'string' && parsed.action ? parsed.action : 'update'
|
||||||
|
const sessionId = typeof parsed.session_id === 'string' ? parsed.session_id.trim() : ''
|
||||||
|
// 三个条件缺一不可:版本、语义、以及**非空**的会话 id。
|
||||||
|
const precise = parsed.version === 2 && parsed.kind === 'message_change' && Boolean(sessionId)
|
||||||
|
|
||||||
|
return {
|
||||||
|
raw,
|
||||||
|
type: action,
|
||||||
|
action,
|
||||||
|
protocol: precise ? 2 : 1,
|
||||||
|
table: typeof parsed.table === 'string' ? parsed.table : '',
|
||||||
|
...(precise ? { sessionId } : {}),
|
||||||
|
...(typeof parsed.observed_at_ms === 'number' ? { observedAtMs: parsed.observed_at_ms } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class Wcdb4Client {
|
export class Wcdb4Client {
|
||||||
static readonly defaultRoot = Wcdb4Client.findExistingDefaultRoot()
|
static readonly defaultRoot = Wcdb4Client.findExistingDefaultRoot()
|
||||||
|
|
||||||
@@ -495,7 +568,7 @@ export class Wcdb4Client {
|
|||||||
private wcdbStopMonitorPipe: (() => void) | null = null
|
private wcdbStopMonitorPipe: (() => void) | null = null
|
||||||
private wcdbGetMonitorPipeName: ((outName: WcdbVoidOut) => number) | null = null
|
private wcdbGetMonitorPipeName: ((outName: WcdbVoidOut) => number) | null = null
|
||||||
private monitorPipeClient: Socket | null = null
|
private monitorPipeClient: Socket | null = null
|
||||||
private monitorCallback: ((type: string, json: string) => void) | null = null
|
private monitorCallback: ((event: Wcdb4MonitorEvent) => void) | null = null
|
||||||
private monitorConnectTimer: ReturnType<typeof setTimeout> | null = null
|
private monitorConnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
private monitorReconnectTimer: ReturnType<typeof setTimeout> | null = null
|
private monitorReconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
private monitorPipePath = ''
|
private monitorPipePath = ''
|
||||||
@@ -773,7 +846,7 @@ export class Wcdb4Client {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async startMonitor(callback: (type: string, json: string) => void): Promise<boolean> {
|
async startMonitor(callback: (event: Wcdb4MonitorEvent) => void): Promise<boolean> {
|
||||||
if (this.closing || !this.wcdbStartMonitorPipe || !this.wcdbGetMonitorPipeName || !this.koffi) {
|
if (this.closing || !this.wcdbStartMonitorPipe || !this.wcdbGetMonitorPipeName || !this.koffi) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -911,13 +984,7 @@ export class Wcdb4Client {
|
|||||||
private emitMonitorPayload(rawPayload: string): void {
|
private emitMonitorPayload(rawPayload: string): void {
|
||||||
const payload = rawPayload.trim()
|
const payload = rawPayload.trim()
|
||||||
if (!payload || !this.monitorCallback) return
|
if (!payload || !this.monitorCallback) return
|
||||||
|
this.monitorCallback(parseMonitorEvent(payload))
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(payload) as { action?: string }
|
|
||||||
this.monitorCallback(parsed.action || 'update', payload)
|
|
||||||
} catch {
|
|
||||||
this.monitorCallback('update', payload)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleMonitorReconnect(): void {
|
private scheduleMonitorReconnect(): void {
|
||||||
|
|||||||
Vendored
+52
-50
@@ -82,26 +82,28 @@ import type {
|
|||||||
PersonalWechatVoiceDiagnostic
|
PersonalWechatVoiceDiagnostic
|
||||||
} from '../shared/personal-wechat'
|
} from '../shared/personal-wechat'
|
||||||
import type { PersonalWechatSendCapability } from '../shared/personal-wechat'
|
import type { PersonalWechatSendCapability } from '../shared/personal-wechat'
|
||||||
|
import type { GroupMemberStatsQuery, GroupMemberStatsResult } from '../shared/group-stats'
|
||||||
import type {
|
import type {
|
||||||
ScheduledReportCreateInput,
|
|
||||||
ScheduledReportExecution,
|
ScheduledReportExecution,
|
||||||
|
ScheduledReportNotification,
|
||||||
|
ScheduledReportNotificationCapability,
|
||||||
ScheduledReportNotificationSettings,
|
ScheduledReportNotificationSettings,
|
||||||
ScheduledReportNotificationSettingsResult,
|
ScheduledReportNotificationSettingsResult,
|
||||||
ScheduledReportResult,
|
ScheduledReportResult
|
||||||
ScheduledReportTask,
|
|
||||||
ScheduledReportUpdateInput
|
|
||||||
} from '../shared/scheduled-report'
|
} from '../shared/scheduled-report'
|
||||||
import type {
|
|
||||||
PersonalWechatRuntimeDownloadResult,
|
|
||||||
PersonalWechatRuntimeProgressEvent,
|
|
||||||
PersonalWechatRuntimeStatus
|
|
||||||
} from '../shared/personal-wechat-runtime'
|
|
||||||
import type {
|
import type {
|
||||||
PersonalWechatVoiceEncodingEnvironment,
|
PersonalWechatVoiceEncodingEnvironment,
|
||||||
PersonalWechatVoiceEncodingEnvironmentResult
|
PersonalWechatVoiceEncodingEnvironmentResult
|
||||||
} from '../shared/personal-wechat-voice-runtime'
|
} from '../shared/personal-wechat-voice-runtime'
|
||||||
import type { AppLogEntry } from '../shared/app-log'
|
import type { AppLogEntry } from '../shared/app-log'
|
||||||
import type { GroupExitMonitorState } from '../shared/group-exit-monitor'
|
import type { GroupExitMonitorEvent, GroupExitMonitorState } from '../shared/group-exit-monitor'
|
||||||
|
import type {
|
||||||
|
AutomationExecution,
|
||||||
|
AutomationRule,
|
||||||
|
AutomationRuleDraft,
|
||||||
|
AutomationStatusSummary,
|
||||||
|
ScheduledRuleRunOutcome
|
||||||
|
} from '../shared/automation'
|
||||||
import type { ActionLogEntry } from '../shared/action-log'
|
import type { ActionLogEntry } from '../shared/action-log'
|
||||||
import type {
|
import type {
|
||||||
AppUpdateCheckResult,
|
AppUpdateCheckResult,
|
||||||
@@ -293,18 +295,37 @@ declare global {
|
|||||||
avatar: string
|
avatar: string
|
||||||
}[]
|
}[]
|
||||||
} | null>
|
} | null>
|
||||||
|
getGroupMemberStats: (request: GroupMemberStatsQuery) => Promise<GroupMemberStatsResult>
|
||||||
getGroupExitMonitorState: () => Promise<GroupExitMonitorState>
|
getGroupExitMonitorState: () => Promise<GroupExitMonitorState>
|
||||||
|
listGroupExitMonitorEvents: (query?: {
|
||||||
|
roomId?: string
|
||||||
|
sinceMs?: number
|
||||||
|
untilMs?: number
|
||||||
|
limit?: number
|
||||||
|
}) => Promise<GroupExitMonitorEvent[]>
|
||||||
setGroupExitMonitorEnabled: (enabled: boolean) => Promise<GroupExitMonitorState>
|
setGroupExitMonitorEnabled: (enabled: boolean) => Promise<GroupExitMonitorState>
|
||||||
setGroupExitMonitorGroups: (
|
/** 保存**监控范围**。通知配置已迁到「自动化 → 退群通知」。 */
|
||||||
roomIds: string[],
|
setGroupExitMonitorGroups: (roomIds: string[]) => Promise<GroupExitMonitorState>
|
||||||
notificationRoomIds?: string[]
|
|
||||||
) => Promise<GroupExitMonitorState>
|
|
||||||
setGroupExitMonitorNotificationTemplate: (template: string) => Promise<GroupExitMonitorState>
|
|
||||||
checkGroupExitMonitorNow: () => Promise<GroupExitMonitorState>
|
checkGroupExitMonitorNow: () => Promise<GroupExitMonitorState>
|
||||||
clearGroupExitMonitorEvents: () => Promise<GroupExitMonitorState>
|
clearGroupExitMonitorEvents: () => Promise<GroupExitMonitorState>
|
||||||
resendGroupExitMonitorEvent: (eventId: string) => Promise<GroupExitMonitorState>
|
|
||||||
markGroupExitMonitorRead: (readAt?: number) => Promise<GroupExitMonitorState>
|
markGroupExitMonitorRead: (readAt?: number) => Promise<GroupExitMonitorState>
|
||||||
listWechatActionLogs: () => Promise<ActionLogEntry[]>
|
listWechatActionLogs: () => Promise<ActionLogEntry[]>
|
||||||
|
getAutomationStatus: () => Promise<AutomationStatusSummary>
|
||||||
|
listAutomationRules: () => Promise<AutomationRule[]>
|
||||||
|
createAutomationRule: (draft: AutomationRuleDraft) => Promise<AutomationRule>
|
||||||
|
updateAutomationRule: (
|
||||||
|
id: string,
|
||||||
|
draft: AutomationRuleDraft
|
||||||
|
) => Promise<AutomationRule | null>
|
||||||
|
deleteAutomationRule: (id: string) => Promise<boolean>
|
||||||
|
setAutomationRuleEnabled: (id: string, enabled: boolean) => Promise<AutomationRule | null>
|
||||||
|
listAutomationExecutions: (query?: { limit?: number }) => Promise<AutomationExecution[]>
|
||||||
|
clearAutomationExecutions: () => Promise<boolean>
|
||||||
|
listAutomationGroups: () => Promise<Array<{ id: string; name: string }>>
|
||||||
|
/** 保存「退群通知」规则(singleton upsert)。 */
|
||||||
|
saveLeaveNotificationRule: (draft: AutomationRuleDraft) => Promise<AutomationRule>
|
||||||
|
/** 「指定好友」的可选项;已在 main 侧过滤掉群聊 / 公众号 / 文件传输助手 / 自己。 */
|
||||||
|
listSendableContacts: () => Promise<Array<{ id: string; name: string }>>
|
||||||
onGroupExitMonitorState: (callback: (state: GroupExitMonitorState) => void) => () => void
|
onGroupExitMonitorState: (callback: (state: GroupExitMonitorState) => void) => () => void
|
||||||
search: (keyword: string) => Promise<string | null>
|
search: (keyword: string) => Promise<string | null>
|
||||||
searchKnowledge: (request: KnowledgeSearchIpcRequest) => Promise<KnowledgeSearchIpcResult>
|
searchKnowledge: (request: KnowledgeSearchIpcRequest) => Promise<KnowledgeSearchIpcResult>
|
||||||
@@ -521,6 +542,7 @@ declare global {
|
|||||||
ttsSelectedVoiceId: string
|
ttsSelectedVoiceId: string
|
||||||
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
||||||
windowsWechatPort: string
|
windowsWechatPort: string
|
||||||
|
reportImagePostfixText: string
|
||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
}
|
}
|
||||||
@@ -561,6 +583,7 @@ declare global {
|
|||||||
ttsSelectedVoiceId: string
|
ttsSelectedVoiceId: string
|
||||||
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
||||||
windowsWechatPort: string
|
windowsWechatPort: string
|
||||||
|
reportImagePostfixText: string
|
||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
}
|
}
|
||||||
@@ -584,6 +607,7 @@ declare global {
|
|||||||
ttsSelectedVoiceId: string
|
ttsSelectedVoiceId: string
|
||||||
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
||||||
windowsWechatPort: string
|
windowsWechatPort: string
|
||||||
|
reportImagePostfixText: string
|
||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
}>
|
}>
|
||||||
@@ -697,21 +721,11 @@ declare global {
|
|||||||
onImageTextIndexStatus: (callback: (status: ImageTextIndexStatus) => void) => () => void
|
onImageTextIndexStatus: (callback: (status: ImageTextIndexStatus) => void) => () => void
|
||||||
getPersonalWechatSenderStatus: () => Promise<PersonalWechatSenderStatus>
|
getPersonalWechatSenderStatus: () => Promise<PersonalWechatSenderStatus>
|
||||||
getPersonalWechatSendCapability: () => Promise<PersonalWechatSendCapability>
|
getPersonalWechatSendCapability: () => Promise<PersonalWechatSendCapability>
|
||||||
getPersonalWechatKeepOneBotProcess: () => Promise<boolean>
|
|
||||||
setPersonalWechatKeepOneBotProcess: (keep: boolean) => Promise<boolean>
|
|
||||||
checkPersonalWechatSenderStatus: (port?: string) => Promise<PersonalWechatSenderStatus>
|
checkPersonalWechatSenderStatus: (port?: string) => Promise<PersonalWechatSenderStatus>
|
||||||
checkPersonalWechatVoiceEncodingEnvironment: () => Promise<PersonalWechatVoiceEncodingEnvironment>
|
checkPersonalWechatVoiceEncodingEnvironment: () => Promise<PersonalWechatVoiceEncodingEnvironment>
|
||||||
installPersonalWechatPilk: () => Promise<PersonalWechatVoiceEncodingEnvironmentResult>
|
installPersonalWechatPilk: () => Promise<PersonalWechatVoiceEncodingEnvironmentResult>
|
||||||
openPersonalWechatVoicePythonDownload: () => Promise<{ success: boolean; error?: string }>
|
openPersonalWechatVoicePythonDownload: () => Promise<{ success: boolean; error?: string }>
|
||||||
openPersonalWechatVoiceFfmpegDownload: () => Promise<{ success: boolean; error?: string }>
|
openPersonalWechatVoiceFfmpegDownload: () => Promise<{ success: boolean; error?: string }>
|
||||||
getPersonalWechatRuntimeStatus: () => Promise<PersonalWechatRuntimeStatus>
|
|
||||||
downloadPersonalWechatRuntime: () => Promise<PersonalWechatRuntimeDownloadResult>
|
|
||||||
cancelPersonalWechatRuntimeDownload: () => Promise<{ success: boolean }>
|
|
||||||
removePersonalWechatRuntime: () => Promise<PersonalWechatRuntimeStatus>
|
|
||||||
openPersonalWechatRuntimeDirectory: () => Promise<{ success: boolean; error?: string }>
|
|
||||||
onPersonalWechatRuntimeProgress: (
|
|
||||||
callback: (status: PersonalWechatRuntimeProgressEvent) => void
|
|
||||||
) => () => void
|
|
||||||
rebindPersonalWechatSender: () => Promise<PersonalWechatSenderStatus>
|
rebindPersonalWechatSender: () => Promise<PersonalWechatSenderStatus>
|
||||||
sendGeneratedTtsVoice: (
|
sendGeneratedTtsVoice: (
|
||||||
request: PersonalWechatGeneratedTtsVoiceRequest
|
request: PersonalWechatGeneratedTtsVoiceRequest
|
||||||
@@ -719,35 +733,23 @@ declare global {
|
|||||||
sendPersonalWechatMessage: (
|
sendPersonalWechatMessage: (
|
||||||
request: PersonalWechatSendRequest
|
request: PersonalWechatSendRequest
|
||||||
) => Promise<PersonalWechatSendResult>
|
) => Promise<PersonalWechatSendResult>
|
||||||
listScheduledReports: () => Promise<ScheduledReportTask[]>
|
/** 定时日报:「立即执行」——与 scheduler 走同一条执行链路(manual trigger)。 */
|
||||||
listScheduledReportExecutions: (taskId?: string) => Promise<ScheduledReportExecution[]>
|
runScheduledReportRule: (
|
||||||
|
ruleId: string
|
||||||
|
) => Promise<{ success: boolean; error?: string; data?: ScheduledRuleRunOutcome }>
|
||||||
|
/** 定时日报:旧执行记录**只读存档**(旧记录无法无损转换,原样展示)。 */
|
||||||
|
listScheduledReportLegacyExecutions: (
|
||||||
|
ruleId?: string
|
||||||
|
) => Promise<ScheduledReportExecution[]>
|
||||||
|
/** 定时日报:微信异常通知开关与能力检测。 */
|
||||||
getScheduledReportNotificationSettings: () => Promise<ScheduledReportNotificationSettings>
|
getScheduledReportNotificationSettings: () => Promise<ScheduledReportNotificationSettings>
|
||||||
|
getScheduledReportNotificationCapability: () => Promise<ScheduledReportNotificationCapability>
|
||||||
setScheduledReportNotificationEnabled: (
|
setScheduledReportNotificationEnabled: (
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
) => Promise<ScheduledReportNotificationSettingsResult>
|
) => Promise<ScheduledReportNotificationSettingsResult>
|
||||||
createScheduledReport: (
|
|
||||||
request: ScheduledReportCreateInput
|
|
||||||
) => Promise<ScheduledReportResult<ScheduledReportTask>>
|
|
||||||
updateScheduledReport: (
|
|
||||||
taskId: string,
|
|
||||||
request: ScheduledReportUpdateInput
|
|
||||||
) => Promise<ScheduledReportResult<ScheduledReportTask>>
|
|
||||||
deleteScheduledReport: (
|
|
||||||
taskId: string
|
|
||||||
) => Promise<ScheduledReportResult<{ deletedId: string }>>
|
|
||||||
setScheduledReportEnabled: (
|
|
||||||
taskId: string,
|
|
||||||
enabled: boolean
|
|
||||||
) => Promise<ScheduledReportResult<ScheduledReportTask>>
|
|
||||||
runScheduledReportNow: (
|
|
||||||
taskId: string
|
|
||||||
) => Promise<ScheduledReportResult<ScheduledReportExecution>>
|
|
||||||
retryScheduledReportSend: (
|
|
||||||
executionId: string
|
|
||||||
) => Promise<ScheduledReportResult<ScheduledReportExecution>>
|
|
||||||
testScheduledReportErrorNotification: (
|
testScheduledReportErrorNotification: (
|
||||||
taskId: string
|
ruleId: string
|
||||||
) => Promise<ScheduledReportResult<ScheduledReportExecution>>
|
) => Promise<ScheduledReportResult<ScheduledReportNotification>>
|
||||||
getPersonalWechatVoiceDiagnostic: () => Promise<PersonalWechatVoiceDiagnostic | null>
|
getPersonalWechatVoiceDiagnostic: () => Promise<PersonalWechatVoiceDiagnostic | null>
|
||||||
getAgentHubStatus: () => Promise<AgentHubStatus>
|
getAgentHubStatus: () => Promise<AgentHubStatus>
|
||||||
getAgentHubLogs: () => Promise<AgentHubLogEntry[]>
|
getAgentHubLogs: () => Promise<AgentHubLogEntry[]>
|
||||||
|
|||||||
+72
-78
@@ -52,26 +52,28 @@ import type {
|
|||||||
} from '../shared/personal-wechat'
|
} from '../shared/personal-wechat'
|
||||||
import type { PersonalWechatSendCapability } from '../shared/personal-wechat'
|
import type { PersonalWechatSendCapability } from '../shared/personal-wechat'
|
||||||
import type {
|
import type {
|
||||||
ScheduledReportCreateInput,
|
|
||||||
ScheduledReportExecution,
|
ScheduledReportExecution,
|
||||||
|
ScheduledReportNotification,
|
||||||
|
ScheduledReportNotificationCapability,
|
||||||
ScheduledReportNotificationSettings,
|
ScheduledReportNotificationSettings,
|
||||||
ScheduledReportNotificationSettingsResult,
|
ScheduledReportNotificationSettingsResult,
|
||||||
ScheduledReportResult,
|
ScheduledReportResult
|
||||||
ScheduledReportTask,
|
|
||||||
ScheduledReportUpdateInput
|
|
||||||
} from '../shared/scheduled-report'
|
} from '../shared/scheduled-report'
|
||||||
import type {
|
|
||||||
PersonalWechatRuntimeDownloadResult,
|
|
||||||
PersonalWechatRuntimeProgressEvent,
|
|
||||||
PersonalWechatRuntimeStatus
|
|
||||||
} from '../shared/personal-wechat-runtime'
|
|
||||||
import type {
|
import type {
|
||||||
PersonalWechatVoiceEncodingEnvironment,
|
PersonalWechatVoiceEncodingEnvironment,
|
||||||
PersonalWechatVoiceEncodingEnvironmentResult
|
PersonalWechatVoiceEncodingEnvironmentResult
|
||||||
} from '../shared/personal-wechat-voice-runtime'
|
} from '../shared/personal-wechat-voice-runtime'
|
||||||
import type { AppLogEntry } from '../shared/app-log'
|
import type { AppLogEntry } from '../shared/app-log'
|
||||||
import type { AppUpdateState } from '../shared/app-update'
|
import type { AppUpdateState } from '../shared/app-update'
|
||||||
import type { GroupExitMonitorState } from '../shared/group-exit-monitor'
|
import type { GroupExitMonitorEvent, GroupExitMonitorState } from '../shared/group-exit-monitor'
|
||||||
|
import type {
|
||||||
|
AutomationExecution,
|
||||||
|
AutomationRule,
|
||||||
|
AutomationRuleDraft,
|
||||||
|
AutomationStatusSummary,
|
||||||
|
ScheduledRuleRunOutcome
|
||||||
|
} from '../shared/automation'
|
||||||
|
import type { GroupMemberStatsQuery, GroupMemberStatsResult } from '../shared/group-stats'
|
||||||
import type { ActionLogEntry } from '../shared/action-log'
|
import type { ActionLogEntry } from '../shared/action-log'
|
||||||
import type { CacheClearScope, CacheSummary } from '../shared/cache'
|
import type { CacheClearScope, CacheSummary } from '../shared/cache'
|
||||||
import type { ExportRequest, ExportJobProgress } from '../shared/export'
|
import type { ExportRequest, ExportJobProgress } from '../shared/export'
|
||||||
@@ -167,29 +169,59 @@ const api = {
|
|||||||
): Promise<MessagesAroundResult> =>
|
): Promise<MessagesAroundResult> =>
|
||||||
ipcRenderer.invoke('db:getMessagesAround', userMd5, messageId, anchorSeconds, radiusSeconds),
|
ipcRenderer.invoke('db:getMessagesAround', userMd5, messageId, anchorSeconds, radiusSeconds),
|
||||||
getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5),
|
getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5),
|
||||||
|
getGroupMemberStats: (request: GroupMemberStatsQuery): Promise<GroupMemberStatsResult> =>
|
||||||
|
ipcRenderer.invoke('group-stats:getMemberStats', request),
|
||||||
getGroupExitMonitorState: (): Promise<GroupExitMonitorState> =>
|
getGroupExitMonitorState: (): Promise<GroupExitMonitorState> =>
|
||||||
ipcRenderer.invoke('group-exit-monitor:getState'),
|
ipcRenderer.invoke('group-exit-monitor:getState'),
|
||||||
|
listGroupExitMonitorEvents: (query?: {
|
||||||
|
roomId?: string
|
||||||
|
sinceMs?: number
|
||||||
|
untilMs?: number
|
||||||
|
limit?: number
|
||||||
|
}): Promise<GroupExitMonitorEvent[]> =>
|
||||||
|
ipcRenderer.invoke('group-exit-monitor:listEvents', query),
|
||||||
setGroupExitMonitorEnabled: (enabled: boolean): Promise<GroupExitMonitorState> =>
|
setGroupExitMonitorEnabled: (enabled: boolean): Promise<GroupExitMonitorState> =>
|
||||||
ipcRenderer.invoke('group-exit-monitor:setEnabled', enabled),
|
ipcRenderer.invoke('group-exit-monitor:setEnabled', enabled),
|
||||||
setGroupExitMonitorGroups: (
|
/** 保存**监控范围**。通知配置已迁到自动化规则,这里不再有第二个参数。 */
|
||||||
roomIds: string[],
|
setGroupExitMonitorGroups: (roomIds: string[]): Promise<GroupExitMonitorState> =>
|
||||||
notificationRoomIds?: string[]
|
ipcRenderer.invoke('group-exit-monitor:setGroups', roomIds),
|
||||||
): Promise<GroupExitMonitorState> =>
|
|
||||||
notificationRoomIds === undefined
|
|
||||||
? ipcRenderer.invoke('group-exit-monitor:setGroups', roomIds)
|
|
||||||
: ipcRenderer.invoke('group-exit-monitor:setGroups', roomIds, notificationRoomIds),
|
|
||||||
setGroupExitMonitorNotificationTemplate: (template: string): Promise<GroupExitMonitorState> =>
|
|
||||||
ipcRenderer.invoke('group-exit-monitor:setTemplate', template),
|
|
||||||
checkGroupExitMonitorNow: (): Promise<GroupExitMonitorState> =>
|
checkGroupExitMonitorNow: (): Promise<GroupExitMonitorState> =>
|
||||||
ipcRenderer.invoke('group-exit-monitor:checkNow'),
|
ipcRenderer.invoke('group-exit-monitor:checkNow'),
|
||||||
clearGroupExitMonitorEvents: (): Promise<GroupExitMonitorState> =>
|
clearGroupExitMonitorEvents: (): Promise<GroupExitMonitorState> =>
|
||||||
ipcRenderer.invoke('group-exit-monitor:clearEvents'),
|
ipcRenderer.invoke('group-exit-monitor:clearEvents'),
|
||||||
resendGroupExitMonitorEvent: (eventId: string): Promise<GroupExitMonitorState> =>
|
|
||||||
ipcRenderer.invoke('group-exit-monitor:resendEvent', eventId),
|
|
||||||
markGroupExitMonitorRead: (readAt?: number): Promise<GroupExitMonitorState> =>
|
markGroupExitMonitorRead: (readAt?: number): Promise<GroupExitMonitorState> =>
|
||||||
ipcRenderer.invoke('group-exit-monitor:markRead', readAt),
|
ipcRenderer.invoke('group-exit-monitor:markRead', readAt),
|
||||||
listWechatActionLogs: (): Promise<ActionLogEntry[]> =>
|
listWechatActionLogs: (): Promise<ActionLogEntry[]> =>
|
||||||
ipcRenderer.invoke('wechat-action-log:list'),
|
ipcRenderer.invoke('wechat-action-log:list'),
|
||||||
|
// ---- Automation v1(@我生成日报)。命名与既有扁平风格一致。 ----
|
||||||
|
getAutomationStatus: (): Promise<AutomationStatusSummary> =>
|
||||||
|
ipcRenderer.invoke('automation:getStatus'),
|
||||||
|
listAutomationRules: (): Promise<AutomationRule[]> => ipcRenderer.invoke('automation:listRules'),
|
||||||
|
createAutomationRule: (draft: AutomationRuleDraft): Promise<AutomationRule> =>
|
||||||
|
ipcRenderer.invoke('automation:createRule', draft),
|
||||||
|
updateAutomationRule: (id: string, draft: AutomationRuleDraft): Promise<AutomationRule | null> =>
|
||||||
|
ipcRenderer.invoke('automation:updateRule', { id, draft }),
|
||||||
|
deleteAutomationRule: (id: string): Promise<boolean> =>
|
||||||
|
ipcRenderer.invoke('automation:deleteRule', id),
|
||||||
|
setAutomationRuleEnabled: (id: string, enabled: boolean): Promise<AutomationRule | null> =>
|
||||||
|
ipcRenderer.invoke('automation:setRuleEnabled', { id, enabled }),
|
||||||
|
listAutomationExecutions: (query?: { limit?: number }): Promise<AutomationExecution[]> =>
|
||||||
|
ipcRenderer.invoke('automation:listExecutions', query),
|
||||||
|
clearAutomationExecutions: (): Promise<boolean> =>
|
||||||
|
ipcRenderer.invoke('automation:clearExecutions'),
|
||||||
|
/** 「在哪些聊天生效」的可选项。`id` 为 `xxx@chatroom`,与规则内 conversationIds 同口径。 */
|
||||||
|
listAutomationGroups: (): Promise<Array<{ id: string; name: string }>> =>
|
||||||
|
ipcRenderer.invoke('automation:listGroups'),
|
||||||
|
/**
|
||||||
|
* 保存「退群通知」规则(singleton upsert,id 由 main 侧固定,渲染层不拼 id)。
|
||||||
|
*/
|
||||||
|
saveLeaveNotificationRule: (draft: AutomationRuleDraft): Promise<AutomationRule> =>
|
||||||
|
ipcRenderer.invoke('automation:saveLeaveNotificationRule', draft),
|
||||||
|
/**
|
||||||
|
* 「指定好友」的可选项。已在 main 侧过滤掉群聊 / 公众号 / 文件传输助手 / 自己。
|
||||||
|
*/
|
||||||
|
listSendableContacts: (): Promise<Array<{ id: string; name: string }>> =>
|
||||||
|
ipcRenderer.invoke('automation:listSendableContacts'),
|
||||||
onGroupExitMonitorState: (callback: (state: GroupExitMonitorState) => void) => {
|
onGroupExitMonitorState: (callback: (state: GroupExitMonitorState) => void) => {
|
||||||
const listener = (_event: Electron.IpcRendererEvent, state: GroupExitMonitorState): void =>
|
const listener = (_event: Electron.IpcRendererEvent, state: GroupExitMonitorState): void =>
|
||||||
callback(state)
|
callback(state)
|
||||||
@@ -519,10 +551,6 @@ const api = {
|
|||||||
ipcRenderer.invoke('wechat-personal:getStatus'),
|
ipcRenderer.invoke('wechat-personal:getStatus'),
|
||||||
getPersonalWechatSendCapability: (): Promise<PersonalWechatSendCapability> =>
|
getPersonalWechatSendCapability: (): Promise<PersonalWechatSendCapability> =>
|
||||||
ipcRenderer.invoke('wechat-personal:getSendCapability'),
|
ipcRenderer.invoke('wechat-personal:getSendCapability'),
|
||||||
getPersonalWechatKeepOneBotProcess: (): Promise<boolean> =>
|
|
||||||
ipcRenderer.invoke('wechat-personal:getKeepProcess'),
|
|
||||||
setPersonalWechatKeepOneBotProcess: (keep: boolean): Promise<boolean> =>
|
|
||||||
ipcRenderer.invoke('wechat-personal:setKeepProcess', keep),
|
|
||||||
checkPersonalWechatSenderStatus: (port?: string): Promise<PersonalWechatSenderStatus> =>
|
checkPersonalWechatSenderStatus: (port?: string): Promise<PersonalWechatSenderStatus> =>
|
||||||
ipcRenderer.invoke('wechat-personal:checkStatus', port),
|
ipcRenderer.invoke('wechat-personal:checkStatus', port),
|
||||||
checkPersonalWechatVoiceEncodingEnvironment:
|
checkPersonalWechatVoiceEncodingEnvironment:
|
||||||
@@ -534,26 +562,6 @@ const api = {
|
|||||||
ipcRenderer.invoke('wechat-personal:openVoicePythonDownload'),
|
ipcRenderer.invoke('wechat-personal:openVoicePythonDownload'),
|
||||||
openPersonalWechatVoiceFfmpegDownload: (): Promise<{ success: boolean; error?: string }> =>
|
openPersonalWechatVoiceFfmpegDownload: (): Promise<{ success: boolean; error?: string }> =>
|
||||||
ipcRenderer.invoke('wechat-personal:openVoiceFfmpegDownload'),
|
ipcRenderer.invoke('wechat-personal:openVoiceFfmpegDownload'),
|
||||||
getPersonalWechatRuntimeStatus: (): Promise<PersonalWechatRuntimeStatus> =>
|
|
||||||
ipcRenderer.invoke('wechat-personal:getRuntimeStatus'),
|
|
||||||
downloadPersonalWechatRuntime: (): Promise<PersonalWechatRuntimeDownloadResult> =>
|
|
||||||
ipcRenderer.invoke('wechat-personal:downloadRuntime'),
|
|
||||||
cancelPersonalWechatRuntimeDownload: (): Promise<{ success: boolean }> =>
|
|
||||||
ipcRenderer.invoke('wechat-personal:cancelRuntimeDownload'),
|
|
||||||
removePersonalWechatRuntime: (): Promise<PersonalWechatRuntimeStatus> =>
|
|
||||||
ipcRenderer.invoke('wechat-personal:removeRuntime'),
|
|
||||||
openPersonalWechatRuntimeDirectory: (): Promise<{ success: boolean; error?: string }> =>
|
|
||||||
ipcRenderer.invoke('wechat-personal:openRuntimeDirectory'),
|
|
||||||
onPersonalWechatRuntimeProgress: (
|
|
||||||
callback: (status: PersonalWechatRuntimeProgressEvent) => void
|
|
||||||
) => {
|
|
||||||
const listener = (
|
|
||||||
_event: Electron.IpcRendererEvent,
|
|
||||||
status: PersonalWechatRuntimeProgressEvent
|
|
||||||
): void => callback(status)
|
|
||||||
ipcRenderer.on('wechat-personal:runtimeProgress', listener)
|
|
||||||
return () => ipcRenderer.removeListener('wechat-personal:runtimeProgress', listener)
|
|
||||||
},
|
|
||||||
rebindPersonalWechatSender: (): Promise<PersonalWechatSenderStatus> =>
|
rebindPersonalWechatSender: (): Promise<PersonalWechatSenderStatus> =>
|
||||||
ipcRenderer.invoke('wechat-personal:rebind'),
|
ipcRenderer.invoke('wechat-personal:rebind'),
|
||||||
sendGeneratedTtsVoice: (
|
sendGeneratedTtsVoice: (
|
||||||
@@ -564,44 +572,30 @@ const api = {
|
|||||||
sendPersonalWechatMessage: (
|
sendPersonalWechatMessage: (
|
||||||
request: PersonalWechatSendRequest
|
request: PersonalWechatSendRequest
|
||||||
): Promise<PersonalWechatSendResult> => ipcRenderer.invoke('wechat-personal:send', request),
|
): Promise<PersonalWechatSendResult> => ipcRenderer.invoke('wechat-personal:send', request),
|
||||||
listScheduledReports: (): Promise<ScheduledReportTask[]> =>
|
/**
|
||||||
ipcRenderer.invoke('scheduled-report:list'),
|
* 定时日报(Automation 的 `scheduled_report` 规则类型)。
|
||||||
listScheduledReportExecutions: (taskId?: string): Promise<ScheduledReportExecution[]> =>
|
*
|
||||||
ipcRenderer.invoke('scheduled-report:listExecutions', taskId),
|
* 规则 CRUD 复用上面的 `automation:*` 通道;这里只暴露三块专属能力:
|
||||||
|
* 立即执行、微信异常通知、旧执行记录只读存档。
|
||||||
|
*/
|
||||||
|
runScheduledReportRule: (
|
||||||
|
ruleId: string
|
||||||
|
): Promise<{ success: boolean; error?: string; data?: ScheduledRuleRunOutcome }> =>
|
||||||
|
ipcRenderer.invoke('automation:runScheduledReportRule', ruleId),
|
||||||
|
listScheduledReportLegacyExecutions: (ruleId?: string): Promise<ScheduledReportExecution[]> =>
|
||||||
|
ipcRenderer.invoke('automation:listScheduledReportLegacyExecutions', ruleId),
|
||||||
getScheduledReportNotificationSettings: (): Promise<ScheduledReportNotificationSettings> =>
|
getScheduledReportNotificationSettings: (): Promise<ScheduledReportNotificationSettings> =>
|
||||||
ipcRenderer.invoke('scheduled-report:getNotificationSettings'),
|
ipcRenderer.invoke('automation:getScheduledReportNotificationSettings'),
|
||||||
|
getScheduledReportNotificationCapability: (): Promise<ScheduledReportNotificationCapability> =>
|
||||||
|
ipcRenderer.invoke('automation:getScheduledReportNotificationCapability'),
|
||||||
setScheduledReportNotificationEnabled: (
|
setScheduledReportNotificationEnabled: (
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
): Promise<ScheduledReportNotificationSettingsResult> =>
|
): Promise<ScheduledReportNotificationSettingsResult> =>
|
||||||
ipcRenderer.invoke('scheduled-report:setNotificationEnabled', enabled),
|
ipcRenderer.invoke('automation:setScheduledReportNotificationEnabled', enabled),
|
||||||
createScheduledReport: (
|
|
||||||
request: ScheduledReportCreateInput
|
|
||||||
): Promise<ScheduledReportResult<ScheduledReportTask>> =>
|
|
||||||
ipcRenderer.invoke('scheduled-report:create', request),
|
|
||||||
updateScheduledReport: (
|
|
||||||
taskId: string,
|
|
||||||
request: ScheduledReportUpdateInput
|
|
||||||
): Promise<ScheduledReportResult<ScheduledReportTask>> =>
|
|
||||||
ipcRenderer.invoke('scheduled-report:update', taskId, request),
|
|
||||||
deleteScheduledReport: (taskId: string): Promise<ScheduledReportResult<{ deletedId: string }>> =>
|
|
||||||
ipcRenderer.invoke('scheduled-report:delete', taskId),
|
|
||||||
setScheduledReportEnabled: (
|
|
||||||
taskId: string,
|
|
||||||
enabled: boolean
|
|
||||||
): Promise<ScheduledReportResult<ScheduledReportTask>> =>
|
|
||||||
ipcRenderer.invoke('scheduled-report:setEnabled', taskId, enabled),
|
|
||||||
runScheduledReportNow: (
|
|
||||||
taskId: string
|
|
||||||
): Promise<ScheduledReportResult<ScheduledReportExecution>> =>
|
|
||||||
ipcRenderer.invoke('scheduled-report:runNow', taskId),
|
|
||||||
retryScheduledReportSend: (
|
|
||||||
executionId: string
|
|
||||||
): Promise<ScheduledReportResult<ScheduledReportExecution>> =>
|
|
||||||
ipcRenderer.invoke('scheduled-report:retrySend', executionId),
|
|
||||||
testScheduledReportErrorNotification: (
|
testScheduledReportErrorNotification: (
|
||||||
taskId: string
|
ruleId: string
|
||||||
): Promise<ScheduledReportResult<ScheduledReportExecution>> =>
|
): Promise<ScheduledReportResult<ScheduledReportNotification>> =>
|
||||||
ipcRenderer.invoke('scheduled-report:testErrorNotification', taskId),
|
ipcRenderer.invoke('automation:testScheduledReportErrorNotification', ruleId),
|
||||||
getPersonalWechatVoiceDiagnostic: (): Promise<PersonalWechatVoiceDiagnostic | null> =>
|
getPersonalWechatVoiceDiagnostic: (): Promise<PersonalWechatVoiceDiagnostic | null> =>
|
||||||
ipcRenderer.invoke('wechat-personal:getVoiceDiagnostic'),
|
ipcRenderer.invoke('wechat-personal:getVoiceDiagnostic'),
|
||||||
getAgentHubStatus: () => ipcRenderer.invoke('agent-hub:getStatus'),
|
getAgentHubStatus: () => ipcRenderer.invoke('agent-hub:getStatus'),
|
||||||
|
|||||||
+84
-32
@@ -18,7 +18,6 @@ import { ReportInfoPanel } from './components/reports/ReportInfoPanel'
|
|||||||
import { ReportSourceSidebar } from './components/reports/ReportSourceSidebar'
|
import { ReportSourceSidebar } from './components/reports/ReportSourceSidebar'
|
||||||
import { ReportTaskStatusPanel } from './components/reports/ReportTaskStatusPanel'
|
import { ReportTaskStatusPanel } from './components/reports/ReportTaskStatusPanel'
|
||||||
import { ReportViewer } from './components/reports/ReportViewer'
|
import { ReportViewer } from './components/reports/ReportViewer'
|
||||||
import { ScheduledReportsWorkspace } from './components/reports/ScheduledReportsWorkspace'
|
|
||||||
import { ReportTemplateMarketWorkspace } from './components/reports/ReportTemplateMarketWorkspace'
|
import { ReportTemplateMarketWorkspace } from './components/reports/ReportTemplateMarketWorkspace'
|
||||||
import { contactDisplayName } from './components/reports/types'
|
import { contactDisplayName } from './components/reports/types'
|
||||||
import type { GeneratedReportRecord, ReportWorkspaceView } from './components/reports/types'
|
import type { GeneratedReportRecord, ReportWorkspaceView } from './components/reports/types'
|
||||||
@@ -42,10 +41,11 @@ import { isRelevantMessageMonitorEvent, parseWcdbMonitorEvent } from './utils/me
|
|||||||
import { enrichQuotedMessages } from './utils/quoted-messages'
|
import { enrichQuotedMessages } from './utils/quoted-messages'
|
||||||
import type { ReportTemplateSelectionId } from '../../shared/report-templates'
|
import type { ReportTemplateSelectionId } from '../../shared/report-templates'
|
||||||
import { switchGeneratedReportTemplate } from './utils/report-template-switch'
|
import { switchGeneratedReportTemplate } from './utils/report-template-switch'
|
||||||
import { runtimePlatform, supportsPersonalWechatSend } from './utils/runtime-environment'
|
import { runtimePlatform } from './utils/runtime-environment'
|
||||||
import { useToast } from './components/ui'
|
import { Button, useToast } from './components/ui'
|
||||||
import { AppUpdatePrompt } from './features/app-update/AppUpdatePrompt'
|
import { AppUpdatePrompt } from './features/app-update/AppUpdatePrompt'
|
||||||
import { GroupExitMonitorWorkspace } from './features/group-exit-monitor/GroupExitMonitorWorkspace'
|
import { GroupExitMonitorWorkspace, type GroupExitMonitorOpenViewRequest } from './features/group-exit-monitor/GroupExitMonitorWorkspace'
|
||||||
|
import { AutomationWorkspace, type AutomationOpenRuleRequest } from './features/automation/AutomationWorkspace'
|
||||||
import { selectContactAvatarRefreshUsernames } from './utils/contact-avatar'
|
import { selectContactAvatarRefreshUsernames } from './utils/contact-avatar'
|
||||||
import {
|
import {
|
||||||
buildContactSearchIndex,
|
buildContactSearchIndex,
|
||||||
@@ -257,6 +257,17 @@ function App(): React.ReactElement {
|
|||||||
const [databaseEnvironment, setDatabaseEnvironment] = useState<DatabaseKeyEnvironment>()
|
const [databaseEnvironment, setDatabaseEnvironment] = useState<DatabaseKeyEnvironment>()
|
||||||
const connectionOperationRef = React.useRef(0)
|
const connectionOperationRef = React.useRef(0)
|
||||||
const [activePage, setActivePage] = useState<AppPage>('archive')
|
const [activePage, setActivePage] = useState<AppPage>('archive')
|
||||||
|
/**
|
||||||
|
* 「退群监控 ⇄ 自动化」之间的跨页深链。
|
||||||
|
*
|
||||||
|
* 项目没有 react-router,一级菜单就是 `activePage` 这一份 state,
|
||||||
|
* 所以深链同样是 state —— 用一个单调递增的 requestId 表达"又点了一次",
|
||||||
|
* 子页面据此重新落位(同一个对象引用不会重复触发 effect)。
|
||||||
|
*/
|
||||||
|
const [automationOpenRuleRequest, setAutomationOpenRuleRequest] =
|
||||||
|
React.useState<AutomationOpenRuleRequest | null>(null)
|
||||||
|
const [exitMonitorOpenViewRequest, setExitMonitorOpenViewRequest] =
|
||||||
|
React.useState<GroupExitMonitorOpenViewRequest | null>(null)
|
||||||
const [archiveJumpTime, setArchiveJumpTime] = useState<number | null>(null)
|
const [archiveJumpTime, setArchiveJumpTime] = useState<number | null>(null)
|
||||||
/**
|
/**
|
||||||
* 精确跳转目标(规范化后的消息 id)。
|
* 精确跳转目标(规范化后的消息 id)。
|
||||||
@@ -268,7 +279,7 @@ function App(): React.ReactElement {
|
|||||||
const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database')
|
const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database')
|
||||||
const [reportSourceContact, setReportSourceContact] = useState<Contact | null>(null)
|
const [reportSourceContact, setReportSourceContact] = useState<Contact | null>(null)
|
||||||
const [reportWorkspaceView, setReportWorkspaceView] = useState<ReportWorkspaceView>('result')
|
const [reportWorkspaceView, setReportWorkspaceView] = useState<ReportWorkspaceView>('result')
|
||||||
const [reportSection, setReportSection] = useState<'today' | 'scheduled' | 'market'>('today')
|
const [reportSection, setReportSection] = useState<'today' | 'market'>('today')
|
||||||
const [generatedReports, setGeneratedReports] = useState<GeneratedReportRecord[]>([])
|
const [generatedReports, setGeneratedReports] = useState<GeneratedReportRecord[]>([])
|
||||||
const [selectedReportId, setSelectedReportId] = useState<string | null>(null)
|
const [selectedReportId, setSelectedReportId] = useState<string | null>(null)
|
||||||
const [latestGeneratedReportId, setLatestGeneratedReportId] = useState<string | null>(null)
|
const [latestGeneratedReportId, setLatestGeneratedReportId] = useState<string | null>(null)
|
||||||
@@ -1639,10 +1650,49 @@ function App(): React.ReactElement {
|
|||||||
setActivePage('settings')
|
setActivePage('settings')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跳到「设置 · 本地索引」。
|
||||||
|
*
|
||||||
|
* 用在群统计这类场景:结果不完整的原因是索引没追平,用户需要一个**可操作的去处**,
|
||||||
|
* 而不是只看到一句「结果可能不完整」却不知道去哪解决。
|
||||||
|
*/
|
||||||
|
const openLocalIndexSettings = (): void => {
|
||||||
|
setSettingsCategory('local-index')
|
||||||
|
setActivePage('settings')
|
||||||
|
}
|
||||||
|
|
||||||
const openAgentHub = (): void => {
|
const openAgentHub = (): void => {
|
||||||
setActivePage('agent-hub')
|
setActivePage('agent-hub')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「退群监控 → 退群通知自动化」。
|
||||||
|
*
|
||||||
|
* 直接落到「自动化 → 规则 → 退群通知」,不是只跳到自动化首页。
|
||||||
|
* 这是纯导航:不改退群监控的任何配置,也不触发任何发送。
|
||||||
|
*/
|
||||||
|
const openLeaveNotificationAutomation = (): void => {
|
||||||
|
setAutomationOpenRuleRequest({ ruleType: 'leave_notification', requestId: Date.now() })
|
||||||
|
setActivePage('automation')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「定时日报」已正式迁入自动化,日报页只保留「今日日报 | 社区模板市场」两个 Tab。
|
||||||
|
*
|
||||||
|
* 这里仍然留一个导航入口:用户是从日报页产生"要定时发日报"这个念头的,
|
||||||
|
* 不给路会显得功能被删了。它**不是**第三个 Tab,只是一句去处的说明。
|
||||||
|
*/
|
||||||
|
const openScheduledReportAutomation = (): void => {
|
||||||
|
setAutomationOpenRuleRequest({ ruleType: 'scheduled_report', requestId: Date.now() })
|
||||||
|
setActivePage('automation')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「自动化 → 退群通知 → 管理监控群聊 / 查看群聊」:回到退群监控的管理群聊页。 */
|
||||||
|
const openExitMonitorGroups = (): void => {
|
||||||
|
setExitMonitorOpenViewRequest({ view: 'manage', requestId: Date.now() })
|
||||||
|
setActivePage('exit-monitor')
|
||||||
|
}
|
||||||
|
|
||||||
const dismissFirstUseWelcome = (): void => {
|
const dismissFirstUseWelcome = (): void => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(FIRST_USE_WELCOME_SEEN_KEY, '1')
|
localStorage.setItem(FIRST_USE_WELCOME_SEEN_KEY, '1')
|
||||||
@@ -1907,6 +1957,7 @@ function App(): React.ReactElement {
|
|||||||
onCreateGroupReport={handleOpenReportWorkspace}
|
onCreateGroupReport={handleOpenReportWorkspace}
|
||||||
onOpenTextToSpeechSettings={openTextToSpeechSettings}
|
onOpenTextToSpeechSettings={openTextToSpeechSettings}
|
||||||
onOpenPersonalWechatSettings={openWechatSendSettings}
|
onOpenPersonalWechatSettings={openWechatSendSettings}
|
||||||
|
onOpenLocalIndexSettings={openLocalIndexSettings}
|
||||||
isAiLoading={reportGeneration.isGenerating}
|
isAiLoading={reportGeneration.isGenerating}
|
||||||
jumpToTime={archiveJumpTime}
|
jumpToTime={archiveJumpTime}
|
||||||
jumpToMessageId={archiveJumpMessageId}
|
jumpToMessageId={archiveJumpMessageId}
|
||||||
@@ -1926,22 +1977,6 @@ function App(): React.ReactElement {
|
|||||||
>
|
>
|
||||||
今日日报
|
今日日报
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={reportSection === 'scheduled'}
|
|
||||||
aria-disabled={!supportsPersonalWechatSend}
|
|
||||||
className={`${reportSection === 'scheduled' ? 'active' : ''} ${!supportsPersonalWechatSend ? 'unsupported' : ''}`}
|
|
||||||
onClick={() => {
|
|
||||||
if (!supportsPersonalWechatSend) {
|
|
||||||
toast({ description: '定时日报目前仅支持 macOS 和 Windows。', duration: 3200 })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setReportSection('scheduled')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
定时日报{!supportsPersonalWechatSend && <small>仅 macOS / Windows</small>}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
@@ -1952,23 +1987,26 @@ function App(): React.ReactElement {
|
|||||||
社区模板市场
|
社区模板市场
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{/*
|
||||||
|
「定时日报已并入自动化」的指引条。
|
||||||
|
|
||||||
|
这里以前是裸 `<p>` + 裸 `<button>`,并且挂了 `.report-workspace-hint` /
|
||||||
|
`.report-workspace-hint-link` 两个**样式表里根本不存在**的类 ——
|
||||||
|
于是就成了一条没样式的文字 + 一个长得不像系统里任何按钮的按钮。
|
||||||
|
现在补上真实样式,按钮统一走 UI 组件库。
|
||||||
|
*/}
|
||||||
|
<div className="report-workspace-hint">
|
||||||
|
<span>需要「定时日报」?它已经并入「自动化」,可以按时间自动生成并发送日报。</span>
|
||||||
|
<Button variant="link" size="sm" onClick={openScheduledReportAutomation}>
|
||||||
|
去自动化配置 →
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div className="report-workspace-body">
|
<div className="report-workspace-body">
|
||||||
{reportSection === 'market' ? (
|
{reportSection === 'market' ? (
|
||||||
<ReportTemplateMarketWorkspace
|
<ReportTemplateMarketWorkspace
|
||||||
value={reportGeneration.templateId}
|
value={reportGeneration.templateId}
|
||||||
onChange={reportGeneration.setTemplateId}
|
onChange={reportGeneration.setTemplateId}
|
||||||
/>
|
/>
|
||||||
) : reportSection === 'scheduled' ? (
|
|
||||||
<ScheduledReportsWorkspace
|
|
||||||
contacts={contacts}
|
|
||||||
platformSupported={supportsPersonalWechatSend}
|
|
||||||
onOpenWechatSettings={openWechatSendSettings}
|
|
||||||
onOpenAgentHub={openAgentHub}
|
|
||||||
onOpenModelSettings={openModelSettings}
|
|
||||||
onNotice={(message, variant) =>
|
|
||||||
toast({ description: message, variant, duration: 3200 })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : reportWorkspaceView === 'result' ? (
|
) : reportWorkspaceView === 'result' ? (
|
||||||
<div className="report-center-page">
|
<div className="report-center-page">
|
||||||
<ReportHistorySidebar
|
<ReportHistorySidebar
|
||||||
@@ -2084,6 +2122,20 @@ function App(): React.ReactElement {
|
|||||||
dbReady={isDatabaseConnected}
|
dbReady={isDatabaseConnected}
|
||||||
contacts={contacts}
|
contacts={contacts}
|
||||||
onOpenSendSettings={openWechatSendSettings}
|
onOpenSendSettings={openWechatSendSettings}
|
||||||
|
openViewRequest={exitMonitorOpenViewRequest}
|
||||||
|
onOpenViewRequestHandled={() => setExitMonitorOpenViewRequest(null)}
|
||||||
|
onOpenLeaveNotificationAutomation={openLeaveNotificationAutomation}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case 'automation':
|
||||||
|
return (
|
||||||
|
<AutomationWorkspace
|
||||||
|
dbReady={isDatabaseConnected}
|
||||||
|
onOpenSendSettings={openWechatSendSettings}
|
||||||
|
onOpenExitMonitorGroups={openExitMonitorGroups}
|
||||||
|
openRuleRequest={automationOpenRuleRequest}
|
||||||
|
onOpenModelSettings={openModelSettings}
|
||||||
|
onOpenAgentHub={openAgentHub}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
case 'agent-hub':
|
case 'agent-hub':
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { DataTrustBar } from './chat/DataTrustBar'
|
|||||||
import { EmptyConversationState } from './chat/EmptyConversationState'
|
import { EmptyConversationState } from './chat/EmptyConversationState'
|
||||||
import { MessageList } from './chat/MessageList'
|
import { MessageList } from './chat/MessageList'
|
||||||
import { PersonalWechatSendDialog } from './chat/PersonalWechatSendDialog'
|
import { PersonalWechatSendDialog } from './chat/PersonalWechatSendDialog'
|
||||||
|
import { mergeGroupExitEvents } from '../../../shared/group-exit-event-message'
|
||||||
|
import { useGroupExitEvents } from '../hooks/useGroupExitEvents'
|
||||||
|
|
||||||
interface ChatWindowProps {
|
interface ChatWindowProps {
|
||||||
contact: Contact | null
|
contact: Contact | null
|
||||||
@@ -22,6 +24,8 @@ interface ChatWindowProps {
|
|||||||
onCreateGroupReport?: () => void
|
onCreateGroupReport?: () => void
|
||||||
onOpenTextToSpeechSettings?: () => void
|
onOpenTextToSpeechSettings?: () => void
|
||||||
onOpenPersonalWechatSettings?: () => void
|
onOpenPersonalWechatSettings?: () => void
|
||||||
|
/** 跳到「设置 · 本地索引」(群统计发现索引没追平时用)。 */
|
||||||
|
onOpenLocalIndexSettings?: () => void
|
||||||
isAiLoading?: boolean
|
isAiLoading?: boolean
|
||||||
jumpToTime?: number | null
|
jumpToTime?: number | null
|
||||||
/** 精确跳转目标(消息 id)。与 jumpToTime 取或:任一存在就说明"这是一次跳转"。 */
|
/** 精确跳转目标(消息 id)。与 jumpToTime 取或:任一存在就说明"这是一次跳转"。 */
|
||||||
@@ -42,6 +46,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
onCreateGroupReport,
|
onCreateGroupReport,
|
||||||
onOpenTextToSpeechSettings,
|
onOpenTextToSpeechSettings,
|
||||||
onOpenPersonalWechatSettings,
|
onOpenPersonalWechatSettings,
|
||||||
|
onOpenLocalIndexSettings,
|
||||||
isAiLoading = false,
|
isAiLoading = false,
|
||||||
jumpToTime,
|
jumpToTime,
|
||||||
jumpToMessageId
|
jumpToMessageId
|
||||||
@@ -122,8 +127,20 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退群推断事件并入档案消息流。
|
||||||
|
*
|
||||||
|
* **先合并再过滤**:反过来的话搜索词就命中不了事件,而「谁退群了」
|
||||||
|
* 恰恰是最需要能被搜到的内容之一。
|
||||||
|
*/
|
||||||
|
const groupExitEvents = useGroupExitEvents(contact)
|
||||||
|
const allMessages = React.useMemo(
|
||||||
|
() => (groupExitEvents.length ? mergeGroupExitEvents(messages, groupExitEvents) : messages),
|
||||||
|
[messages, groupExitEvents]
|
||||||
|
)
|
||||||
|
|
||||||
const filteredMessages = React.useMemo(() => {
|
const filteredMessages = React.useMemo(() => {
|
||||||
return messages.filter((msg) => {
|
return allMessages.filter((msg) => {
|
||||||
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '')
|
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '')
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((type) => type.trim())
|
.map((type) => type.trim())
|
||||||
@@ -132,7 +149,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
const contentMatch = !contentFilter || msg.content.includes(contentFilter)
|
const contentMatch = !contentFilter || msg.content.includes(contentFilter)
|
||||||
return typeMatch && contentMatch
|
return typeMatch && contentMatch
|
||||||
})
|
})
|
||||||
}, [messages, contentFilter])
|
}, [allMessages, contentFilter])
|
||||||
|
|
||||||
const handleOpenPersonalWechatSend = useCallback(async (): Promise<void> => {
|
const handleOpenPersonalWechatSend = useCallback(async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@@ -168,6 +185,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
onRefreshData={onRefreshData}
|
onRefreshData={onRefreshData}
|
||||||
onTestSend={() => void handleOpenPersonalWechatSend()}
|
onTestSend={() => void handleOpenPersonalWechatSend()}
|
||||||
onOpenAiSettings={onCreateGroupReport || (() => undefined)}
|
onOpenAiSettings={onCreateGroupReport || (() => undefined)}
|
||||||
|
onOpenLocalIndexSettings={onOpenLocalIndexSettings}
|
||||||
/>
|
/>
|
||||||
<DataTrustBar messageCount={messages.length} />
|
<DataTrustBar messageCount={messages.length} />
|
||||||
<MessageList
|
<MessageList
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { ConversationContentSearch } from './ConversationContentSearch'
|
import { ConversationContentSearch } from './ConversationContentSearch'
|
||||||
import { AiIcon, MoreIcon, RefreshIcon, SearchIcon, SendIcon } from './icons'
|
import { AiIcon, MoreIcon, RefreshIcon, SearchIcon, SendIcon } from './icons'
|
||||||
import { supportsPersonalWechatSend } from '../../utils/runtime-environment'
|
import { supportsPersonalWechatSend } from '../../utils/runtime-environment'
|
||||||
|
import { GroupMemberStatsDialog } from '../group-stats/GroupMemberStatsDialog'
|
||||||
|
|
||||||
interface ChatHeaderProps {
|
interface ChatHeaderProps {
|
||||||
contact: Contact
|
contact: Contact
|
||||||
@@ -27,6 +28,8 @@ interface ChatHeaderProps {
|
|||||||
onRefreshData?: () => void
|
onRefreshData?: () => void
|
||||||
onTestSend: () => void
|
onTestSend: () => void
|
||||||
onOpenAiSettings: () => void
|
onOpenAiSettings: () => void
|
||||||
|
/** 跳到「设置 · 本地索引」(群统计发现索引没追平时用)。 */
|
||||||
|
onOpenLocalIndexSettings?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatHeader({
|
export function ChatHeader({
|
||||||
@@ -40,9 +43,11 @@ export function ChatHeader({
|
|||||||
onRefresh,
|
onRefresh,
|
||||||
onRefreshData,
|
onRefreshData,
|
||||||
onTestSend,
|
onTestSend,
|
||||||
onOpenAiSettings
|
onOpenAiSettings,
|
||||||
|
onOpenLocalIndexSettings
|
||||||
}: ChatHeaderProps): React.ReactElement {
|
}: ChatHeaderProps): React.ReactElement {
|
||||||
const [searchOpen, setSearchOpen] = useState(Boolean(contentFilter))
|
const [searchOpen, setSearchOpen] = useState(Boolean(contentFilter))
|
||||||
|
const [statsOpen, setStatsOpen] = useState(false)
|
||||||
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
|
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
|
||||||
const typeLabel = isGroupChat ? '群聊' : '联系人'
|
const typeLabel = isGroupChat ? '群聊' : '联系人'
|
||||||
const visibleCount = contentFilter ? filteredCount : loadedCount
|
const visibleCount = contentFilter ? filteredCount : loadedCount
|
||||||
@@ -93,12 +98,23 @@ export function ChatHeader({
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<IconButton label="更多" tooltip="" variant="ghost" className="h-8 w-8">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="chat-header-text-action"
|
||||||
|
aria-label="更多功能"
|
||||||
|
title="更多功能"
|
||||||
|
>
|
||||||
<MoreIcon />
|
<MoreIcon />
|
||||||
</IconButton>
|
<span>更多功能</span>
|
||||||
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onSelect={() => onRefreshData?.()}>刷新数据</DropdownMenuItem>
|
<DropdownMenuItem onSelect={() => onRefreshData?.()}>刷新数据</DropdownMenuItem>
|
||||||
|
{/* 群发言统计只对群聊有意义;单聊没有「成员名单」这个概念。 */}
|
||||||
|
{isGroupChat ? (
|
||||||
|
<DropdownMenuItem onSelect={() => setStatsOpen(true)}>群发言统计</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
{supportsPersonalWechatSend ? (
|
{supportsPersonalWechatSend ? (
|
||||||
@@ -148,6 +164,14 @@ export function ChatHeader({
|
|||||||
<span>{isAiLoading ? '生成中' : '生成 AI 日报'}</span>
|
<span>{isAiLoading ? '生成中' : '生成 AI 日报'}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{isGroupChat ? (
|
||||||
|
<GroupMemberStatsDialog
|
||||||
|
open={statsOpen}
|
||||||
|
onOpenChange={setStatsOpen}
|
||||||
|
contact={contact}
|
||||||
|
onOpenLocalIndexSettings={onOpenLocalIndexSettings}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import type { Message } from '../../../../shared/types'
|
||||||
|
|
||||||
|
interface GroupExitEventBubbleProps {
|
||||||
|
message: Message
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退群推断事件在档案里的展示。
|
||||||
|
*
|
||||||
|
* ⚠️ 与真实微信系统消息**必须视觉可区分**,原因不是审美:
|
||||||
|
* 这条内容**不是微信说的话**,而是 TraceMemo 通过「群成员快照前后对比」推断出来的。
|
||||||
|
* 两者的可信度完全不同 —— 微信系统消息是事实原文,这条是推断产物,
|
||||||
|
* 而且它记录的是**检测时刻**(可能比真实退群时间晚几分钟到几天)。
|
||||||
|
*
|
||||||
|
* 所以它带一个「本地推断」标记,且样式与 `wechat-system-message` 明确区分。
|
||||||
|
*/
|
||||||
|
export function GroupExitEventBubble({ message }: GroupExitEventBubbleProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="group-exit-event-row">
|
||||||
|
<div className="group-exit-event-bubble">
|
||||||
|
<span className="group-exit-event-tag">本地推断</span>
|
||||||
|
<span className="group-exit-event-text">{message.content}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Contact } from '../../../../shared/types'
|
import { Contact } from '../../../../shared/types'
|
||||||
|
import { isGroupExitEventMessage } from '../../../../shared/group-exit-event-message'
|
||||||
|
import { GroupExitEventBubble } from './GroupExitEventBubble'
|
||||||
import { MessageBubble } from './MessageBubble'
|
import { MessageBubble } from './MessageBubble'
|
||||||
import { MessageGroupModel } from './messageGrouping'
|
import { MessageGroupModel } from './messageGrouping'
|
||||||
|
|
||||||
@@ -26,11 +28,17 @@ export function MessageGroup({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{group.timeLabel && <div className="chat-time-separator">{group.timeLabel}</div>}
|
{group.timeLabel && <div className="chat-time-separator">{group.timeLabel}</div>}
|
||||||
{group.messages.map((message) => (
|
{group.messages.map((message) =>
|
||||||
<div key={message.id} className="wechat-system-message-row">
|
// 退群推断事件与真实微信系统消息都落在 isSystem 分支,
|
||||||
<div className="wechat-system-message">{message.content}</div>
|
// 但必须分开渲染:前者是本地推断,后者是微信原话。
|
||||||
</div>
|
isGroupExitEventMessage(message) ? (
|
||||||
))}
|
<GroupExitEventBubble key={message.id} message={message} />
|
||||||
|
) : (
|
||||||
|
<div key={message.id} className="wechat-system-message-row">
|
||||||
|
<div className="wechat-system-message">{message.content}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,16 +5,14 @@ import type {
|
|||||||
PersonalWechatSenderStatus,
|
PersonalWechatSenderStatus,
|
||||||
PersonalWechatVoiceDiagnostic
|
PersonalWechatVoiceDiagnostic
|
||||||
} from '../../../../shared/personal-wechat'
|
} from '../../../../shared/personal-wechat'
|
||||||
import type {
|
|
||||||
PersonalWechatRuntimeProgressEvent,
|
|
||||||
PersonalWechatRuntimeStatus
|
|
||||||
} from '../../../../shared/personal-wechat-runtime'
|
|
||||||
import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui'
|
import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui'
|
||||||
import { isMac, isWindows } from '../../utils/runtime-environment'
|
import { isMac, isWindows } from '../../utils/runtime-environment'
|
||||||
import { PersonalWechatChatComposer, type ChatMessage } from './PersonalWechatChatComposer'
|
import { PersonalWechatChatComposer, type ChatMessage } from './PersonalWechatChatComposer'
|
||||||
import { PersonalWechatSetupGuide } from './PersonalWechatSetupGuide'
|
import { PersonalWechatSetupGuide } from './PersonalWechatSetupGuide'
|
||||||
import { PersonalWechatVoiceDiagnosticDialog } from './PersonalWechatVoiceDiagnosticDialog'
|
import { PersonalWechatVoiceDiagnosticDialog } from './PersonalWechatVoiceDiagnosticDialog'
|
||||||
import { PersonalWechatWindowsSendDialog } from './PersonalWechatWindowsSendDialog'
|
import { PersonalWechatWindowsSendDialog } from './PersonalWechatWindowsSendDialog'
|
||||||
|
import { ReportImagePostfixInput } from './ReportImagePostfixInput'
|
||||||
|
import { useReportImagePostfixSetting } from './useReportImagePostfixSetting'
|
||||||
|
|
||||||
type SelectedLocalFile = { path: string; name: string }
|
type SelectedLocalFile = { path: string; name: string }
|
||||||
|
|
||||||
@@ -36,7 +34,7 @@ function fallbackStatus(error: unknown): PersonalWechatSenderStatus {
|
|||||||
sipDisabled: false,
|
sipDisabled: false,
|
||||||
wechatRunning: false,
|
wechatRunning: false,
|
||||||
runtimeReady: false,
|
runtimeReady: false,
|
||||||
endpoint: '127.0.0.1:58080',
|
endpoint: '',
|
||||||
endpointReady: false,
|
endpointReady: false,
|
||||||
attachReady: false,
|
attachReady: false,
|
||||||
baseAddressReady: false,
|
baseAddressReady: false,
|
||||||
@@ -67,18 +65,14 @@ function PersonalWechatMacSendDialog({
|
|||||||
initialImage = null
|
initialImage = null
|
||||||
}: PersonalWechatSendDialogProps): React.ReactElement {
|
}: PersonalWechatSendDialogProps): React.ReactElement {
|
||||||
const [senderStatus, setSenderStatus] = useState<PersonalWechatSenderStatus | null>(null)
|
const [senderStatus, setSenderStatus] = useState<PersonalWechatSenderStatus | null>(null)
|
||||||
const [runtimeStatus, setRuntimeStatus] = useState<PersonalWechatRuntimeStatus | null>(null)
|
|
||||||
const [runtimeProgress, setRuntimeProgress] = useState<PersonalWechatRuntimeProgressEvent | null>(
|
|
||||||
null
|
|
||||||
)
|
|
||||||
const [detecting, setDetecting] = useState(true)
|
const [detecting, setDetecting] = useState(true)
|
||||||
const [binding, setBinding] = useState(false)
|
const [binding, setBinding] = useState(false)
|
||||||
const [runtimeBusy, setRuntimeBusy] = useState(false)
|
|
||||||
const [sendBusy, setSendBusy] = useState(false)
|
const [sendBusy, setSendBusy] = useState(false)
|
||||||
// 状态可能来自之前的 OneBot 进程或日志;发送入口只信任当前语音能力状态。
|
// 发送入口只信任当前 native runtime 上报的能力状态。
|
||||||
const [sessionBound, setSessionBound] = useState(false)
|
const [sessionBound, setSessionBound] = useState(false)
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||||
const [sendError, setSendError] = useState<string | null>(null)
|
const [sendError, setSendError] = useState<string | null>(null)
|
||||||
|
const [sendSuccess, setSendSuccess] = useState<string | null>(null)
|
||||||
const [voiceDiagnostic, setVoiceDiagnostic] = useState<PersonalWechatVoiceDiagnostic | null>(null)
|
const [voiceDiagnostic, setVoiceDiagnostic] = useState<PersonalWechatVoiceDiagnostic | null>(null)
|
||||||
const [voiceDiagnosticOpen, setVoiceDiagnosticOpen] = useState(false)
|
const [voiceDiagnosticOpen, setVoiceDiagnosticOpen] = useState(false)
|
||||||
const requestIdRef = useRef(0)
|
const requestIdRef = useRef(0)
|
||||||
@@ -86,23 +80,19 @@ function PersonalWechatMacSendDialog({
|
|||||||
const closingRef = useRef(false)
|
const closingRef = useRef(false)
|
||||||
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
|
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
|
||||||
const targetId = contact.m_nsUsrName
|
const targetId = contact.m_nsUsrName
|
||||||
const isBusy = binding || runtimeBusy || sendBusy
|
const isBusy = binding || sendBusy
|
||||||
const setupReady = Boolean(initialImage ? senderStatus?.canSendImage : senderStatus?.canSendVoice)
|
const setupReady = Boolean(initialImage ? senderStatus?.canSendImage : senderStatus?.canSendVoice)
|
||||||
|
const { postfixText, setPostfixText, persistPostfixText } = useReportImagePostfixSetting(
|
||||||
|
Boolean(initialImage)
|
||||||
|
)
|
||||||
|
|
||||||
const refreshStatus = useCallback(async (): Promise<void> => {
|
const refreshStatus = useCallback(async (): Promise<void> => {
|
||||||
const requestId = ++requestIdRef.current
|
const requestId = ++requestIdRef.current
|
||||||
setDetecting(true)
|
setDetecting(true)
|
||||||
setSendError(null)
|
setSendError(null)
|
||||||
try {
|
try {
|
||||||
const [nextRuntime, nextSender] = await Promise.all([
|
const nextSender = await window.api.getPersonalWechatSenderStatus()
|
||||||
isMac
|
|
||||||
? window.api.getPersonalWechatRuntimeStatus?.() || Promise.resolve(null)
|
|
||||||
: Promise.resolve(null),
|
|
||||||
window.api.getPersonalWechatSenderStatus()
|
|
||||||
])
|
|
||||||
if (requestId !== requestIdRef.current) return
|
if (requestId !== requestIdRef.current) return
|
||||||
setRuntimeStatus(nextRuntime)
|
|
||||||
setRuntimeProgress(nextRuntime?.state === 'downloading' ? nextRuntime : null)
|
|
||||||
setSenderStatus(nextSender)
|
setSenderStatus(nextSender)
|
||||||
setSessionBound(
|
setSessionBound(
|
||||||
nextSender.state === 'online' ||
|
nextSender.state === 'online' ||
|
||||||
@@ -122,13 +112,6 @@ function PersonalWechatMacSendDialog({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshStatus()
|
void refreshStatus()
|
||||||
if (!isMac) return undefined
|
|
||||||
const unsubscribe = window.api.onPersonalWechatRuntimeProgress?.((status) => {
|
|
||||||
setRuntimeProgress(status)
|
|
||||||
setRuntimeStatus(status)
|
|
||||||
if (status.state === 'ready') void refreshStatus()
|
|
||||||
})
|
|
||||||
return unsubscribe
|
|
||||||
}, [refreshStatus])
|
}, [refreshStatus])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -146,22 +129,6 @@ function PersonalWechatMacSendDialog({
|
|||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer)
|
||||||
}, [refreshStatus, senderStatus])
|
}, [refreshStatus, senderStatus])
|
||||||
|
|
||||||
const handleDownloadRuntime = async (): Promise<void> => {
|
|
||||||
if (runtimeBusy) return
|
|
||||||
setRuntimeBusy(true)
|
|
||||||
setSendError(null)
|
|
||||||
try {
|
|
||||||
const result = await window.api.downloadPersonalWechatRuntime()
|
|
||||||
setRuntimeStatus(result.status)
|
|
||||||
if (!result.success && result.error) setSendError(result.error)
|
|
||||||
if (result.success) await refreshStatus()
|
|
||||||
} catch (error) {
|
|
||||||
setSendError(error instanceof Error ? error.message : String(error))
|
|
||||||
} finally {
|
|
||||||
setRuntimeBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBind = async (): Promise<void> => {
|
const handleBind = async (): Promise<void> => {
|
||||||
if (binding) return
|
if (binding) return
|
||||||
setBinding(true)
|
setBinding(true)
|
||||||
@@ -232,18 +199,27 @@ function PersonalWechatMacSendDialog({
|
|||||||
if (!initialImage || !senderStatus?.canSendImage || sendBusy) return
|
if (!initialImage || !senderStatus?.canSendImage || sendBusy) return
|
||||||
setSendBusy(true)
|
setSendBusy(true)
|
||||||
setSendError(null)
|
setSendError(null)
|
||||||
|
setSendSuccess(null)
|
||||||
try {
|
try {
|
||||||
|
await persistPostfixText()
|
||||||
const response = await window.api.sendPersonalWechatMessage({
|
const response = await window.api.sendPersonalWechatMessage({
|
||||||
type: 'image',
|
type: 'image',
|
||||||
to: targetId,
|
to: targetId,
|
||||||
isGroup: isGroupChat,
|
isGroup: isGroupChat,
|
||||||
filePath: initialImage.path
|
filePath: initialImage.path,
|
||||||
|
postfixText
|
||||||
} satisfies PersonalWechatSendRequest)
|
} satisfies PersonalWechatSendRequest)
|
||||||
setSenderStatus(response.status)
|
setSenderStatus(response.status)
|
||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
setSendError(response.error || '日报图片发送失败')
|
setSendError(response.error || '日报图片发送失败')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (response.postfixError) {
|
||||||
|
setSendError(`日报图片已发送,但${response.postfixError}`)
|
||||||
|
setSendSuccess('日报图片发送成功')
|
||||||
|
} else {
|
||||||
|
setSendSuccess(postfixText.trim() ? '日报图片和后置词发送成功' : '日报图片发送成功')
|
||||||
|
}
|
||||||
setMessages((current) => [
|
setMessages((current) => [
|
||||||
...current,
|
...current,
|
||||||
{
|
{
|
||||||
@@ -287,7 +263,7 @@ function PersonalWechatMacSendDialog({
|
|||||||
{displayName.slice(0, 1)}
|
{displayName.slice(0, 1)}
|
||||||
</div>
|
</div>
|
||||||
<div className="personal-wechat-chat-heading">
|
<div className="personal-wechat-chat-heading">
|
||||||
<DialogTitle>文字转语音</DialogTitle>
|
<DialogTitle>{initialImage ? '发送日报图片' : '文字转语音'}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
发送给 {displayName} · {setupReady ? '微信已连接' : '配置微信发送能力'}
|
发送给 {displayName} · {setupReady ? '微信已连接' : '配置微信发送能力'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
@@ -319,14 +295,10 @@ function PersonalWechatMacSendDialog({
|
|||||||
|
|
||||||
{!setupReady && senderStatus && (
|
{!setupReady && senderStatus && (
|
||||||
<PersonalWechatSetupGuide
|
<PersonalWechatSetupGuide
|
||||||
runtimeStatus={runtimeStatus}
|
|
||||||
senderStatus={senderStatus}
|
senderStatus={senderStatus}
|
||||||
runtimeProgress={runtimeProgress}
|
|
||||||
runtimeBusy={runtimeBusy}
|
|
||||||
binding={binding}
|
binding={binding}
|
||||||
detecting={detecting}
|
detecting={detecting}
|
||||||
sessionBound={sessionBound}
|
sessionBound={sessionBound}
|
||||||
onDownloadRuntime={() => void handleDownloadRuntime()}
|
|
||||||
onBind={() => void handleBind()}
|
onBind={() => void handleBind()}
|
||||||
onStartSending={() => undefined}
|
onStartSending={() => undefined}
|
||||||
onOpenTextToSpeechSettings={handleOpenSettings}
|
onOpenTextToSpeechSettings={handleOpenSettings}
|
||||||
@@ -336,6 +308,16 @@ function PersonalWechatMacSendDialog({
|
|||||||
{setupReady && initialImage && (
|
{setupReady && initialImage && (
|
||||||
<section className="personal-wechat-composer" aria-label="日报图片发送">
|
<section className="personal-wechat-composer" aria-label="日报图片发送">
|
||||||
<p>已准备日报图片:{initialImage.name}</p>
|
<p>已准备日报图片:{initialImage.name}</p>
|
||||||
|
<ReportImagePostfixInput
|
||||||
|
value={postfixText}
|
||||||
|
onChange={setPostfixText}
|
||||||
|
onBlur={() =>
|
||||||
|
void persistPostfixText().catch((error) =>
|
||||||
|
setSendError(error instanceof Error ? error.message : '发送后置词保存失败')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={sendBusy}
|
||||||
|
/>
|
||||||
<Button size="sm" onClick={() => void handleSendReportImage()} disabled={sendBusy}>
|
<Button size="sm" onClick={() => void handleSendReportImage()} disabled={sendBusy}>
|
||||||
{sendBusy ? '发送中…' : '发送日报图片'}
|
{sendBusy ? '发送中…' : '发送日报图片'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -358,8 +340,13 @@ function PersonalWechatMacSendDialog({
|
|||||||
{sendError}
|
{sendError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{sendSuccess && (
|
||||||
|
<div className="personal-wechat-global-success" role="status">
|
||||||
|
{sendSuccess}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{setupReady && isMac && (
|
{setupReady && isMac && !initialImage && (
|
||||||
<div className="personal-wechat-chat-footer flex items-center justify-end">
|
<div className="personal-wechat-chat-footer flex items-center justify-end">
|
||||||
<Button variant="link" size="sm" onClick={() => void handleOpenVoiceDiagnostic()}>
|
<Button variant="link" size="sm" onClick={() => void handleOpenVoiceDiagnostic()}>
|
||||||
语音发送诊断
|
语音发送诊断
|
||||||
|
|||||||
@@ -1,22 +1,13 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import type { PersonalWechatSenderStatus } from '../../../../shared/personal-wechat'
|
import type { PersonalWechatSenderStatus } from '../../../../shared/personal-wechat'
|
||||||
import type {
|
import { Button } from '../ui'
|
||||||
PersonalWechatRuntimeProgressEvent,
|
|
||||||
PersonalWechatRuntimeStatus
|
const GROUP_README_URL = 'https://github.com/Wxw-Gu/TraceMemo#-交流与反馈'
|
||||||
} from '../../../../shared/personal-wechat-runtime'
|
|
||||||
import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui'
|
|
||||||
import { isMac } from '../../utils/runtime-environment'
|
|
||||||
import { PersonalWechatSupportedVersionsContent } from './PersonalWechatSupportedVersionsContent'
|
|
||||||
|
|
||||||
interface PersonalWechatSetupGuideProps {
|
interface PersonalWechatSetupGuideProps {
|
||||||
runtimeStatus: PersonalWechatRuntimeStatus | null
|
|
||||||
senderStatus: PersonalWechatSenderStatus | null
|
senderStatus: PersonalWechatSenderStatus | null
|
||||||
runtimeProgress: PersonalWechatRuntimeProgressEvent | null
|
|
||||||
runtimeBusy: boolean
|
|
||||||
binding: boolean
|
binding: boolean
|
||||||
detecting: boolean
|
detecting: boolean
|
||||||
sessionBound: boolean
|
sessionBound: boolean
|
||||||
onDownloadRuntime: () => void
|
|
||||||
onBind: () => void
|
onBind: () => void
|
||||||
onStartSending: () => void
|
onStartSending: () => void
|
||||||
onOpenTextToSpeechSettings?: () => void
|
onOpenTextToSpeechSettings?: () => void
|
||||||
@@ -27,21 +18,18 @@ function capabilityLabel(ready: boolean, initializing: boolean): string {
|
|||||||
return initializing ? '初始化中' : '未就绪'
|
return initializing ? '初始化中' : '未就绪'
|
||||||
}
|
}
|
||||||
|
|
||||||
function diagnosticValue(value: unknown): string {
|
|
||||||
if (value === undefined || value === null || value === '') return '未检测到'
|
|
||||||
return String(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindingHint(status: PersonalWechatSenderStatus): string {
|
function bindingHint(status: PersonalWechatSenderStatus): string {
|
||||||
if (status.state === 'wechat_not_running') return '请先启动并登录微信。'
|
if (status.state === 'wechat_not_running') {
|
||||||
|
return '请保持微信未登录窗口状态,点击“绑定微信”后,再点击微信窗口登录。'
|
||||||
|
}
|
||||||
if (status.state === 'unsupported_platform') return '当前系统暂不支持个人微信发送。'
|
if (status.state === 'unsupported_platform') return '当前系统暂不支持个人微信发送。'
|
||||||
if (status.state === 'unsupported_version')
|
if (status.state === 'unsupported_version')
|
||||||
return '当前微信版本暂不支持,请查看微信发送设置中的支持版本。'
|
return '当前微信版本暂不支持,请联系群主确认可用版本。'
|
||||||
if (status.state === 'runtime_missing') {
|
if (status.state === 'runtime_missing') {
|
||||||
return isMac ? '请先完成 OneBot 运行时准备。' : '请先完成发送运行时准备。'
|
return '请先完成发送运行时准备。'
|
||||||
}
|
}
|
||||||
if (status.state === 'error') return '连接微信时遇到问题,请稍后重试。'
|
if (status.state === 'error') return '连接微信时遇到问题,请稍后重试。'
|
||||||
return '请启动并登录当前微信,TraceMemo 会自动绑定正在使用的账号。'
|
return '请保持微信未登录窗口状态,点击“绑定微信”后,再点击微信窗口登录。'
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWechatBound(status: PersonalWechatSenderStatus): boolean {
|
function isWechatBound(status: PersonalWechatSenderStatus): boolean {
|
||||||
@@ -54,28 +42,21 @@ function isWechatBound(status: PersonalWechatSenderStatus): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PersonalWechatSetupGuide({
|
export function PersonalWechatSetupGuide({
|
||||||
runtimeStatus,
|
|
||||||
senderStatus,
|
senderStatus,
|
||||||
runtimeProgress,
|
|
||||||
runtimeBusy,
|
|
||||||
binding,
|
binding,
|
||||||
detecting,
|
detecting,
|
||||||
sessionBound,
|
sessionBound,
|
||||||
onDownloadRuntime,
|
|
||||||
onBind,
|
onBind,
|
||||||
onStartSending,
|
onStartSending,
|
||||||
onOpenTextToSpeechSettings
|
onOpenTextToSpeechSettings
|
||||||
}: PersonalWechatSetupGuideProps): React.ReactElement {
|
}: PersonalWechatSetupGuideProps): React.ReactElement {
|
||||||
const [showSupportedVersions, setShowSupportedVersions] = useState(false)
|
const runtimeUnavailable = senderStatus?.state === 'runtime_missing'
|
||||||
const runtimeLabel = isMac ? 'OneBot 运行时' : '发送运行时'
|
const runtimeLabel = '发送运行时'
|
||||||
const runtimeReady = runtimeStatus?.state === 'ready' || senderStatus?.runtimeReady === true
|
const runtimeReady = senderStatus?.runtimeReady === true
|
||||||
const runtimeDownloading = runtimeBusy || runtimeStatus?.state === 'downloading'
|
|
||||||
const connected = sessionBound && (senderStatus ? isWechatBound(senderStatus) : false)
|
const connected = sessionBound && (senderStatus ? isWechatBound(senderStatus) : false)
|
||||||
const canSendVoice = Boolean(senderStatus?.canSendVoice)
|
const canSendVoice = Boolean(senderStatus?.canSendVoice)
|
||||||
const initializing = connected && !canSendVoice && senderStatus?.state !== 'error'
|
const initializing = connected && !canSendVoice && senderStatus?.state !== 'error'
|
||||||
const allReady = connected && Boolean(senderStatus?.canSend)
|
const allReady = connected && Boolean(senderStatus?.canSend)
|
||||||
const progress = runtimeProgress || runtimeStatus
|
|
||||||
const progressPercent = Math.max(0, Math.min(100, Math.round((progress?.progress || 0) * 100)))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="personal-wechat-setup" aria-label="微信消息功能配置">
|
<section className="personal-wechat-setup" aria-label="微信消息功能配置">
|
||||||
@@ -89,38 +70,21 @@ export function PersonalWechatSetupGuide({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ol className="personal-wechat-steps">
|
<ol className="personal-wechat-steps">
|
||||||
<li
|
<li className={runtimeReady ? 'is-complete' : 'is-current'}>
|
||||||
className={runtimeReady ? 'is-complete' : runtimeDownloading ? 'is-active' : 'is-current'}
|
|
||||||
>
|
|
||||||
<span className="personal-wechat-step-number">{runtimeReady ? '✓' : '1'}</span>
|
<span className="personal-wechat-step-number">{runtimeReady ? '✓' : '1'}</span>
|
||||||
<div className="personal-wechat-step-content">
|
<div className="personal-wechat-step-content">
|
||||||
<strong>准备 {runtimeLabel}</strong>
|
<strong>准备 {runtimeLabel}</strong>
|
||||||
<p>个人微信发送需要 {runtimeLabel},首次使用时下载一次即可。</p>
|
<p>
|
||||||
{runtimeDownloading ? (
|
{runtimeUnavailable
|
||||||
<div className="personal-wechat-download-progress" aria-live="polite">
|
? `个人微信发送需要${runtimeLabel},授权后即可使用。`
|
||||||
<div>
|
: `${runtimeLabel}随 TraceMemo 一起提供,无需额外安装。`}
|
||||||
<span>正在准备 {runtimeLabel}</span>
|
</p>
|
||||||
<span>{progressPercent}%</span>
|
{runtimeReady ? (
|
||||||
</div>
|
<span className="personal-wechat-step-status">✓ {runtimeLabel}已就绪</span>
|
||||||
<div className="personal-wechat-progress-track">
|
) : runtimeUnavailable ? (
|
||||||
<span style={{ width: `${progressPercent}%` }} />
|
<span className="personal-wechat-step-error">当前版本暂未提供微信消息发送功能</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : runtimeReady ? (
|
|
||||||
<span className="personal-wechat-step-status">✓ {runtimeLabel}已准备</span>
|
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<span className="personal-wechat-step-status">正在检查 {runtimeLabel}…</span>
|
||||||
size="sm"
|
|
||||||
onClick={onDownloadRuntime}
|
|
||||||
disabled={runtimeBusy || runtimeStatus?.state === 'unsupported'}
|
|
||||||
>
|
|
||||||
下载运行时
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{runtimeStatus?.error && !runtimeDownloading && !runtimeReady && (
|
|
||||||
<p className="personal-wechat-step-error">
|
|
||||||
运行时准备失败,请重试或查看微信发送设置。
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -139,7 +103,7 @@ export function PersonalWechatSetupGuide({
|
|||||||
<span className="personal-wechat-step-number">{connected ? '✓' : '2'}</span>
|
<span className="personal-wechat-step-number">{connected ? '✓' : '2'}</span>
|
||||||
<div className="personal-wechat-step-content">
|
<div className="personal-wechat-step-content">
|
||||||
<strong>绑定个人微信</strong>
|
<strong>绑定个人微信</strong>
|
||||||
<p>请启动并登录当前微信,TraceMemo 会自动绑定正在使用的账号。</p>
|
<p>请保持微信未登录窗口状态,点击“绑定微信”后,再点击微信窗口登录。</p>
|
||||||
{connected ? (
|
{connected ? (
|
||||||
<span className="personal-wechat-step-status">✓ 微信已绑定</span>
|
<span className="personal-wechat-step-status">✓ 微信已绑定</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -147,15 +111,28 @@ export function PersonalWechatSetupGuide({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onBind}
|
onClick={onBind}
|
||||||
disabled={!runtimeReady || binding || runtimeDownloading}
|
disabled={runtimeUnavailable || !runtimeReady || binding}
|
||||||
>
|
>
|
||||||
{binding ? '正在绑定…' : '绑定微信'}
|
{binding ? '正在绑定…' : '绑定微信'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!connected && senderStatus && (
|
{!connected && senderStatus && !runtimeUnavailable && (
|
||||||
<p className="personal-wechat-step-hint">{bindingHint(senderStatus)}</p>
|
<p className="personal-wechat-step-hint">{bindingHint(senderStatus)}</p>
|
||||||
)}
|
)}
|
||||||
{!connected && (
|
{!connected && runtimeUnavailable && (
|
||||||
|
<div className="personal-wechat-authorization-warning" role="alert">
|
||||||
|
<span aria-hidden>!</span>
|
||||||
|
<p>
|
||||||
|
发送能力属授权制,需要联系群主。请先加入交流群,然后在群内添加群主申请授权。
|
||||||
|
进群请点击{' '}
|
||||||
|
<a href={GROUP_README_URL} target="_blank" rel="noreferrer">
|
||||||
|
这里
|
||||||
|
</a>{' '}
|
||||||
|
跳转。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!connected && !runtimeUnavailable && (
|
||||||
<p className="personal-wechat-step-warning" role="note">
|
<p className="personal-wechat-step-warning" role="note">
|
||||||
绑定微信可能导致当前微信异常闪退,这是正常现象。若微信退出,请重新启动微信后,再回到这里重新检测/绑定。
|
绑定微信可能导致当前微信异常闪退,这是正常现象。若微信退出,请重新启动微信后,再回到这里重新检测/绑定。
|
||||||
<br />
|
<br />
|
||||||
@@ -163,14 +140,6 @@ export function PersonalWechatSetupGuide({
|
|||||||
通用”,取消勾选“有更新时自动升级微信”,否则版本变化后可能无法绑定。
|
通用”,取消勾选“有更新时自动升级微信”,否则版本变化后可能无法绑定。
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<Button
|
|
||||||
className="w-fit"
|
|
||||||
variant="link"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowSupportedVersions(true)}
|
|
||||||
>
|
|
||||||
查看支持的微信版本
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
@@ -225,7 +194,7 @@ export function PersonalWechatSetupGuide({
|
|||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
{!runtimeReady && runtimeStatus?.state === 'unsupported' && onOpenTextToSpeechSettings && (
|
{!runtimeReady && runtimeUnavailable && onOpenTextToSpeechSettings && (
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -235,54 +204,6 @@ export function PersonalWechatSetupGuide({
|
|||||||
查看语音设置
|
查看语音设置
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog open={showSupportedVersions} onOpenChange={setShowSupportedVersions}>
|
|
||||||
<DialogContent className="max-h-[calc(100vh-3rem)] max-w-[620px] overflow-y-auto">
|
|
||||||
<DialogHeader className="pr-8">
|
|
||||||
<DialogTitle className="text-lg">支持的微信版本</DialogTitle>
|
|
||||||
<DialogDescription>请安装下列完整版本之一。</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<PersonalWechatSupportedVersionsContent />
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<details className="personal-wechat-diagnostics">
|
|
||||||
<summary>高级诊断</summary>
|
|
||||||
<p>仅用于排查连接问题,普通使用无需关注这些信息。</p>
|
|
||||||
<dl>
|
|
||||||
{[
|
|
||||||
['微信进程', senderStatus?.wechatPid ? `PID ${senderStatus.wechatPid}` : '未检测到'],
|
|
||||||
...(isMac
|
|
||||||
? [['OneBot', senderStatus?.oneBotPid ? `PID ${senderStatus.oneBotPid}` : '未启动']]
|
|
||||||
: []),
|
|
||||||
['绑定进程', diagnosticValue(senderStatus?.boundWechatPid)],
|
|
||||||
[
|
|
||||||
'接口监听',
|
|
||||||
`${diagnosticValue(senderStatus?.endpoint)} · ${senderStatus?.endpointReady ? '监听中' : '未监听'}`
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'基址扫描',
|
|
||||||
senderStatus?.baseAddress || (senderStatus?.baseAddressReady ? '已完成' : '未完成')
|
|
||||||
],
|
|
||||||
['文字 Hook', senderStatus?.textHookReady ? '已就绪' : '未就绪'],
|
|
||||||
['图片 Hook', senderStatus?.imageHookReady ? '已就绪' : '未就绪'],
|
|
||||||
['语音能力', senderStatus?.canSendVoice ? '可发送' : '未就绪'],
|
|
||||||
['消息监听', senderStatus?.messageListenerReady ? '正常' : '未就绪'],
|
|
||||||
['微信版本', diagnosticValue(senderStatus?.wechatVersion)],
|
|
||||||
[
|
|
||||||
'运行时',
|
|
||||||
runtimeStatus?.version
|
|
||||||
? `${runtimeStatus.version} · ${runtimeStatus.state}`
|
|
||||||
: diagnosticValue(senderStatus?.runtimeReady)
|
|
||||||
]
|
|
||||||
].map(([label, value]) => (
|
|
||||||
<div key={label}>
|
|
||||||
<dt>{label}</dt>
|
|
||||||
<dd>{value}</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
</details>
|
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import type React from 'react'
|
|
||||||
|
|
||||||
export const WECHAT_VERSION_DOWNLOAD_URL = 'https://github.com/zsbai/wechat-versions/releases'
|
|
||||||
|
|
||||||
export const BUNDLED_WECHAT_VERSIONS = [
|
|
||||||
'4.1.6.12',
|
|
||||||
'4.1.6.46',
|
|
||||||
'4.1.6.47',
|
|
||||||
'4.1.7.31',
|
|
||||||
'4.1.7.55',
|
|
||||||
'4.1.7.57',
|
|
||||||
'4.1.8.28',
|
|
||||||
'4.1.8.29',
|
|
||||||
'4.1.8.104',
|
|
||||||
'4.1.8.107',
|
|
||||||
'4.1.9.52',
|
|
||||||
'4.1.9.55',
|
|
||||||
'4.1.9.58',
|
|
||||||
'4.1.10.53',
|
|
||||||
'4.1.11.53'
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export function PersonalWechatSupportedVersionsContent(): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<a
|
|
||||||
className="w-fit rounded-md border border-primary/30 bg-primary/10 px-3 py-2 text-xs font-semibold text-primary hover:border-primary"
|
|
||||||
href={WECHAT_VERSION_DOWNLOAD_URL}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
下载微信历史版本 ↗
|
|
||||||
</a>
|
|
||||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
|
||||||
{BUNDLED_WECHAT_VERSIONS.map((version) => (
|
|
||||||
<span
|
|
||||||
className="rounded-md border border-border bg-background px-2 py-2 text-center text-xs text-muted-foreground"
|
|
||||||
key={version}
|
|
||||||
>
|
|
||||||
{version}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,8 @@ import type {
|
|||||||
import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui'
|
import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui'
|
||||||
import { PersonalWechatChatComposer, type ChatMessage } from './PersonalWechatChatComposer'
|
import { PersonalWechatChatComposer, type ChatMessage } from './PersonalWechatChatComposer'
|
||||||
import type { PersonalWechatSendDialogProps } from './PersonalWechatSendDialog'
|
import type { PersonalWechatSendDialogProps } from './PersonalWechatSendDialog'
|
||||||
|
import { ReportImagePostfixInput } from './ReportImagePostfixInput'
|
||||||
|
import { useReportImagePostfixSetting } from './useReportImagePostfixSetting'
|
||||||
|
|
||||||
function fallbackStatus(error: unknown): PersonalWechatSenderStatus {
|
function fallbackStatus(error: unknown): PersonalWechatSenderStatus {
|
||||||
return {
|
return {
|
||||||
@@ -45,7 +47,7 @@ function statusLabel(status: PersonalWechatSenderStatus | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function statusText(value: string): string {
|
function statusText(value: string): string {
|
||||||
return value.replace(/OneBot|Hook/gi, '微信发送能力').replace(/个人微信发送组件/g, '微信发送能力')
|
return value.replace(/Hook/gi, '微信发送能力').replace(/个人微信发送组件/g, '微信发送能力')
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusDescription(status: PersonalWechatSenderStatus | null): string {
|
function statusDescription(status: PersonalWechatSenderStatus | null): string {
|
||||||
@@ -64,12 +66,17 @@ export function PersonalWechatWindowsSendDialog({
|
|||||||
const [status, setStatus] = useState<PersonalWechatSenderStatus | null>(null)
|
const [status, setStatus] = useState<PersonalWechatSenderStatus | null>(null)
|
||||||
const [detecting, setDetecting] = useState(true)
|
const [detecting, setDetecting] = useState(true)
|
||||||
const [sendBusy, setSendBusy] = useState(false)
|
const [sendBusy, setSendBusy] = useState(false)
|
||||||
|
const [sendError, setSendError] = useState<string | null>(null)
|
||||||
|
const [sendSuccess, setSendSuccess] = useState<string | null>(null)
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||||
const requestIdRef = useRef(0)
|
const requestIdRef = useRef(0)
|
||||||
const restoreFocusRef = useRef<HTMLElement | null>(null)
|
const restoreFocusRef = useRef<HTMLElement | null>(null)
|
||||||
const closingRef = useRef(false)
|
const closingRef = useRef(false)
|
||||||
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
|
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
|
||||||
const targetId = contact.m_nsUsrName
|
const targetId = contact.m_nsUsrName
|
||||||
|
const { postfixText, setPostfixText, persistPostfixText } = useReportImagePostfixSetting(
|
||||||
|
Boolean(initialImage)
|
||||||
|
)
|
||||||
|
|
||||||
const refreshStatus = useCallback(async (): Promise<void> => {
|
const refreshStatus = useCallback(async (): Promise<void> => {
|
||||||
const requestId = ++requestIdRef.current
|
const requestId = ++requestIdRef.current
|
||||||
@@ -103,9 +110,7 @@ export function PersonalWechatWindowsSendDialog({
|
|||||||
onOpenPersonalWechatSettings()
|
onOpenPersonalWechatSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSend = async (
|
const handleSend = async (filePath: string): Promise<{ success: boolean; error?: string }> => {
|
||||||
filePath: string
|
|
||||||
): Promise<{ success: boolean; error?: string }> => {
|
|
||||||
setSendBusy(true)
|
setSendBusy(true)
|
||||||
try {
|
try {
|
||||||
const response = await window.api.sendGeneratedTtsVoice({
|
const response = await window.api.sendGeneratedTtsVoice({
|
||||||
@@ -137,15 +142,28 @@ export function PersonalWechatWindowsSendDialog({
|
|||||||
const handleSendReportImage = async (): Promise<void> => {
|
const handleSendReportImage = async (): Promise<void> => {
|
||||||
if (!initialImage || !status?.canSendImage || sendBusy) return
|
if (!initialImage || !status?.canSendImage || sendBusy) return
|
||||||
setSendBusy(true)
|
setSendBusy(true)
|
||||||
|
setSendError(null)
|
||||||
|
setSendSuccess(null)
|
||||||
try {
|
try {
|
||||||
|
await persistPostfixText()
|
||||||
const response = await window.api.sendPersonalWechatMessage({
|
const response = await window.api.sendPersonalWechatMessage({
|
||||||
type: 'image',
|
type: 'image',
|
||||||
to: targetId,
|
to: targetId,
|
||||||
isGroup: isGroupChat,
|
isGroup: isGroupChat,
|
||||||
filePath: initialImage.path
|
filePath: initialImage.path,
|
||||||
|
postfixText
|
||||||
} satisfies PersonalWechatSendRequest)
|
} satisfies PersonalWechatSendRequest)
|
||||||
setStatus(response.status)
|
setStatus(response.status)
|
||||||
if (!response.success) return
|
if (!response.success) {
|
||||||
|
setSendError(response.error || '日报图片发送失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (response.postfixError) {
|
||||||
|
setSendError(`日报图片已发送,但${response.postfixError}`)
|
||||||
|
setSendSuccess('日报图片发送成功')
|
||||||
|
} else {
|
||||||
|
setSendSuccess(postfixText.trim() ? '日报图片和后置词发送成功' : '日报图片发送成功')
|
||||||
|
}
|
||||||
setMessages((current) => [
|
setMessages((current) => [
|
||||||
...current,
|
...current,
|
||||||
{
|
{
|
||||||
@@ -156,6 +174,8 @@ export function PersonalWechatWindowsSendDialog({
|
|||||||
outgoing: true
|
outgoing: true
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
} catch (error) {
|
||||||
|
setSendError(error instanceof Error ? error.message : String(error))
|
||||||
} finally {
|
} finally {
|
||||||
setSendBusy(false)
|
setSendBusy(false)
|
||||||
}
|
}
|
||||||
@@ -183,7 +203,7 @@ export function PersonalWechatWindowsSendDialog({
|
|||||||
<DialogHeader className="flex-row items-center justify-between space-y-0 pr-10">
|
<DialogHeader className="flex-row items-center justify-between space-y-0 pr-10">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[11px] font-bold tracking-normal text-primary">实验性功能</span>
|
<span className="text-[11px] font-bold tracking-normal text-primary">实验性功能</span>
|
||||||
<DialogTitle className="mt-0.5 text-[19px] leading-[26px] tracking-normal">
|
<DialogTitle className="mt-0.5 text-[19px] leading-[26px] tracking-normal">
|
||||||
文字转语音
|
文字转语音
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</div>
|
</div>
|
||||||
@@ -201,7 +221,7 @@ export function PersonalWechatWindowsSendDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="personal-wechat-send-target">
|
<div className="personal-wechat-send-target">
|
||||||
<span>{isGroupChat ? '发送到群聊' : '发送给联系人'}</span>
|
<span>{isGroupChat ? '发送到群聊' : '发送给联系人'}</span>
|
||||||
<strong>{displayName}</strong>
|
<strong>{displayName}</strong>
|
||||||
<code>{targetId}</code>
|
<code>{targetId}</code>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,7 +266,7 @@ export function PersonalWechatWindowsSendDialog({
|
|||||||
key={message.id}
|
key={message.id}
|
||||||
className={`personal-wechat-message-bubble ${message.outgoing ? 'is-outgoing' : ''}`}
|
className={`personal-wechat-message-bubble ${message.outgoing ? 'is-outgoing' : ''}`}
|
||||||
>
|
>
|
||||||
<span className="personal-wechat-message-kind">语音</span>
|
<span className="personal-wechat-message-kind">语音</span>
|
||||||
<span>{message.text || message.fileName}</span>
|
<span>{message.text || message.fileName}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -255,6 +275,16 @@ export function PersonalWechatWindowsSendDialog({
|
|||||||
{initialImage && status.canSendImage ? (
|
{initialImage && status.canSendImage ? (
|
||||||
<section className="personal-wechat-composer" aria-label="日报图片发送">
|
<section className="personal-wechat-composer" aria-label="日报图片发送">
|
||||||
<p>已准备日报图片:{initialImage.name}</p>
|
<p>已准备日报图片:{initialImage.name}</p>
|
||||||
|
<ReportImagePostfixInput
|
||||||
|
value={postfixText}
|
||||||
|
onChange={setPostfixText}
|
||||||
|
onBlur={() =>
|
||||||
|
void persistPostfixText().catch((error) =>
|
||||||
|
setSendError(error instanceof Error ? error.message : '发送后置词保存失败')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={sendBusy}
|
||||||
|
/>
|
||||||
<Button size="sm" onClick={() => void handleSendReportImage()} disabled={sendBusy}>
|
<Button size="sm" onClick={() => void handleSendReportImage()} disabled={sendBusy}>
|
||||||
{sendBusy ? '发送中…' : '发送日报图片'}
|
{sendBusy ? '发送中…' : '发送日报图片'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -271,6 +301,16 @@ export function PersonalWechatWindowsSendDialog({
|
|||||||
busy={sendBusy}
|
busy={sendBusy}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{sendError ? (
|
||||||
|
<div className="personal-wechat-global-error" role="alert">
|
||||||
|
{sendError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{sendSuccess ? (
|
||||||
|
<div className="personal-wechat-global-success" role="status">
|
||||||
|
{sendSuccess}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { DEFAULT_REPORT_IMAGE_POSTFIX_TEXT } from '../../../../shared/personal-wechat'
|
||||||
|
|
||||||
|
export function ReportImagePostfixInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onBlur,
|
||||||
|
disabled
|
||||||
|
}: {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
onBlur: () => void
|
||||||
|
disabled: boolean
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<label className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span className="font-medium">发送后置词</span>
|
||||||
|
<input
|
||||||
|
aria-label="发送后置词"
|
||||||
|
className="h-9 rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary disabled:opacity-60"
|
||||||
|
value={value}
|
||||||
|
maxLength={200}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
onBlur={onBlur}
|
||||||
|
placeholder={DEFAULT_REPORT_IMAGE_POSTFIX_TEXT}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
图片确认发送成功后,会发送一条文本消息;留空则只发图片。
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { DEFAULT_REPORT_IMAGE_POSTFIX_TEXT } from '../../../../shared/personal-wechat'
|
||||||
|
|
||||||
|
export function useReportImagePostfixSetting(enabled: boolean): {
|
||||||
|
postfixText: string
|
||||||
|
setPostfixText: (value: string) => void
|
||||||
|
persistPostfixText: () => Promise<void>
|
||||||
|
} {
|
||||||
|
const [postfixText, setPostfixText] = useState(DEFAULT_REPORT_IMAGE_POSTFIX_TEXT)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return
|
||||||
|
const getSettings = window.api.getSettings
|
||||||
|
if (typeof getSettings !== 'function') return
|
||||||
|
let active = true
|
||||||
|
void getSettings()
|
||||||
|
.then((result) => {
|
||||||
|
if (!active) return
|
||||||
|
setPostfixText(
|
||||||
|
String(result.settings.reportImagePostfixText ?? DEFAULT_REPORT_IMAGE_POSTFIX_TEXT)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [enabled])
|
||||||
|
|
||||||
|
const persistPostfixText = useCallback(async (): Promise<void> => {
|
||||||
|
const setSettings = window.api.setSettings
|
||||||
|
if (typeof setSettings !== 'function') return
|
||||||
|
await setSettings({ reportImagePostfixText: postfixText })
|
||||||
|
}, [postfixText])
|
||||||
|
|
||||||
|
return { postfixText, setPostfixText, persistPostfixText }
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import type { Contact } from '../../../../shared/types'
|
||||||
|
import {
|
||||||
|
GROUP_STATS_RANGE_OPTIONS,
|
||||||
|
formatActiveMemberStats,
|
||||||
|
formatMemberLineName,
|
||||||
|
formatSilentMemberStats,
|
||||||
|
formatStatsDateTime,
|
||||||
|
resolveGroupStatsRangeStart,
|
||||||
|
type GroupMemberStatsResult,
|
||||||
|
type GroupStatsRangeKey
|
||||||
|
} from '../../../../shared/group-stats'
|
||||||
|
import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, Tabs, TabsContent, TabsList, TabsTrigger } from '../ui'
|
||||||
|
|
||||||
|
interface GroupMemberStatsDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
contact: Contact
|
||||||
|
/** 跳到「设置 · 本地索引」,让用户能把没追平的索引处理掉。 */
|
||||||
|
onOpenLocalIndexSettings?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type CopyTarget = 'none' | 'active' | 'silent'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群发言统计面板(单群)。
|
||||||
|
*
|
||||||
|
* - **结果**由 main 的 `GroupStatsService` 给出,组件只展示,不自己拼业务文本;
|
||||||
|
* - **可复制的文本**由 `shared/group-stats` 的 formatter 生成,保证「界面上看到的」
|
||||||
|
* 与「复制出去的」不会各说各话;
|
||||||
|
* - **名单不截断**:数字与列表必须能对上,否则用户会以为统计漏了人。
|
||||||
|
* 列表靠 CSS 限高滚动,不靠丢数据。
|
||||||
|
*/
|
||||||
|
export function GroupMemberStatsDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
contact,
|
||||||
|
onOpenLocalIndexSettings
|
||||||
|
}: GroupMemberStatsDialogProps): React.ReactElement {
|
||||||
|
const [rangeKey, setRangeKey] = useState<GroupStatsRangeKey>('30d')
|
||||||
|
const [customStart, setCustomStart] = useState('')
|
||||||
|
const [customEnd, setCustomEnd] = useState('')
|
||||||
|
const [result, setResult] = useState<GroupMemberStatsResult | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [copied, setCopied] = useState<CopyTarget>('none')
|
||||||
|
const [notice, setNotice] = useState<string | null>(null)
|
||||||
|
/** 发送能力必须由 main 判定:renderer 侧的 platform 常量会误判 macOS Intel。 */
|
||||||
|
const [canSendText, setCanSendText] = useState(false)
|
||||||
|
const [sending, setSending] = useState(false)
|
||||||
|
|
||||||
|
const timeWindow = useMemo(() => {
|
||||||
|
if (rangeKey === 'custom') {
|
||||||
|
const start = customStart ? new Date(`${customStart}T00:00:00`).getTime() : NaN
|
||||||
|
const end = customEnd ? new Date(`${customEnd}T23:59:59.999`).getTime() : NaN
|
||||||
|
return { startTime: start, endTime: end }
|
||||||
|
}
|
||||||
|
const endTime = Date.now()
|
||||||
|
return { startTime: resolveGroupStatsRangeStart(rangeKey, endTime), endTime }
|
||||||
|
}, [rangeKey, customStart, customEnd])
|
||||||
|
|
||||||
|
const rangeReady = Number.isFinite(timeWindow.startTime) && Number.isFinite(timeWindow.endTime)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !rangeReady || !contact.md5) return
|
||||||
|
let disposed = false
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
window.api
|
||||||
|
.getGroupMemberStats({
|
||||||
|
userMd5: contact.md5,
|
||||||
|
startTime: timeWindow.startTime,
|
||||||
|
endTime: timeWindow.endTime
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (!disposed) setResult(data)
|
||||||
|
})
|
||||||
|
.catch((loadError: unknown) => {
|
||||||
|
if (!disposed) setError(loadError instanceof Error ? loadError.message : String(loadError))
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!disposed) setLoading(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
disposed = true
|
||||||
|
}
|
||||||
|
}, [open, rangeReady, contact.md5, timeWindow.startTime, timeWindow.endTime])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
let disposed = false
|
||||||
|
window.api
|
||||||
|
.getPersonalWechatSenderStatus()
|
||||||
|
.then((status) => {
|
||||||
|
if (!disposed) setCanSendText(status?.canSendText === true)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!disposed) setCanSendText(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
disposed = true
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const silentText = useMemo(() => (result ? formatSilentMemberStats(result) : ''), [result])
|
||||||
|
const activeText = useMemo(() => (result ? formatActiveMemberStats(result) : ''), [result])
|
||||||
|
|
||||||
|
const copy = useCallback(async (text: string, target: CopyTarget) => {
|
||||||
|
if (!text) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
setCopied(target)
|
||||||
|
setNotice(null)
|
||||||
|
setTimeout(() => setCopied('none'), 2000)
|
||||||
|
} catch {
|
||||||
|
setError('复制失败,请手动选择文本')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const send = useCallback(
|
||||||
|
async (text: string) => {
|
||||||
|
if (!text || sending) return
|
||||||
|
setSending(true)
|
||||||
|
setNotice(null)
|
||||||
|
try {
|
||||||
|
await window.api.sendPersonalWechatMessage({
|
||||||
|
to: contact.m_nsUsrName,
|
||||||
|
isGroup: true,
|
||||||
|
type: 'text',
|
||||||
|
text
|
||||||
|
})
|
||||||
|
setNotice('已发送到当前群')
|
||||||
|
} catch (sendError: unknown) {
|
||||||
|
setError(sendError instanceof Error ? sendError.message : '发送失败')
|
||||||
|
} finally {
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[contact.m_nsUsrName, sending]
|
||||||
|
)
|
||||||
|
|
||||||
|
const stale = result !== null && result.freshness !== 'fresh'
|
||||||
|
const groupTitle = contact.m_nsNickName || contact.m_nsUsrName
|
||||||
|
/** 备注与群名相同时不重复展示,避免标题里出现两遍同一个人。 */
|
||||||
|
const groupRemark = contact.remark && contact.remark !== groupTitle ? contact.remark : ''
|
||||||
|
/** 选「全部」时 startTime 为 0,改用本机第一条消息的真实时间作为起点。 */
|
||||||
|
const rangeStart = result
|
||||||
|
? result.startTime > 0
|
||||||
|
? result.startTime
|
||||||
|
: result.firstMessageTime
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>群发言统计 · {groupTitle}</DialogTitle>
|
||||||
|
{groupRemark ? (
|
||||||
|
<p className="group-stats-subtitle">群备注:{groupRemark}</p>
|
||||||
|
) : null}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="group-stats-panel">
|
||||||
|
<div className="group-stats-ranges">
|
||||||
|
{GROUP_STATS_RANGE_OPTIONS.map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option.key}
|
||||||
|
variant={rangeKey === option.key ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setRangeKey(option.key)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
{rangeKey === 'custom' ? (
|
||||||
|
<div className="group-stats-custom-range">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
aria-label="开始日期"
|
||||||
|
value={customStart}
|
||||||
|
onChange={(event) => setCustomStart(event.target.value)}
|
||||||
|
/>
|
||||||
|
<span>至</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
aria-label="结束日期"
|
||||||
|
value={customEnd}
|
||||||
|
onChange={(event) => setCustomEnd(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!rangeReady ? (
|
||||||
|
<p className="group-stats-hint">请选择完整的开始与结束日期。</p>
|
||||||
|
) : null}
|
||||||
|
{stale ? (
|
||||||
|
<div className="group-stats-stale">
|
||||||
|
<p role="alert" className="group-stats-warning">
|
||||||
|
本地索引尚未完全同步,以下结果可能不完整。
|
||||||
|
</p>
|
||||||
|
{onOpenLocalIndexSettings ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
// 先关面板再跳转:否则设置页会被这个 Dialog 挡在后面。
|
||||||
|
onOpenChange(false)
|
||||||
|
onOpenLocalIndexSettings()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
去建立索引
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{error ? (
|
||||||
|
<p role="alert" className="group-stats-error">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{notice ? <p className="group-stats-notice">{notice}</p> : null}
|
||||||
|
|
||||||
|
{loading ? <p className="group-stats-hint">统计中…</p> : null}
|
||||||
|
|
||||||
|
{result && !loading ? (
|
||||||
|
<>
|
||||||
|
<div className="group-stats-overview">
|
||||||
|
<div className="group-stats-metric">
|
||||||
|
<div className="group-stats-metric-label">当前群成员</div>
|
||||||
|
<div className="group-stats-metric-value">{result.memberCount}</div>
|
||||||
|
</div>
|
||||||
|
<div className="group-stats-metric">
|
||||||
|
<div className="group-stats-metric-label">发过言</div>
|
||||||
|
<div className="group-stats-metric-value">{result.activeMemberCount}</div>
|
||||||
|
</div>
|
||||||
|
<div className="group-stats-metric">
|
||||||
|
<div className="group-stats-metric-label">没发言</div>
|
||||||
|
<div className="group-stats-metric-value">{result.silentMemberCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="group-stats-range-text">
|
||||||
|
统计区间:
|
||||||
|
{rangeStart
|
||||||
|
? `${formatStatsDateTime(rangeStart)} ~ ${formatStatsDateTime(result.endTime)}`
|
||||||
|
: `全部历史 ~ ${formatStatsDateTime(result.endTime)}`}
|
||||||
|
{result.startTime <= 0 && rangeStart ? '(本机该群第一条消息起)' : null}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 两个名单用 tab 分开:一次只渲染一个列表,滚动压力减半,
|
||||||
|
而且「复制/发送」跟它作用的内容处在同一个 tab 里,不会看错对象。 */}
|
||||||
|
<Tabs defaultValue="active" className="group-stats-tabs">
|
||||||
|
<TabsList className="group-stats-tabs-list">
|
||||||
|
<TabsTrigger value="active">
|
||||||
|
发言排行({result.activeMemberCount})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="silent">
|
||||||
|
未发言统计({result.silentMemberCount})
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="active">
|
||||||
|
<div className="group-stats-section">
|
||||||
|
<div className="group-stats-section-head">
|
||||||
|
<h3>
|
||||||
|
发言成员排行
|
||||||
|
<span className="group-stats-section-count">
|
||||||
|
(共 {result.activeMemberCount} 人)
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<div className="group-stats-section-actions">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void copy(activeText, 'active')}
|
||||||
|
>
|
||||||
|
{copied === 'active' ? '已复制' : '复制'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{result.activeMembers.length === 0 ? (
|
||||||
|
<p className="group-stats-empty">该区间内没有人发言。</p>
|
||||||
|
) : (
|
||||||
|
<ol className="group-stats-list">
|
||||||
|
{result.activeMembers.map((member, index) => (
|
||||||
|
<li key={member.senderId} className="group-stats-row">
|
||||||
|
<span className="group-stats-row-name">
|
||||||
|
<span className="group-stats-row-index">{index + 1}.</span>
|
||||||
|
{formatMemberLineName(member)}
|
||||||
|
</span>
|
||||||
|
<span className="group-stats-row-meta">
|
||||||
|
{member.messageCount} 条 ·{' '}
|
||||||
|
{formatStatsDateTime(member.lastMessageTime)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="silent">
|
||||||
|
<div className="group-stats-section">
|
||||||
|
<div className="group-stats-section-head">
|
||||||
|
<h3>
|
||||||
|
未发言成员
|
||||||
|
<span className="group-stats-section-count">
|
||||||
|
(共 {result.silentMemberCount} 人)
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<div className="group-stats-section-actions">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void copy(silentText, 'silent')}
|
||||||
|
>
|
||||||
|
{copied === 'silent' ? '已复制' : '复制'}
|
||||||
|
</Button>
|
||||||
|
{canSendText ? (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
disabled={sending}
|
||||||
|
onClick={() => void send(silentText)}
|
||||||
|
>
|
||||||
|
{sending ? '发送中…' : '发送到当前群'}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{result.silentMembers.length === 0 ? (
|
||||||
|
<p className="group-stats-empty">全部成员在该区间内都发过言。</p>
|
||||||
|
) : (
|
||||||
|
<ol className="group-stats-list">
|
||||||
|
{result.silentMembers.map((member, index) => (
|
||||||
|
<li key={member.senderId} className="group-stats-row">
|
||||||
|
<span className="group-stats-row-name">
|
||||||
|
<span className="group-stats-row-index">{index + 1}.</span>
|
||||||
|
{formatMemberLineName(member)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<p className="group-stats-notes">
|
||||||
|
{result.unattributedMessages > 0
|
||||||
|
? `另有 ${result.unattributedMessages} 条消息无法归属到具体成员(未计入任何人)。`
|
||||||
|
: null}
|
||||||
|
{result.excludedSystemMessages > 0
|
||||||
|
? `已排除 ${result.excludedSystemMessages} 条系统消息。`
|
||||||
|
: null}
|
||||||
|
{result.limitations.join(' ')}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -54,6 +54,13 @@ function NavIcon({ page }: NavIconProps): React.ReactElement {
|
|||||||
<path d="M5.5 15.5v3h13v-3" />
|
<path d="M5.5 15.5v3h13v-3" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
case 'automation':
|
||||||
|
// 闪电:强调「命中即自动执行」,与退群监控的箭头区分开。
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M13 4.5 7.5 13h4l-.5 6.5L16.5 11h-4z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
case 'agent-hub':
|
case 'agent-hub':
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export type AppPage =
|
|||||||
| 'search'
|
| 'search'
|
||||||
| 'report'
|
| 'report'
|
||||||
| 'exit-monitor'
|
| 'exit-monitor'
|
||||||
|
| 'automation'
|
||||||
| 'agent-hub'
|
| 'agent-hub'
|
||||||
| 'export'
|
| 'export'
|
||||||
| 'api'
|
| 'api'
|
||||||
@@ -18,6 +19,7 @@ export const PRIMARY_NAV_ITEMS: NavigationItem[] = [
|
|||||||
{ id: 'search', label: '问问微信' },
|
{ id: 'search', label: '问问微信' },
|
||||||
{ id: 'report', label: '日报' },
|
{ id: 'report', label: '日报' },
|
||||||
{ id: 'exit-monitor', label: '退群监控' },
|
{ id: 'exit-monitor', label: '退群监控' },
|
||||||
|
{ id: 'automation', label: '自动化' },
|
||||||
{ id: 'agent-hub', label: 'Agent' },
|
{ id: 'agent-hub', label: 'Agent' },
|
||||||
{ id: 'export', label: '导出' },
|
{ id: 'export', label: '导出' },
|
||||||
{ id: 'api', label: 'API' },
|
{ id: 'api', label: 'API' },
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import { Checkbox } from '../ui'
|
|||||||
|
|
||||||
interface MessageTypeSelectorProps {
|
interface MessageTypeSelectorProps {
|
||||||
value: SummaryMessageType[]
|
value: SummaryMessageType[]
|
||||||
counts: Record<SummaryMessageType, number>
|
/**
|
||||||
|
* 每类消息的条数。
|
||||||
|
*
|
||||||
|
* 可选:`自动化 → 定时日报` 的编辑器没有真实消息统计,
|
||||||
|
* 不传就不显示数字,而不是显示一排假的 0。
|
||||||
|
*/
|
||||||
|
counts?: Record<SummaryMessageType, number>
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
onChange: (value: SummaryMessageType[]) => void
|
onChange: (value: SummaryMessageType[]) => void
|
||||||
}
|
}
|
||||||
@@ -49,7 +55,7 @@ export function MessageTypeSelector({
|
|||||||
<b>{option.label}</b>
|
<b>{option.label}</b>
|
||||||
<small>{option.description}</small>
|
<small>{option.description}</small>
|
||||||
</span>
|
</span>
|
||||||
<em>{counts[option.value]}</em>
|
{counts ? <em>{counts[option.value]}</em> : null}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,342 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
Progress,
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from '../ui'
|
||||||
|
import {
|
||||||
|
describeImageTextCoverage,
|
||||||
|
imageTextCoverageState,
|
||||||
|
imageTextPhaseLabel,
|
||||||
|
imageTextProcessedPercent,
|
||||||
|
imageTextStateLabel,
|
||||||
|
imageTextStateTone
|
||||||
|
} from '../../../../shared/image-text-index'
|
||||||
|
import { useImageTextIndexStatus } from './hooks/useImageTextIndexStatus'
|
||||||
|
import {
|
||||||
|
IndexStatusBadge,
|
||||||
|
type IndexStatusTone
|
||||||
|
} from '../../features/settings/components/IndexStatusBadge'
|
||||||
|
|
||||||
|
interface ImageTextIndexSummaryProps {
|
||||||
|
dbReady: boolean
|
||||||
|
onNotice: (message: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「图片文字索引」卡片(**设置页宽版**)。
|
||||||
|
*
|
||||||
|
* 为什么不是直接复用 `ImageTextIndexCard`:那个组件是给**问问微信侧栏**写的,
|
||||||
|
* 而侧栏最窄时正文只有约 145px(见 `search.scss` 的注释)。
|
||||||
|
* 四列 metric 布局塞进 145px 会直接崩掉 —— 反过来让侧栏继续用表格布局又浪费设置页的宽度。
|
||||||
|
* 所以两者**共享全部数据来源与文案函数**(`useImageTextIndexStatus` +
|
||||||
|
* `shared/image-text-index`),只有**版式**不同。
|
||||||
|
*
|
||||||
|
* 状态徽章、统计数字、进度条都走公共组件/函数,不存在第二套计算口径。
|
||||||
|
*/
|
||||||
|
export function ImageTextIndexSummary({
|
||||||
|
dbReady,
|
||||||
|
onNotice
|
||||||
|
}: ImageTextIndexSummaryProps): React.ReactElement {
|
||||||
|
const {
|
||||||
|
status,
|
||||||
|
count,
|
||||||
|
counting,
|
||||||
|
pending,
|
||||||
|
running,
|
||||||
|
paused,
|
||||||
|
established,
|
||||||
|
refreshCount,
|
||||||
|
start,
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
cancel,
|
||||||
|
resetFailures,
|
||||||
|
repair
|
||||||
|
} = useImageTextIndexStatus({ dbReady, onNotice })
|
||||||
|
const [confirming, setConfirming] = useState(false)
|
||||||
|
const [confirmCount, setConfirmCount] = useState<number | null>(null)
|
||||||
|
/**
|
||||||
|
* 处理范围(天)。`0` = 全部历史。
|
||||||
|
*
|
||||||
|
* 保留这个选择器是因为它来自真实业务能力,不是装饰:全量回填几万张图没法用来排查问题,
|
||||||
|
* 先跑「最近 1 天」才能证明链路真的通了。
|
||||||
|
*/
|
||||||
|
const [rangeDays, setRangeDays] = useState('0')
|
||||||
|
const sinceMs = useMemo(() => {
|
||||||
|
const days = Number(rangeDays)
|
||||||
|
return Number.isFinite(days) && days > 0 ? Date.now() - days * 24 * 60 * 60 * 1000 : undefined
|
||||||
|
}, [rangeDays])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dbReady) return
|
||||||
|
void refreshCount(sinceMs)
|
||||||
|
}, [dbReady, sinceMs, refreshCount])
|
||||||
|
|
||||||
|
const coverage = status?.coverage ?? null
|
||||||
|
const progress = status?.progress ?? null
|
||||||
|
const coverageState = coverage ? imageTextCoverageState(coverage) : 'not_built'
|
||||||
|
const percent = coverage
|
||||||
|
? imageTextProcessedPercent(coverage.processed, coverage.totalImageMessages)
|
||||||
|
: 0
|
||||||
|
const interrupted = paused || progress?.state === 'cancelled'
|
||||||
|
const phaseLabel = progress?.currentPhase ? imageTextPhaseLabel(progress.currentPhase) : null
|
||||||
|
|
||||||
|
const labelInput = {
|
||||||
|
progressState: progress?.state,
|
||||||
|
running,
|
||||||
|
paused,
|
||||||
|
established,
|
||||||
|
coverageState,
|
||||||
|
percent
|
||||||
|
}
|
||||||
|
const stateLabel = imageTextStateLabel(labelInput)
|
||||||
|
const tone = imageTextStateTone(labelInput) as IndexStatusTone
|
||||||
|
|
||||||
|
const detectedImages = count?.totalImageMessages ?? coverage?.totalImageMessages ?? null
|
||||||
|
const nothingCounted =
|
||||||
|
count !== null && count.scannedConversations === 0 && count.failedConversations > 0
|
||||||
|
const failureCount = coverage?.failed ?? 0
|
||||||
|
|
||||||
|
const requestStart = async (): Promise<void> => {
|
||||||
|
const fresh = await refreshCount(sinceMs)
|
||||||
|
setConfirmCount(fresh?.totalImageMessages ?? detectedImages)
|
||||||
|
setConfirming(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="local-index-card" aria-label="图片文字索引状态">
|
||||||
|
<header className="local-index-card-head">
|
||||||
|
<div className="local-index-card-title">
|
||||||
|
<h3>图片文字索引</h3>
|
||||||
|
<p>本地 OCR 提取图片文字,让截图、报价图也能被搜到</p>
|
||||||
|
</div>
|
||||||
|
<IndexStatusBadge label={stateLabel} tone={tone} />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* 未建立:先给一个可核对的图片量,再让用户决定跑不跑 */}
|
||||||
|
{!established && !running ? (
|
||||||
|
<>
|
||||||
|
<div className="local-index-stats">
|
||||||
|
<div className="local-index-stat">
|
||||||
|
<strong className="local-index-stat-value">
|
||||||
|
{counting
|
||||||
|
? '统计中…'
|
||||||
|
: nothingCounted
|
||||||
|
? '无法统计'
|
||||||
|
: detectedImages === null
|
||||||
|
? '—'
|
||||||
|
: detectedImages.toLocaleString()}
|
||||||
|
</strong>
|
||||||
|
<span className="local-index-stat-label">检测到图片消息</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="local-index-inline">
|
||||||
|
<span className="local-index-stat-label">处理范围</span>
|
||||||
|
<Select value={rangeDays} onValueChange={setRangeDays}>
|
||||||
|
<SelectTrigger className="h-7 w-[110px] text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="0">全部历史</SelectItem>
|
||||||
|
<SelectItem value="1">近 1 天</SelectItem>
|
||||||
|
<SelectItem value="7">近 7 天</SelectItem>
|
||||||
|
<SelectItem value="30">近 30 天</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{nothingCounted ? (
|
||||||
|
<p className="local-index-note is-error">
|
||||||
|
{`无法统计本账号的图片消息(${count?.error || '读取消息表失败'})。这不代表没有图片,可以重新统计再试一次。`}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* 运行中 / 暂停:原地切进度,按钮按真实状态显隐,不允许重复点 */}
|
||||||
|
{(running || paused) && progress ? (
|
||||||
|
<div className="local-index-progress">
|
||||||
|
<div className="local-index-progress-head">
|
||||||
|
<span>识别图片文字</span>
|
||||||
|
<span className="local-index-progress-count">
|
||||||
|
{progress.processed.toLocaleString()} / {progress.totalImageMessages.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* 复用项目 `Progress`(更新页 / 导出任务中心同一组件),不另起一套轨道样式。 */}
|
||||||
|
<Progress
|
||||||
|
value={Math.min(100, Math.max(0, progress.percent))}
|
||||||
|
aria-label="图片文字索引进度"
|
||||||
|
/>
|
||||||
|
<p className="local-index-note">
|
||||||
|
{phaseLabel ?? '正在识别…'}
|
||||||
|
{` · 识别出文字 ${progress.indexed.toLocaleString()} · 无文字 ${progress.empty.toLocaleString()}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* 已建立:四个可核对的数字 */}
|
||||||
|
{established && !running && !paused && coverage ? (
|
||||||
|
<>
|
||||||
|
<div className="local-index-stats is-four">
|
||||||
|
<div className="local-index-stat">
|
||||||
|
<strong className="local-index-stat-value">
|
||||||
|
{coverage.indexed.toLocaleString()}
|
||||||
|
</strong>
|
||||||
|
<span className="local-index-stat-label">已识别文字</span>
|
||||||
|
</div>
|
||||||
|
<div className="local-index-stat">
|
||||||
|
<strong className="local-index-stat-value">{coverage.empty.toLocaleString()}</strong>
|
||||||
|
<span className="local-index-stat-label">无文字图片</span>
|
||||||
|
</div>
|
||||||
|
<div className="local-index-stat">
|
||||||
|
<strong className="local-index-stat-value">
|
||||||
|
{coverage.missing.toLocaleString()}
|
||||||
|
</strong>
|
||||||
|
<span className="local-index-stat-label">图片已清理</span>
|
||||||
|
</div>
|
||||||
|
<div className="local-index-stat">
|
||||||
|
<strong className="local-index-stat-value">{coverage.failed.toLocaleString()}</strong>
|
||||||
|
<span className="local-index-stat-label">识别失败</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{progress?.state === 'error' ? (
|
||||||
|
<p className="local-index-note is-error">
|
||||||
|
{progress.lastError || '图片文字索引建立失败,可以稍后重试。'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{coverage?.systemicFailure === true ? (
|
||||||
|
<p className="local-index-note is-error">
|
||||||
|
{`${(coverage.failed ?? 0).toLocaleString()} 条处理失败,成功识别 0 条 —— 当前无法搜索图片中的文字。`}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{paused ? (
|
||||||
|
<p className="local-index-note">已暂停。已识别的结果都保留了,点「继续」会从断点接着做。</p>
|
||||||
|
) : null}
|
||||||
|
{!dbReady ? (
|
||||||
|
<p className="local-index-note is-error">请先连接微信数据,然后再建立图片文字索引。</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* footer:左侧覆盖量说明,右侧操作。按钮不再悬浮在 stats 右下角。 */}
|
||||||
|
<div className="local-index-footer">
|
||||||
|
{established && !running && !paused && coverage ? (
|
||||||
|
<p className="local-index-note">{describeImageTextCoverage(coverage)}</p>
|
||||||
|
) : null}
|
||||||
|
<div className="local-index-actions">
|
||||||
|
{running ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={pending !== null}
|
||||||
|
onClick={() => void pause()}
|
||||||
|
>
|
||||||
|
{pending === 'pause' ? '暂停中…' : '暂停'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={pending !== null}
|
||||||
|
onClick={() => void cancel()}
|
||||||
|
>
|
||||||
|
{pending === 'cancel' ? '取消中…' : '取消'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* 中断过就一定要有「继续」:checkpoint 是保留的,续做不会从头开始。 */}
|
||||||
|
{interrupted ? (
|
||||||
|
<Button size="sm" disabled={pending !== null} onClick={() => void resume()}>
|
||||||
|
{pending === 'resume' ? '继续中…' : '继续'}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!running && !interrupted ? (
|
||||||
|
<>
|
||||||
|
{/* 修复类操作各自只在很窄的场景有用,收进「更多」;
|
||||||
|
平铺出来会让用户面对四个都带「索引」字样的按钮。 */}
|
||||||
|
{established || failureCount > 0 ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
aria-label="更多操作"
|
||||||
|
disabled={pending !== null}
|
||||||
|
>
|
||||||
|
···
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{established ? (
|
||||||
|
<DropdownMenuItem disabled={pending !== null} onSelect={() => void repair()}>
|
||||||
|
图片内容搜不到?修复搜索索引
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{failureCount > 0 ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
disabled={pending !== null}
|
||||||
|
onSelect={() => void resetFailures()}
|
||||||
|
>
|
||||||
|
{`重试识别失败的图片(${failureCount.toLocaleString()} 张)`}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!dbReady || pending !== null || counting}
|
||||||
|
onClick={() => void requestStart()}
|
||||||
|
>
|
||||||
|
{/* 统计图片数期间按钮是禁用的;这时如实说"统计中",
|
||||||
|
而不是灰着一个写着「更新索引」却点不动的按钮。 */}
|
||||||
|
{counting ? '统计中…' : established ? '更新索引' : '建立图片文字索引'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 确认弹窗复用 `confirming` 状态;保持与侧栏卡片一致的二次确认语义。 */}
|
||||||
|
{confirming ? (
|
||||||
|
<div className="local-index-confirm" role="dialog" aria-label="建立图片文字索引">
|
||||||
|
<p>
|
||||||
|
当前账号检测到约{' '}
|
||||||
|
<strong>
|
||||||
|
{confirmCount === null ? '未知数量' : confirmCount.toLocaleString()} 条图片消息
|
||||||
|
</strong>
|
||||||
|
。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
识别仅在本机进行,原始图片不会因为本地识别而自动上传;可能需要较长时间,可以暂停稍后继续。
|
||||||
|
</p>
|
||||||
|
<div className="local-index-actions">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setConfirming(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setConfirming(false)
|
||||||
|
void start({ ...(sinceMs ? { sinceMs } : {}) })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
开始索引
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useKnowledgeStatus } from './hooks/useKnowledgeStatus'
|
||||||
|
import {
|
||||||
|
formatBytes,
|
||||||
|
formatIndexDate,
|
||||||
|
formatKnowledgeProcessed,
|
||||||
|
knowledgeIsStale,
|
||||||
|
knowledgeStateLabel
|
||||||
|
} from './searchFormatters'
|
||||||
|
import {
|
||||||
|
IndexStatusBadge,
|
||||||
|
type IndexStatusTone
|
||||||
|
} from '../../features/settings/components/IndexStatusBadge'
|
||||||
|
import { Button } from '../ui'
|
||||||
|
|
||||||
|
interface KnowledgeIndexCardProps {
|
||||||
|
dbReady: boolean
|
||||||
|
onNotice: (message: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「聊天记录索引」卡片 —— 问问微信背后那份索引。
|
||||||
|
*
|
||||||
|
* 布局与服务端语义分得很开:
|
||||||
|
* - 数据全部来自 `useKnowledgeStatus`(与问问微信页**同一个 hook**,不存在第二套状态源);
|
||||||
|
* - 文案全部来自 `searchFormatters`(那边的词汇表有约束:区分「正在追新」与「正在补齐历史」、
|
||||||
|
* 不可查询时吃掉「可用」前缀、禁止笼统的「已同步」);
|
||||||
|
* - 这里只负责**信息层级**:标题 → 状态徽章 → 三个核心数字 → 操作。
|
||||||
|
*
|
||||||
|
* 状态刻意从标题里拆出来单独做成徽章:混在标题行里时既没有视觉权重差、也扫不到。
|
||||||
|
*/
|
||||||
|
export function KnowledgeIndexCard({
|
||||||
|
dbReady,
|
||||||
|
onNotice
|
||||||
|
}: KnowledgeIndexCardProps): React.ReactElement {
|
||||||
|
const {
|
||||||
|
knowledgeStatus,
|
||||||
|
knowledgeSyncing,
|
||||||
|
syncStarting,
|
||||||
|
cancelRequested,
|
||||||
|
startKnowledgeSync,
|
||||||
|
cancelKnowledgeSync
|
||||||
|
} = useKnowledgeStatus({ dbReady, onNotice })
|
||||||
|
|
||||||
|
const indexed = knowledgeStatus?.indexedMessageCount ?? 0
|
||||||
|
const usable = indexed > 0 || (knowledgeStatus?.indexedChunkCount ?? 0) > 0
|
||||||
|
const stale = knowledgeIsStale(knowledgeStatus)
|
||||||
|
const failed = knowledgeStatus?.state === 'error'
|
||||||
|
|
||||||
|
/** 徽章色只用主题 token,不硬编码彩虹色。 */
|
||||||
|
const tone: IndexStatusTone = failed
|
||||||
|
? 'error'
|
||||||
|
: !usable
|
||||||
|
? 'idle'
|
||||||
|
: stale || knowledgeSyncing
|
||||||
|
? 'warn'
|
||||||
|
: 'ok'
|
||||||
|
|
||||||
|
const cancellable = knowledgeStatus?.pass?.cancellable === true
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="local-index-card" aria-label="聊天记录索引状态">
|
||||||
|
<header className="local-index-card-head">
|
||||||
|
<div className="local-index-card-title">
|
||||||
|
<h3>聊天记录索引</h3>
|
||||||
|
<p>用于搜索、问问微信和群聊统计</p>
|
||||||
|
</div>
|
||||||
|
<IndexStatusBadge label={knowledgeStateLabel(knowledgeStatus)} tone={tone} />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{usable ? (
|
||||||
|
<div className="local-index-stats">
|
||||||
|
<div className="local-index-stat">
|
||||||
|
<strong className="local-index-stat-value">{indexed.toLocaleString()}</strong>
|
||||||
|
<span className="local-index-stat-label">已收录消息</span>
|
||||||
|
</div>
|
||||||
|
<div className="local-index-stat">
|
||||||
|
<strong className="local-index-stat-value">
|
||||||
|
{formatBytes(knowledgeStatus?.databaseBytes ?? 0)}
|
||||||
|
</strong>
|
||||||
|
<span className="local-index-stat-label">占用空间</span>
|
||||||
|
</div>
|
||||||
|
<div className="local-index-stat">
|
||||||
|
<strong className="local-index-stat-value">
|
||||||
|
{knowledgeStatus?.indexLatestAt
|
||||||
|
? formatIndexDate(knowledgeStatus.indexLatestAt)
|
||||||
|
: '—'}
|
||||||
|
</strong>
|
||||||
|
<span className="local-index-stat-label">最后更新</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="local-index-note">
|
||||||
|
还没有建立索引。建立后「问问微信」才能跨会话检索历史消息。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{failed && knowledgeStatus?.lastError ? (
|
||||||
|
<p className="local-index-note is-error">{knowledgeStatus.lastError}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!dbReady ? (
|
||||||
|
<p className="local-index-note is-error">请先连接微信数据,然后再建立聊天记录索引。</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* footer:左侧状态说明,右侧操作。按钮不再悬浮在 stats 右下角。 */}
|
||||||
|
<div className="local-index-footer">
|
||||||
|
<p className="local-index-note">
|
||||||
|
{knowledgeSyncing && knowledgeStatus?.pass
|
||||||
|
? formatKnowledgeProcessed(knowledgeStatus)
|
||||||
|
: failed
|
||||||
|
? '上次同步没有完成,可以重新同步。'
|
||||||
|
: stale
|
||||||
|
? '有较新的聊天记录尚未入索引,点「同步最新记录」追平。'
|
||||||
|
: '索引数据仅保存在本机,可随时重新建立。'}
|
||||||
|
</p>
|
||||||
|
<div className="local-index-actions">
|
||||||
|
{knowledgeSyncing && cancellable ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={cancelRequested}
|
||||||
|
onClick={() => void cancelKnowledgeSync()}
|
||||||
|
>
|
||||||
|
{cancelRequested ? '正在取消…' : '取消同步'}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{/* 用项目 `Button` 而不是自写 `<button>` + 自己的主色样式:
|
||||||
|
`hsl(var(--tm-primary))` 在这套主题变量下不生效(实测背景回退成了浏览器默认灰),
|
||||||
|
而复用组件同时还省掉了一套与全站不一致的按钮外观。 */}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!dbReady || syncStarting || (knowledgeSyncing && !cancellable)}
|
||||||
|
onClick={() => void startKnowledgeSync()}
|
||||||
|
>
|
||||||
|
{knowledgeSyncing ? '同步中…' : usable ? '同步最新记录' : '建立索引'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { SegmentedControl, SegmentedControlItem } from '../../components/ui'
|
||||||
|
import { AUTOMATION_RULE_TYPE_LABELS, type AutomationRuleType } from '../../../../shared/automation'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tab 上能出现的规则类型。
|
||||||
|
*
|
||||||
|
* `scheduled_report` 是真实的 `AutomationRuleType`;这个别名只为让调用点的
|
||||||
|
* 语义更清楚 —— 它等价于 `AutomationRuleType`。
|
||||||
|
*/
|
||||||
|
export type AutomationRuleTabType = AutomationRuleType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tab 顺序 = 触发方式的演进:消息触发 → 时间触发 → 系统事件触发。
|
||||||
|
*/
|
||||||
|
const RULE_TABS: AutomationRuleTabType[] = [
|
||||||
|
'daily_report',
|
||||||
|
'scheduled_report',
|
||||||
|
'leave_notification'
|
||||||
|
]
|
||||||
|
|
||||||
|
const TAB_LABELS: Record<AutomationRuleTabType, string> = AUTOMATION_RULE_TYPE_LABELS
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AutomationRuleTypeTabs —— 编辑器上方的**规则类型切换**。
|
||||||
|
*
|
||||||
|
* 它切的是"现在在编辑哪一类规则",**不是**"把当前这条规则改成另一类"。
|
||||||
|
* 文案必须说清这一点:叫「规则类型」时,用户会以为它是当前规则的类型选择器,
|
||||||
|
* 于是"切一下再保存"就意外改到了另一条规则上。
|
||||||
|
*
|
||||||
|
* 两项都是**真规则**:切换只改前端选中态,不碰任何已保存的配置。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function AutomationRuleTypeTabs({
|
||||||
|
value,
|
||||||
|
onValueChange
|
||||||
|
}: {
|
||||||
|
value: AutomationRuleTabType
|
||||||
|
onValueChange: (next: AutomationRuleTabType) => void
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="automation-rule-type-tabs">
|
||||||
|
<span className="automation-rule-type-label">切换规则类型</span>
|
||||||
|
<SegmentedControl
|
||||||
|
value={value}
|
||||||
|
onValueChange={(next) => onValueChange(next as AutomationRuleTabType)}
|
||||||
|
aria-label="切换规则类型"
|
||||||
|
>
|
||||||
|
{RULE_TABS.map((type) => (
|
||||||
|
<SegmentedControlItem key={type} value={type}>
|
||||||
|
{TAB_LABELS[type]}
|
||||||
|
</SegmentedControlItem>
|
||||||
|
))}
|
||||||
|
</SegmentedControl>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,798 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import {
|
||||||
|
BUILTIN_DAILY_REPORT_RULE_ID,
|
||||||
|
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
|
||||||
|
calculateNextRunAt,
|
||||||
|
normalizeScheduledReportConfig,
|
||||||
|
type AutomationExecution,
|
||||||
|
type AutomationRule,
|
||||||
|
type AutomationRuleDraft,
|
||||||
|
type AutomationStatusSummary
|
||||||
|
} from '../../../../shared/automation'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
SegmentedControl,
|
||||||
|
SegmentedControlItem,
|
||||||
|
Spinner,
|
||||||
|
useToast
|
||||||
|
} from '../../components/ui'
|
||||||
|
import { RuleListPanel } from './RuleListPanel'
|
||||||
|
import { RuleEditorPanel } from './RuleEditorPanel'
|
||||||
|
import { ExecutionLogPanel } from './ExecutionLogPanel'
|
||||||
|
import { ExecutionDetailDrawer } from './ExecutionDetailDrawer'
|
||||||
|
import {
|
||||||
|
AutomationRuleTypeTabs,
|
||||||
|
type AutomationRuleTabType
|
||||||
|
} from './AutomationRuleTypeTabs'
|
||||||
|
import { ScheduledReportEditor } from './ScheduledReportEditor'
|
||||||
|
import { ScheduledReportRuleList } from './ScheduledReportRuleList'
|
||||||
|
import { ScheduledReportNotificationPanel } from './ScheduledReportNotificationPanel'
|
||||||
|
import {
|
||||||
|
createDraftFromRule,
|
||||||
|
createEmptyScheduledReportDraft,
|
||||||
|
draftToAutomationRuleDraft,
|
||||||
|
formatNextRunAt,
|
||||||
|
resolveGroupDisplayName,
|
||||||
|
type ScheduledReportDraft
|
||||||
|
} from './model/scheduled-report-model'
|
||||||
|
import {
|
||||||
|
LeaveNotificationEditor,
|
||||||
|
type LeaveNotificationSaveInput
|
||||||
|
} from './LeaveNotificationEditor'
|
||||||
|
import type { LeaveNotificationContactOption } from './LeaveNotificationTargetPicker'
|
||||||
|
import { UNAVAILABLE_STATUS, automationApi, isAutomationApiAvailable, type AutomationGroupOption } from './model/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AutomationWorkspace —— 自动化一级菜单。
|
||||||
|
*
|
||||||
|
* 两个 tab:规则 / 执行日志。顶部三块状态(监听 / 发送能力 / 今日执行)
|
||||||
|
* **全部来自 main 的真实能力**,渲染层不做任何平台猜测 ——
|
||||||
|
* 粗平台判断在这里是错的:装了 macOS 但没绑定个人微信,照样发不出去。
|
||||||
|
*
|
||||||
|
* 「规则」内部再分一层**规则类型**:@我生成日报 / 退群通知。
|
||||||
|
* 两种类型现在都是**真实规则**,保存都会落盘。
|
||||||
|
*/
|
||||||
|
|
||||||
|
type AutomationTab = 'rules' | 'logs'
|
||||||
|
|
||||||
|
/** 由「退群监控」深链过来时的请求:直接打开指定规则类型。 */
|
||||||
|
export interface AutomationOpenRuleRequest {
|
||||||
|
ruleType: AutomationRuleTabType
|
||||||
|
/** 每次点击都要能重新触发,所以带一个自增/时间戳。 */
|
||||||
|
requestId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutomationWorkspaceProps {
|
||||||
|
dbReady: boolean
|
||||||
|
/** 发送能力不可用时,引导用户去设置页。 */
|
||||||
|
onOpenSendSettings?: () => void
|
||||||
|
/** 「管理监控群聊 →」:跳到退群监控的管理群聊页(纯导航)。 */
|
||||||
|
onOpenExitMonitorGroups?: () => void
|
||||||
|
/** 「更改模型」:复用现有的模型设置入口(定时日报的模型配置)。 */
|
||||||
|
onOpenModelSettings?: () => void
|
||||||
|
/** 「微信异常通知」依赖 Agent Hub:未就绪时给一个可操作的去处。 */
|
||||||
|
onOpenAgentHub?: () => void
|
||||||
|
/** 深链请求(来自退群监控页的「退群通知自动化」入口)。 */
|
||||||
|
openRuleRequest?: AutomationOpenRuleRequest | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorState {
|
||||||
|
mode: 'create' | 'edit'
|
||||||
|
rule: AutomationRule | null
|
||||||
|
/** 用 Tab 类型:定时日报只在本层存在,不进真实 `AutomationRule`。 */
|
||||||
|
ruleType: AutomationRuleTabType
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutomationWorkspace({
|
||||||
|
dbReady,
|
||||||
|
onOpenSendSettings,
|
||||||
|
onOpenExitMonitorGroups,
|
||||||
|
onOpenModelSettings,
|
||||||
|
onOpenAgentHub,
|
||||||
|
openRuleRequest
|
||||||
|
}: AutomationWorkspaceProps): React.ReactElement {
|
||||||
|
const { toast } = useToast()
|
||||||
|
const apiAvailable = React.useMemo(() => isAutomationApiAvailable(), [])
|
||||||
|
|
||||||
|
const [tab, setTab] = React.useState<AutomationTab>('rules')
|
||||||
|
const [status, setStatus] = React.useState<AutomationStatusSummary>(UNAVAILABLE_STATUS)
|
||||||
|
const [rules, setRules] = React.useState<AutomationRule[]>([])
|
||||||
|
const [groups, setGroups] = React.useState<AutomationGroupOption[]>([])
|
||||||
|
const [executions, setExecutions] = React.useState<AutomationExecution[]>([])
|
||||||
|
const [sendableContacts, setSendableContacts] = React.useState<LeaveNotificationContactOption[]>([])
|
||||||
|
const [monitoredGroupCount, setMonitoredGroupCount] = React.useState(0)
|
||||||
|
/** 已监控群聊清单(含显示名)—— 退群通知「通知群聊」二次勾选的候选项。 */
|
||||||
|
const [monitoredGroups, setMonitoredGroups] = React.useState<AutomationGroupOption[]>([])
|
||||||
|
const [loading, setLoading] = React.useState(true)
|
||||||
|
const [clearing, setClearing] = React.useState(false)
|
||||||
|
const [saving, setSaving] = React.useState(false)
|
||||||
|
const [busyRuleId, setBusyRuleId] = React.useState<string | null>(null)
|
||||||
|
const [editor, setEditor] = React.useState<EditorState | null>(null)
|
||||||
|
const [selectedExecution, setSelectedExecution] = React.useState<AutomationExecution | null>(null)
|
||||||
|
const [pendingDelete, setPendingDelete] = React.useState<AutomationRule | null>(null)
|
||||||
|
|
||||||
|
const reload = React.useCallback(async (): Promise<void> => {
|
||||||
|
const [
|
||||||
|
nextStatus,
|
||||||
|
nextRules,
|
||||||
|
nextGroups,
|
||||||
|
nextExecutions,
|
||||||
|
nextContacts,
|
||||||
|
nextMonitored,
|
||||||
|
nextMonitoredGroups
|
||||||
|
] = await Promise.all([
|
||||||
|
automationApi.getStatus(),
|
||||||
|
automationApi.listRules(),
|
||||||
|
automationApi.listGroups(),
|
||||||
|
automationApi.listExecutions(100),
|
||||||
|
// 「指定好友」候选、已监控群聊数量与清单:都来自既有能力,不在自动化里另存一份。
|
||||||
|
automationApi.listSendableContacts(),
|
||||||
|
automationApi.getMonitoredGroupCount(),
|
||||||
|
automationApi.getMonitoredGroups()
|
||||||
|
])
|
||||||
|
setStatus(nextStatus)
|
||||||
|
setRules(nextRules)
|
||||||
|
setGroups(nextGroups)
|
||||||
|
setExecutions(nextExecutions)
|
||||||
|
setSendableContacts(nextContacts)
|
||||||
|
setMonitoredGroupCount(nextMonitored)
|
||||||
|
setMonitoredGroups(nextMonitoredGroups)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let disposed = false
|
||||||
|
setLoading(true)
|
||||||
|
void reload().finally(() => {
|
||||||
|
if (!disposed) setLoading(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
disposed = true
|
||||||
|
}
|
||||||
|
}, [reload])
|
||||||
|
|
||||||
|
// 切到日志 tab 时刷新一次:规则跑完后台不会主动推事件给渲染层,
|
||||||
|
// 用户点进来看到的是上一次加载的快照才是真的误导。
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (tab !== 'logs') return
|
||||||
|
void reload()
|
||||||
|
}, [tab, reload])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「退群监控 → 退群通知自动化」的深链。
|
||||||
|
*
|
||||||
|
* 落到「规则 → 退群通知」这一层,而不是只跳到自动化首页 ——
|
||||||
|
* 否则用户还得自己再找一次。请求对象本身在父层是稳定的,
|
||||||
|
* 所以依赖整个对象;requestId 变化就代表又点了一次。
|
||||||
|
*/
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!openRuleRequest) return
|
||||||
|
setTab('rules')
|
||||||
|
setEditor({ mode: 'edit', rule: null, ruleType: openRuleRequest.ruleType })
|
||||||
|
}, [openRuleRequest])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前编辑目标(日报)—— **UI 显示与保存共用这一个**。
|
||||||
|
*
|
||||||
|
* 这里保留"回落到内置日报"的兜底,但它同时驱动下面的「正在编辑:X」,
|
||||||
|
* 所以即使回落发生,用户也能看到真实目标,不会再出现"以为在改 A、实际改了 B"。
|
||||||
|
*/
|
||||||
|
const dailyReportTarget =
|
||||||
|
editor?.ruleType === 'daily_report'
|
||||||
|
? (editor.rule ?? rules.find((item) => item.id === BUILTIN_DAILY_REPORT_RULE_ID) ?? null)
|
||||||
|
: null
|
||||||
|
|
||||||
|
/** 退群通知是 singleton 规则,永远从规则表里取(不存在则由 main 侧播种)。 */
|
||||||
|
const leaveNotificationRule =
|
||||||
|
rules.find((item) => item.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID) ?? null
|
||||||
|
|
||||||
|
|
||||||
|
/** 「指定好友」目标的显示名(首页卡片用;拿不到时留空,不伪造)。 */
|
||||||
|
const leaveContactName = React.useMemo((): string => {
|
||||||
|
const target = leaveNotificationRule?.leaveNotification?.target
|
||||||
|
if (!target || target.type !== 'contact') return ''
|
||||||
|
return sendableContacts.find((contact) => contact.id === target.contactId)?.name ?? ''
|
||||||
|
}, [leaveNotificationRule, sendableContacts])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记住"上一次在日报这一类里编辑的那条"。
|
||||||
|
*
|
||||||
|
* 切类型只是换一类规则来编辑,切回来时应当回到刚才那条 ——
|
||||||
|
* 否则用户从"某条日报"切走再切回,会被悄悄带到内置日报上。
|
||||||
|
*/
|
||||||
|
const lastDailyTargetRef = React.useRef<AutomationRule | null>(null)
|
||||||
|
|
||||||
|
/** 切到「@我生成日报」时的编辑目标:上次那条 → 内置日报 → 新建。 */
|
||||||
|
const resolveDailyTarget = React.useCallback((): AutomationRule | null => {
|
||||||
|
return (
|
||||||
|
lastDailyTargetRef.current ??
|
||||||
|
rules.find((item) => item.id === BUILTIN_DAILY_REPORT_RULE_ID) ??
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}, [rules])
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ---------- 定时日报(真实规则,真写入)----------
|
||||||
|
*
|
||||||
|
* `scheduled_report` 是真实的 `AutomationRuleType`,所以这一层
|
||||||
|
* **不做任何数据副本**:列表 = `rules.filter(ruleType==='scheduled_report')`,
|
||||||
|
* 保存 / 启停 / 删除 / 立即执行全部经 `automation:*` IPC 落到
|
||||||
|
* `AutomationRuleStore` 与 `AutomationService`。
|
||||||
|
*/
|
||||||
|
const scheduledRules = React.useMemo(
|
||||||
|
() => rules.filter((rule) => rule.ruleType === 'scheduled_report'),
|
||||||
|
[rules]
|
||||||
|
)
|
||||||
|
/** `null` = 看列表;非空 = 在编辑器里(`ruleId` 为 `null` 表示新建)。 */
|
||||||
|
const [scheduledEditor, setScheduledEditor] = React.useState<{ ruleId: string | null } | null>(
|
||||||
|
null
|
||||||
|
)
|
||||||
|
const [scheduledDraft, setScheduledDraft] = React.useState<ScheduledReportDraft | null>(null)
|
||||||
|
|
||||||
|
/** 「正在编辑」显示的名字(与保存目标同源)。 */
|
||||||
|
const editorTargetName = React.useMemo((): string => {
|
||||||
|
if (!editor) return ''
|
||||||
|
if (editor.ruleType === 'leave_notification') return leaveNotificationRule?.name ?? '退群通知'
|
||||||
|
if (editor.ruleType === 'scheduled_report') {
|
||||||
|
return scheduledDraft?.name?.trim() || '新建定时日报'
|
||||||
|
}
|
||||||
|
return dailyReportTarget?.name ?? '新建自动化'
|
||||||
|
}, [editor, leaveNotificationRule, dailyReportTarget, scheduledDraft])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首页汇总卡:数量 / 运行中 / 最近一次「下次执行」。
|
||||||
|
* 数据直接来自真实规则 —— 加载中不显示(避免闪一个假的"0 条")。
|
||||||
|
*/
|
||||||
|
const scheduledSummary = React.useMemo(() => {
|
||||||
|
if (loading) return null
|
||||||
|
const now = new Date()
|
||||||
|
const running = scheduledRules.filter((rule) => rule.enabled)
|
||||||
|
const nextRuns = running
|
||||||
|
.map((rule) => normalizeScheduledReportConfig(rule.scheduledReport))
|
||||||
|
.filter((config) => !config.targetNeedsReview)
|
||||||
|
.map((config) => calculateNextRunAt(config.schedule.time, now))
|
||||||
|
.sort()
|
||||||
|
return {
|
||||||
|
total: scheduledRules.length,
|
||||||
|
running: running.length,
|
||||||
|
nextRunLabel: nextRuns.length ? formatNextRunAt(nextRuns[0]) : '—'
|
||||||
|
}
|
||||||
|
}, [scheduledRules, loading])
|
||||||
|
|
||||||
|
/** 群标识 → 显示名(列表与编辑器共用,保证任何地方都不出现裸 id)。 */
|
||||||
|
const resolveScheduledGroupDisplay = React.useCallback(
|
||||||
|
(raw: string): string => resolveGroupDisplayName(raw, groups),
|
||||||
|
[groups]
|
||||||
|
)
|
||||||
|
|
||||||
|
const openScheduledEditor = (rule: AutomationRule | null): void => {
|
||||||
|
setScheduledDraft(rule ? createDraftFromRule(rule) : createEmptyScheduledReportDraft())
|
||||||
|
setScheduledEditor({ ruleId: rule?.id ?? null })
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeScheduledEditor = (): void => {
|
||||||
|
setScheduledEditor(null)
|
||||||
|
setScheduledDraft(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存:**真的**创建 / 更新一条 `scheduled_report` 规则。 */
|
||||||
|
const handleScheduledSave = async (draft: ScheduledReportDraft): Promise<void> => {
|
||||||
|
if (!scheduledEditor) return
|
||||||
|
const target = scheduledEditor.ruleId
|
||||||
|
? (scheduledRules.find((rule) => rule.id === scheduledEditor.ruleId) ?? null)
|
||||||
|
: null
|
||||||
|
// 编辑态但目标不存在 ⇒ 报错,绝不静默新建(与 @我日报同一条红线)。
|
||||||
|
if (scheduledEditor.ruleId && !target) {
|
||||||
|
toast({
|
||||||
|
description: '要编辑的定时日报已不存在,请返回列表重新打开',
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 3600
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
const payload = draftToAutomationRuleDraft(draft)
|
||||||
|
const saved =
|
||||||
|
scheduledEditor.ruleId && target
|
||||||
|
? await automationApi.updateRule(target.id, payload)
|
||||||
|
: await automationApi.createRule(payload)
|
||||||
|
setSaving(false)
|
||||||
|
if (!saved) {
|
||||||
|
toast({ description: '保存失败,请稍后重试', variant: 'destructive', duration: 3200 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRules((current) =>
|
||||||
|
current.some((item) => item.id === saved.id)
|
||||||
|
? current.map((item) => (item.id === saved.id ? saved : item))
|
||||||
|
: [...current, saved]
|
||||||
|
)
|
||||||
|
closeScheduledEditor()
|
||||||
|
toast({
|
||||||
|
description: scheduledEditor.ruleId ? `已保存「${saved.name}」` : `已创建「${saved.name}」`,
|
||||||
|
duration: 2800
|
||||||
|
})
|
||||||
|
void automationApi.getStatus().then(setStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 立即执行:走与 scheduler 完全相同的执行链路(manual trigger)。 */
|
||||||
|
const handleScheduledRunNow = async (rule: AutomationRule): Promise<void> => {
|
||||||
|
setBusyRuleId(rule.id)
|
||||||
|
const result = await automationApi.runScheduledReportRule(rule.id)
|
||||||
|
setBusyRuleId(null)
|
||||||
|
toast({
|
||||||
|
description: result.success
|
||||||
|
? `「${rule.name}」已执行完成`
|
||||||
|
: result.error || '定时日报执行失败,请查看执行日志',
|
||||||
|
variant: result.success ? undefined : 'destructive',
|
||||||
|
duration: 3600
|
||||||
|
})
|
||||||
|
// 执行结果落在 Automation Execution Log,切到日志 tab 就能看到。
|
||||||
|
void reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启停:真写 store。 */
|
||||||
|
const handleScheduledToggle = async (rule: AutomationRule, enabled: boolean): Promise<void> => {
|
||||||
|
setBusyRuleId(rule.id)
|
||||||
|
const updated = await automationApi.setRuleEnabled(rule.id, enabled)
|
||||||
|
setBusyRuleId(null)
|
||||||
|
if (!updated) {
|
||||||
|
toast({ description: '切换失败,规则状态未改变', variant: 'destructive', duration: 3200 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRules((current) => current.map((item) => (item.id === updated.id ? updated : item)))
|
||||||
|
void automationApi.getStatus().then(setStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除:走统一的确认弹层(与其它规则类型一致)。 */
|
||||||
|
const handleScheduledDelete = (rule: AutomationRule): void => {
|
||||||
|
setPendingDelete(rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggle = async (rule: AutomationRule, enabled: boolean): Promise<void> => {
|
||||||
|
setBusyRuleId(rule.id)
|
||||||
|
const updated = await automationApi.setRuleEnabled(rule.id, enabled)
|
||||||
|
setBusyRuleId(null)
|
||||||
|
if (!updated) {
|
||||||
|
toast({ description: '切换失败,规则状态未改变', variant: 'destructive', duration: 3200 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRules((current) => current.map((item) => (item.id === updated.id ? updated : item)))
|
||||||
|
void automationApi.getStatus().then(setStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async (draft: AutomationRuleDraft): Promise<void> => {
|
||||||
|
if (!editor) return
|
||||||
|
|
||||||
|
const target = editor.ruleType === 'daily_report' ? dailyReportTarget : editor.rule
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 编辑态但目标不存在 ⇒ **报错,绝不许静默新建**。
|
||||||
|
*
|
||||||
|
* 这里以前写的是 `editor.mode === 'create' || !target ? createRule : updateRule` ——
|
||||||
|
* 把"没有目标"当成了"新建",于是用户以为在改 A、实际多出一条新规则。
|
||||||
|
* 新建只能由 `mode === 'create'` 决定;`edit` 且无目标是不可恢复的状态,必须说出来。
|
||||||
|
*/
|
||||||
|
if (editor.mode === 'edit' && !target) {
|
||||||
|
toast({
|
||||||
|
description: '要编辑的规则已不存在,请返回列表重新打开',
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 3600
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
let saved: AutomationRule | null = null
|
||||||
|
if (editor.mode === 'create') {
|
||||||
|
saved = await automationApi.createRule(draft)
|
||||||
|
} else if (target) {
|
||||||
|
saved = await automationApi.updateRule(target.id, draft)
|
||||||
|
}
|
||||||
|
setSaving(false)
|
||||||
|
if (!saved) {
|
||||||
|
toast({ description: '保存失败,请稍后重试', variant: 'destructive', duration: 3200 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRules((current) => {
|
||||||
|
const exists = current.some((item) => item.id === saved.id)
|
||||||
|
return exists
|
||||||
|
? current.map((item) => (item.id === saved.id ? saved : item))
|
||||||
|
: [...current, saved]
|
||||||
|
})
|
||||||
|
setEditor(null)
|
||||||
|
toast({
|
||||||
|
description: editor.mode === 'create' ? `已创建「${saved.name}」` : `已保存「${saved.name}」`,
|
||||||
|
duration: 2800
|
||||||
|
})
|
||||||
|
void automationApi.getStatus().then(setStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存「退群通知」规则。
|
||||||
|
*
|
||||||
|
* 走独立的 singleton upsert 通道:这条规则 id 固定,保存永远不会多出一条。
|
||||||
|
* 保存成功后清掉迁移遗留的 `targetNeedsReview`(用户已经做过选择)。
|
||||||
|
*/
|
||||||
|
const handleLeaveNotificationSave = async (
|
||||||
|
input: LeaveNotificationSaveInput
|
||||||
|
): Promise<void> => {
|
||||||
|
// 静默 return 会让"点保存没反应"变成用户眼里的坏掉,必须说出来。
|
||||||
|
if (!leaveNotificationRule) {
|
||||||
|
toast({
|
||||||
|
description: '退群通知规则已不存在,请返回列表重新打开',
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 3600
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
const saved = await automationApi.saveLeaveNotificationRule({
|
||||||
|
...leaveNotificationRule,
|
||||||
|
enabled: input.enabled,
|
||||||
|
leaveNotification: input.config
|
||||||
|
})
|
||||||
|
setSaving(false)
|
||||||
|
if (!saved) {
|
||||||
|
toast({ description: '保存失败,请稍后重试', variant: 'destructive', duration: 3200 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRules((current) =>
|
||||||
|
current.some((item) => item.id === saved.id)
|
||||||
|
? current.map((item) => (item.id === saved.id ? saved : item))
|
||||||
|
: [...current, saved]
|
||||||
|
)
|
||||||
|
setEditor(null)
|
||||||
|
toast({ description: `已保存「${saved.name}」`, duration: 2800 })
|
||||||
|
void automationApi.getStatus().then(setStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = async (): Promise<void> => {
|
||||||
|
if (!pendingDelete) return
|
||||||
|
const target = pendingDelete
|
||||||
|
setPendingDelete(null)
|
||||||
|
setBusyRuleId(target.id)
|
||||||
|
const removed = await automationApi.deleteRule(target.id)
|
||||||
|
setBusyRuleId(null)
|
||||||
|
if (!removed) {
|
||||||
|
toast({ description: '删除失败,规则仍然存在', variant: 'destructive', duration: 3200 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRules((current) => current.filter((item) => item.id !== target.id))
|
||||||
|
toast({ description: `已删除「${target.name}」`, duration: 2800 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearExecutions = async (): Promise<void> => {
|
||||||
|
setClearing(true)
|
||||||
|
const cleared = await automationApi.clearExecutions()
|
||||||
|
setClearing(false)
|
||||||
|
if (!cleared) {
|
||||||
|
toast({ description: '清空失败,请稍后重试', variant: 'destructive', duration: 3200 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setExecutions([])
|
||||||
|
void automationApi.getStatus().then(setStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
const capability = status.sendCapability
|
||||||
|
const capabilityReady = capability.ready && capability.canSendText && capability.canSendImage
|
||||||
|
const showEditor = tab === 'rules' && editor !== null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="automation-page">
|
||||||
|
<header className="automation-page-header">
|
||||||
|
<div>
|
||||||
|
<h1>自动化</h1>
|
||||||
|
<p>当群里出现符合条件的消息时,TraceMemo 会自动执行你配置的动作。</p>
|
||||||
|
</div>
|
||||||
|
{tab === 'rules' && !showEditor ? (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
lastDailyTargetRef.current = null
|
||||||
|
setEditor({ mode: 'create', rule: null, ruleType: 'daily_report' })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
新建自动化
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{!apiAvailable ? (
|
||||||
|
<p className="automation-notice warning">
|
||||||
|
自动化接口尚未就绪(可能正在启动或版本不匹配)。下面的数据可能不是最新的。
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{!dbReady ? (
|
||||||
|
<p className="automation-notice warning">
|
||||||
|
微信数据库尚未连接,规则可以编辑,但无法读取群列表,也不会触发。
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="automation-status-bar" aria-label="自动化状态">
|
||||||
|
<div className="automation-status-card">
|
||||||
|
<span className="automation-status-label">消息监听</span>
|
||||||
|
<span className={`automation-status-value ${status.listening ? 'ok' : 'off'}`}>
|
||||||
|
{status.listening ? '运行中' : '未在监听'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="automation-status-card">
|
||||||
|
<span className="automation-status-label">发送能力</span>
|
||||||
|
<span className={`automation-status-value ${capabilityReady ? 'ok' : 'warn'}`}>
|
||||||
|
{capabilityReady ? '可以发送文字和图片' : capability.ready ? '发送能力不完整' : '尚未就绪'}
|
||||||
|
</span>
|
||||||
|
<small className="automation-status-note">{capability.message}</small>
|
||||||
|
{!capabilityReady && onOpenSendSettings ? (
|
||||||
|
<Button variant="link" size="sm" onClick={onOpenSendSettings}>
|
||||||
|
去设置发送能力
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="automation-status-card">
|
||||||
|
<span className="automation-status-label">今日执行</span>
|
||||||
|
<span className="automation-status-value">
|
||||||
|
{status.todaySuccesses} / {status.todayExecutions}
|
||||||
|
</span>
|
||||||
|
<small className="automation-status-note">成功 / 总计</small>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{!showEditor ? (
|
||||||
|
<SegmentedControl
|
||||||
|
value={tab}
|
||||||
|
onValueChange={(value) => setTab(value as AutomationTab)}
|
||||||
|
aria-label="自动化视图"
|
||||||
|
>
|
||||||
|
<SegmentedControlItem value="rules">规则</SegmentedControlItem>
|
||||||
|
<SegmentedControlItem value="logs">执行日志</SegmentedControlItem>
|
||||||
|
</SegmentedControl>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showEditor && editor ? (
|
||||||
|
<div className="automation-rule-type-shell">
|
||||||
|
{/*
|
||||||
|
切类型 = **换一条规则来编辑**,不是"把这条规则改成另一类"。
|
||||||
|
|
||||||
|
以前这里只改 `ruleType`,把上个类型的 `mode`/`rule` 一起带过来,
|
||||||
|
于是"从退群通知切到日报"会留下 `mode='edit' + rule=null` 的中间态,
|
||||||
|
保存时再隐式回落到内置日报 —— 用户以为在改退群通知,实际改掉了日报。
|
||||||
|
现在切换时**显式重算编辑目标**,并把当前目标名显示出来。
|
||||||
|
*/}
|
||||||
|
<AutomationRuleTypeTabs
|
||||||
|
value={editor.ruleType}
|
||||||
|
onValueChange={(next) => {
|
||||||
|
if (next === editor.ruleType) return
|
||||||
|
// 离开定时日报时清掉它的编辑态,下次进来回到列表。
|
||||||
|
closeScheduledEditor()
|
||||||
|
if (next === 'scheduled_report') {
|
||||||
|
// 定时日报是**多条**规则:先给列表,不直接进编辑器。
|
||||||
|
setEditor({ mode: 'edit', rule: null, ruleType: 'scheduled_report' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (next === 'leave_notification') {
|
||||||
|
// 退群通知是 singleton:编辑目标就是它自己,不依赖规则列表。
|
||||||
|
setEditor({ mode: 'edit', rule: null, ruleType: 'leave_notification' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 日报可以有多条:目标必须是**具体某一条**或"新建",
|
||||||
|
// 绝不允许留下 mode='edit' 却没有目标的状态。
|
||||||
|
const target = resolveDailyTarget()
|
||||||
|
lastDailyTargetRef.current = target
|
||||||
|
setEditor(
|
||||||
|
target
|
||||||
|
? { mode: 'edit', rule: target, ruleType: 'daily_report' }
|
||||||
|
: { mode: 'create', rule: null, ruleType: 'daily_report' }
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
编辑目标必须**可见**。
|
||||||
|
保存的目标与这里显示的名字来自**同一个变量** —— 不允许 UI 显示一个、保存改另一个。
|
||||||
|
*/}
|
||||||
|
{editor.ruleType !== 'scheduled_report' || scheduledEditor ? (
|
||||||
|
<p className="automation-editing-target" data-testid="automation-editing-target">
|
||||||
|
正在编辑:<strong>{editorTargetName}</strong>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{editor.ruleType === 'daily_report' ? (
|
||||||
|
/*
|
||||||
|
* 编辑态却没有目标(规则被删/列表刷新后消失)—— 不能让用户对着一个
|
||||||
|
* 标题写着「编辑自动化」的空表单填半天,最后保存时报错。
|
||||||
|
*/
|
||||||
|
editor.mode === 'edit' && !dailyReportTarget ? (
|
||||||
|
<div className="automation-notice warning automation-notice-stack">
|
||||||
|
<p>要编辑的规则已不存在(可能已被删除)。</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setEditor(null)}>
|
||||||
|
返回规则列表
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<RuleEditorPanel
|
||||||
|
mode={editor.mode}
|
||||||
|
rule={dailyReportTarget}
|
||||||
|
groups={groups}
|
||||||
|
saving={saving}
|
||||||
|
onCancel={() => setEditor(null)}
|
||||||
|
onSave={(draft) => void handleSave(draft)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : editor.ruleType === 'scheduled_report' ? (
|
||||||
|
scheduledEditor && scheduledDraft ? (
|
||||||
|
<ScheduledReportEditor
|
||||||
|
// 换一条规则就重建编辑器,避免把上一条的草稿带过来。
|
||||||
|
key={scheduledEditor.ruleId ?? 'new'}
|
||||||
|
initialDraft={scheduledDraft}
|
||||||
|
groups={groups}
|
||||||
|
sendableContacts={sendableContacts}
|
||||||
|
saving={saving}
|
||||||
|
onBack={closeScheduledEditor}
|
||||||
|
onCancel={closeScheduledEditor}
|
||||||
|
onSave={(draft) => void handleScheduledSave(draft)}
|
||||||
|
{...(onOpenModelSettings ? { onOpenModelSettings } : {})}
|
||||||
|
{...(scheduledEditor.ruleId
|
||||||
|
? (() => {
|
||||||
|
const rule = scheduledRules.find(
|
||||||
|
(item) => item.id === scheduledEditor.ruleId
|
||||||
|
)
|
||||||
|
const legacy = rule?.scheduledReport?.legacyTarget
|
||||||
|
return legacy ? { legacyTarget: legacy } : {}
|
||||||
|
})()
|
||||||
|
: {})}
|
||||||
|
notificationSlot={
|
||||||
|
<ScheduledReportNotificationPanel
|
||||||
|
rule={scheduledRules.find((item) => item.id === scheduledEditor.ruleId) ?? null}
|
||||||
|
{...(onOpenAgentHub ? { onOpenAgentHub } : {})}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ScheduledReportRuleList
|
||||||
|
rules={scheduledRules}
|
||||||
|
loading={loading}
|
||||||
|
busyRuleId={busyRuleId}
|
||||||
|
resolveGroupDisplay={resolveScheduledGroupDisplay}
|
||||||
|
// 与另外两个类型的「取消」等价:退出本类型,回到规则面板。
|
||||||
|
// 少了这一个,用户在定时日报列表上就只能靠切 tab 绕出去。
|
||||||
|
onBack={() => setEditor(null)}
|
||||||
|
onCreate={() => openScheduledEditor(null)}
|
||||||
|
onEdit={openScheduledEditor}
|
||||||
|
onRunNow={(rule) => void handleScheduledRunNow(rule)}
|
||||||
|
onToggle={(rule, enabled) => void handleScheduledToggle(rule, enabled)}
|
||||||
|
onDelete={handleScheduledDelete}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : leaveNotificationRule ? (
|
||||||
|
<LeaveNotificationEditor
|
||||||
|
rule={leaveNotificationRule}
|
||||||
|
contacts={sendableContacts}
|
||||||
|
monitoredCount={monitoredGroupCount}
|
||||||
|
monitoredGroups={monitoredGroups}
|
||||||
|
saving={saving}
|
||||||
|
{...(capabilityReady
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
// 只提示,不阻断编辑。
|
||||||
|
sendCapabilityWarning:
|
||||||
|
'当前发送能力未就绪:退群事件仍会被记录,但通知发送会失败。'
|
||||||
|
})}
|
||||||
|
onOpenMonitoredGroups={onOpenExitMonitorGroups}
|
||||||
|
onCancel={() => setEditor(null)}
|
||||||
|
onSave={(input) => void handleLeaveNotificationSave(input)}
|
||||||
|
/>
|
||||||
|
) : loading ? (
|
||||||
|
/*
|
||||||
|
* **正在读,不是"没有"。**
|
||||||
|
*
|
||||||
|
* 从「退群监控 → 退群通知自动化」深链进来时,编辑器是同步打开的,
|
||||||
|
* 而规则列表还在 IPC 回来的路上 —— 这一帧必然还没有规则。
|
||||||
|
* 旧写法在这里直接渲染「尚未就绪 / 版本不匹配」,把最正常的一种
|
||||||
|
* 加载状态说成了故障,必须区分开。
|
||||||
|
*/
|
||||||
|
<div className="automation-loading">
|
||||||
|
<Spinner />
|
||||||
|
<span>正在读取退群通知规则…</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/*
|
||||||
|
* 读完了**确实没有**这条规则。
|
||||||
|
*
|
||||||
|
* `builtin-leave-notification` 由主进程 `AutomationRuleStore` 首次加载时自动播种,
|
||||||
|
* 所以走到这里通常意味着:跑着的主进程还是迁移前的旧构建
|
||||||
|
* (渲染层已经升级,`out/main` 没有)。如实说明并给一个重试入口。
|
||||||
|
*/
|
||||||
|
<div className="automation-notice warning automation-notice-stack">
|
||||||
|
<p>没有找到内置的退群通知规则。</p>
|
||||||
|
<small>
|
||||||
|
这条规则应由主进程在首次启动时自动创建。如果你刚更新过版本,
|
||||||
|
请重启 TraceMemo —— 已经跑起来的主进程不会自动换成新代码。
|
||||||
|
</small>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => {
|
||||||
|
setLoading(true)
|
||||||
|
void reload().finally(() => setLoading(false))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重新加载
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : tab === 'rules' ? (
|
||||||
|
<RuleListPanel
|
||||||
|
rules={rules}
|
||||||
|
groups={groups}
|
||||||
|
loading={loading}
|
||||||
|
busyRuleId={busyRuleId}
|
||||||
|
leaveNotification={
|
||||||
|
leaveNotificationRule
|
||||||
|
? {
|
||||||
|
rule: leaveNotificationRule,
|
||||||
|
monitoredCount: monitoredGroupCount,
|
||||||
|
contactName: leaveContactName
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
scheduledReport={scheduledSummary}
|
||||||
|
onManageScheduledReport={() =>
|
||||||
|
setEditor({ mode: 'edit', rule: null, ruleType: 'scheduled_report' })
|
||||||
|
}
|
||||||
|
onToggle={(rule, enabled) => void handleToggle(rule, enabled)}
|
||||||
|
onEdit={(rule) => {
|
||||||
|
// 记住这条,切类型回来时能恢复 —— 不记住就会被悄悄换成内置日报。
|
||||||
|
lastDailyTargetRef.current = rule
|
||||||
|
setEditor({ mode: 'edit', rule, ruleType: rule.ruleType ?? 'daily_report' })
|
||||||
|
}}
|
||||||
|
onDelete={(rule) => setPendingDelete(rule)}
|
||||||
|
onCreate={() => {
|
||||||
|
// 新建是"还没有对象",所以不记住任何规则。
|
||||||
|
lastDailyTargetRef.current = null
|
||||||
|
setEditor({ mode: 'create', rule: null, ruleType: 'daily_report' })
|
||||||
|
}}
|
||||||
|
onEditLeaveNotification={() =>
|
||||||
|
setEditor({ mode: 'edit', rule: null, ruleType: 'leave_notification' })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ExecutionLogPanel
|
||||||
|
executions={executions}
|
||||||
|
loading={loading}
|
||||||
|
clearing={clearing}
|
||||||
|
onSelect={setSelectedExecution}
|
||||||
|
onClear={() => void handleClearExecutions()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ExecutionDetailDrawer
|
||||||
|
execution={selectedExecution}
|
||||||
|
onClose={() => setSelectedExecution(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{pendingDelete ? (
|
||||||
|
<div className="automation-confirm-layer" role="presentation" onClick={() => setPendingDelete(null)}>
|
||||||
|
<div
|
||||||
|
className="automation-confirm"
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="删除自动化"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2>删除「{pendingDelete.name}」?</h2>
|
||||||
|
<p>删除后不会再触发,已有的执行记录会保留。</p>
|
||||||
|
<div className="automation-confirm-actions">
|
||||||
|
<Button variant="ghost" onClick={() => setPendingDelete(null)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={() => void confirmDelete()}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import type { AutomationRuleDraft } from '../../../../shared/automation'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EffectPreview —— 微信式效果模拟预览。
|
||||||
|
*
|
||||||
|
* **纯前端模拟**:不调用真实微信发送、不调日报生成、不调 AI。
|
||||||
|
* 它的全部作用是在用户按下保存之前,把「这条规则到底会做什么」演一遍。
|
||||||
|
*
|
||||||
|
* 与表单**逐项联动**(这是验收点):
|
||||||
|
* - 改关键词 → 左侧模拟消息里的内容跟着变;
|
||||||
|
* - 改回复文案 → 右侧气泡跟着变;
|
||||||
|
* - 关闭「回复确认」→ 右侧气泡消失;
|
||||||
|
* - 关闭「必须真正 @我」→ 左侧不再显示 `@你`;
|
||||||
|
* - 关闭「生成日报」→ 不再出图片,并说明原因;
|
||||||
|
* - 关闭「发送图片」→ 日报仍会生成但不会进群,说明区分开。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SAMPLE_SENDER_NAME = '张三'
|
||||||
|
|
||||||
|
function isActionEnabled(draft: AutomationRuleDraft, type: string): boolean {
|
||||||
|
return draft.actions.some((action) => action.type === type && action.enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
function replyText(draft: AutomationRuleDraft): string {
|
||||||
|
const action = draft.actions.find((item) => item.type === 'replyText' && item.enabled)
|
||||||
|
return action?.text?.trim() || '(未填写回复内容)'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 左侧模拟消息的正文:`@你 今日<关键词>`。关键词为空时给一句通用占位,而不是伪造关键词。 */
|
||||||
|
function sampleIncomingText(draft: AutomationRuleDraft): string {
|
||||||
|
const keyword = draft.conditions.keyword.trim()
|
||||||
|
const body = keyword ? `今日${keyword}` : '今天群里有什么新消息'
|
||||||
|
return draft.conditions.requireMentionMe ? `@你 ${body}` : body
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EffectPreview({ draft }: { draft: AutomationRuleDraft }): React.ReactElement {
|
||||||
|
const mentionMe = draft.conditions.requireMentionMe
|
||||||
|
const hasReply = isActionEnabled(draft, 'replyText')
|
||||||
|
const hasReport = isActionEnabled(draft, 'generateReport')
|
||||||
|
const hasSend = isActionEnabled(draft, 'sendReportImage')
|
||||||
|
const showReportImage = hasReport && hasSend
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="automation-preview" aria-label="效果模拟预览">
|
||||||
|
<div className="automation-preview-heading">
|
||||||
|
<h3>效果预览</h3>
|
||||||
|
<span className="automation-preview-badge">仅预览,不会真实发送</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="automation-preview-phone">
|
||||||
|
<div className="automation-preview-chat">
|
||||||
|
{/* 左侧:触发消息 */}
|
||||||
|
<div className="automation-chat-row incoming">
|
||||||
|
<span className="automation-chat-avatar" aria-hidden="true">
|
||||||
|
{SAMPLE_SENDER_NAME.slice(0, 1)}
|
||||||
|
</span>
|
||||||
|
<div className="automation-chat-body">
|
||||||
|
<span className="automation-chat-name">{SAMPLE_SENDER_NAME}</span>
|
||||||
|
<span className="automation-chat-bubble">{sampleIncomingText(draft)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mentionMe ? (
|
||||||
|
<p className="automation-preview-hint">
|
||||||
|
只有当对方<strong>真正</strong> @ 你时才会触发;正文里手打的「@昵称」不算。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="automation-preview-hint">
|
||||||
|
当前不要求 @你,该会话内任何含关键词的消息都会触发。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 右侧:自动回复 */}
|
||||||
|
{hasReply ? (
|
||||||
|
<div className="automation-chat-row outgoing">
|
||||||
|
<div className="automation-chat-body">
|
||||||
|
<span className="automation-chat-name">我</span>
|
||||||
|
<span className="automation-chat-bubble">{replyText(draft)}</span>
|
||||||
|
</div>
|
||||||
|
<span className="automation-chat-avatar self" aria-hidden="true">
|
||||||
|
我
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="automation-preview-hint muted">未启用「回复确认」,命中后不会先回一句。</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 右侧:日报图片 */}
|
||||||
|
{showReportImage ? (
|
||||||
|
<div className="automation-chat-row outgoing">
|
||||||
|
<div className="automation-chat-body">
|
||||||
|
<span className="automation-chat-name">我</span>
|
||||||
|
<figure className="automation-chat-image">
|
||||||
|
<div className="automation-chat-image-canvas" aria-hidden="true">
|
||||||
|
<span className="automation-chat-image-title">群聊日报</span>
|
||||||
|
<span className="automation-chat-image-line" />
|
||||||
|
<span className="automation-chat-image-line short" />
|
||||||
|
<span className="automation-chat-image-line" />
|
||||||
|
<span className="automation-chat-image-line short" />
|
||||||
|
</div>
|
||||||
|
<figcaption>日报图片预览</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
<span className="automation-chat-avatar self" aria-hidden="true">
|
||||||
|
我
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="automation-preview-hint muted">
|
||||||
|
{!hasReport
|
||||||
|
? '未启用「生成日报」,命中后不会产出日报。'
|
||||||
|
: '日报仍会生成,但未启用发送,不会发到群里。'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="automation-preview-facts">
|
||||||
|
<div>
|
||||||
|
<dt>生效范围</dt>
|
||||||
|
<dd>
|
||||||
|
{draft.conditions.conversationIds.length
|
||||||
|
? `已选 ${draft.conditions.conversationIds.length} 个群`
|
||||||
|
: '所有群聊'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>触发间隔</dt>
|
||||||
|
<dd>{draft.cooldownSeconds > 0 ? `${draft.cooldownSeconds} 秒` : '不限制'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>自己发的消息</dt>
|
||||||
|
<dd>{draft.conditions.ignoreSelf ? '不触发' : '也会触发'}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { AUTOMATION_EXECUTION_STATUS_LABELS } from '../../../../shared/automation'
|
||||||
|
import type { AutomationExecution } from '../../../../shared/automation'
|
||||||
|
import { Button } from '../../components/ui'
|
||||||
|
import {
|
||||||
|
STEP_STATUS_LABELS,
|
||||||
|
describeSkippedStep,
|
||||||
|
formatClockTime,
|
||||||
|
formatDuration,
|
||||||
|
formatTriggerTime,
|
||||||
|
stepStatusTone
|
||||||
|
} from './model/format'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ExecutionDetailDrawer —— 执行详情抽屉(对应 Stitch 设计稿 3 的右半部分)。
|
||||||
|
*
|
||||||
|
* 用户来这里只想知道一件事:**到底坏在哪一步**。
|
||||||
|
* 所以步骤列表是主体,每个失败步骤都要把原因写在脸上;
|
||||||
|
* 「已跳过」的步骤还要说明是「上一步挂了」还是「规则本来就没配」。
|
||||||
|
*
|
||||||
|
* 抽屉而不是居中弹窗:详情是「从列表里钻进去看」,右侧抽屉保留了列表的位置感。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ExecutionDetailDrawerProps {
|
||||||
|
execution: AutomationExecution | null
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExecutionDetailDrawer({
|
||||||
|
execution,
|
||||||
|
onClose
|
||||||
|
}: ExecutionDetailDrawerProps): React.ReactElement | null {
|
||||||
|
const open = execution !== null
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||||
|
if (event.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [open, onClose])
|
||||||
|
|
||||||
|
if (!execution) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="automation-drawer-layer" role="presentation" onClick={onClose}>
|
||||||
|
<aside
|
||||||
|
className="automation-drawer"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="执行详情"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<header className="automation-drawer-header">
|
||||||
|
<div>
|
||||||
|
<span className="automation-drawer-eyebrow">执行详情</span>
|
||||||
|
<h2>{execution.ruleName}</h2>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="automation-drawer-body">
|
||||||
|
<dl className="automation-detail-facts">
|
||||||
|
<div>
|
||||||
|
<dt>触发时间</dt>
|
||||||
|
<dd>{formatTriggerTime(execution.triggerTime)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>来源</dt>
|
||||||
|
<dd>{execution.sourceDisplayName}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>结果</dt>
|
||||||
|
<dd>
|
||||||
|
<span className={`automation-status-chip ${execution.status}`}>
|
||||||
|
{AUTOMATION_EXECUTION_STATUS_LABELS[execution.status]}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>总耗时</dt>
|
||||||
|
<dd>{formatDuration(execution.durationMs)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{execution.errorSummary ? (
|
||||||
|
<p className="automation-detail-error">
|
||||||
|
<strong>失败原因:</strong>
|
||||||
|
{execution.errorSummary}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="automation-detail-steps">
|
||||||
|
<h3>执行步骤</h3>
|
||||||
|
{execution.steps.length === 0 ? (
|
||||||
|
<p className="automation-detail-empty">
|
||||||
|
本次执行没有产生步骤记录(可能在发送能力检查阶段就被拦下了)。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ol className="automation-step-list">
|
||||||
|
{execution.steps.map((step) => (
|
||||||
|
<li key={step.key} className={`automation-step ${stepStatusTone(step.status)}`}>
|
||||||
|
<span className="automation-step-marker" aria-hidden="true" />
|
||||||
|
<div className="automation-step-body">
|
||||||
|
<div className="automation-step-title">
|
||||||
|
<span className="automation-step-label">{step.label}</span>
|
||||||
|
<span className="automation-step-status">
|
||||||
|
{STEP_STATUS_LABELS[step.status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="automation-step-meta">
|
||||||
|
{step.status === 'skipped' ? (
|
||||||
|
<span>{describeSkippedStep(step, execution.steps)}</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>{formatClockTime(step.startedAt)}</span>
|
||||||
|
<span className="automation-step-sep" aria-hidden="true">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
<span>{formatClockTime(step.finishedAt)}</span>
|
||||||
|
<span className="automation-step-duration">
|
||||||
|
{formatDuration(step.durationMs)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{step.detail ? (
|
||||||
|
<p className="automation-step-detail">{step.detail}</p>
|
||||||
|
) : null}
|
||||||
|
{step.error ? <p className="automation-step-error">{step.error}</p> : null}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { AUTOMATION_EXECUTION_STATUS_LABELS } from '../../../../shared/automation'
|
||||||
|
import type { AutomationExecution } from '../../../../shared/automation'
|
||||||
|
import { Button, Spinner } from '../../components/ui'
|
||||||
|
import { STEP_STATUS_LABELS, formatDuration, formatTriggerTime, stepStatusTone } from './model/format'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ExecutionLogPanel —— 执行日志列表(对应 Stitch 设计稿 3 的左半部分)。
|
||||||
|
*
|
||||||
|
* 每行是一条**用户层**记录:时间 / 规则名 / 来源 / 结果 / 耗时 / 步骤轨迹。
|
||||||
|
*
|
||||||
|
* 刻意不展示 wxid、localId、serverId、source XML、原始 payload 与图片路径 ——
|
||||||
|
* `AutomationExecution` 里本来就不带这些字段,这里也不允许从别处补。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ExecutionLogPanelProps {
|
||||||
|
executions: AutomationExecution[]
|
||||||
|
loading: boolean
|
||||||
|
clearing: boolean
|
||||||
|
onSelect: (execution: AutomationExecution) => void
|
||||||
|
onClear: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 5 个步骤压成一串小圆点,一眼能看出「卡在哪一步」。 */
|
||||||
|
function StepTrail({ execution }: { execution: AutomationExecution }): React.ReactElement {
|
||||||
|
if (!execution.steps.length) {
|
||||||
|
return <span className="automation-trail-empty">—</span>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="automation-trail" aria-label="执行步骤">
|
||||||
|
{execution.steps.map((step, index) => (
|
||||||
|
<React.Fragment key={step.key}>
|
||||||
|
{index > 0 ? <span className="automation-trail-link" aria-hidden="true" /> : null}
|
||||||
|
<span
|
||||||
|
className={`automation-trail-dot ${stepStatusTone(step.status)}`}
|
||||||
|
title={`${step.label}:${STEP_STATUS_LABELS[step.status]}`}
|
||||||
|
aria-label={`${step.label}:${STEP_STATUS_LABELS[step.status]}`}
|
||||||
|
role="img"
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExecutionLogPanel({
|
||||||
|
executions,
|
||||||
|
loading,
|
||||||
|
clearing,
|
||||||
|
onSelect,
|
||||||
|
onClear
|
||||||
|
}: ExecutionLogPanelProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="automation-log-panel">
|
||||||
|
<div className="automation-section-heading">
|
||||||
|
<h2>执行日志</h2>
|
||||||
|
<div className="automation-log-heading-actions">
|
||||||
|
<span className="automation-section-note">最近 {executions.length} 条</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClear}
|
||||||
|
disabled={clearing || executions.length === 0}
|
||||||
|
>
|
||||||
|
{clearing ? '清空中…' : '清空记录'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="automation-loading">
|
||||||
|
<Spinner />
|
||||||
|
<span>正在读取执行记录…</span>
|
||||||
|
</div>
|
||||||
|
) : executions.length === 0 ? (
|
||||||
|
<div className="automation-empty-card">
|
||||||
|
<p>暂无执行记录。</p>
|
||||||
|
<small>规则命中并开始执行后,这里会留下完整的步骤轨迹。</small>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="automation-log-table" role="table" aria-label="执行日志">
|
||||||
|
<div className="automation-log-row head" role="row">
|
||||||
|
<span role="columnheader">时间</span>
|
||||||
|
<span role="columnheader">规则</span>
|
||||||
|
<span role="columnheader">来源</span>
|
||||||
|
<span role="columnheader">结果</span>
|
||||||
|
<span role="columnheader">耗时</span>
|
||||||
|
<span role="columnheader">步骤轨迹</span>
|
||||||
|
<span role="columnheader" />
|
||||||
|
</div>
|
||||||
|
{executions.map((execution) => (
|
||||||
|
<div key={execution.executionId} className="automation-log-row" role="row">
|
||||||
|
<span role="cell" className="automation-log-time">
|
||||||
|
{formatTriggerTime(execution.triggerTime)}
|
||||||
|
</span>
|
||||||
|
<span role="cell" className="automation-log-rule">
|
||||||
|
{execution.ruleName}
|
||||||
|
</span>
|
||||||
|
<span role="cell" className="automation-log-source">
|
||||||
|
{execution.sourceDisplayName}
|
||||||
|
</span>
|
||||||
|
<span role="cell">
|
||||||
|
<span className={`automation-status-chip ${execution.status}`}>
|
||||||
|
{AUTOMATION_EXECUTION_STATUS_LABELS[execution.status]}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span role="cell" className="automation-log-duration">
|
||||||
|
{formatDuration(execution.durationMs)}
|
||||||
|
</span>
|
||||||
|
<span role="cell">
|
||||||
|
<StepTrail execution={execution} />
|
||||||
|
</span>
|
||||||
|
<span role="cell" className="automation-log-action">
|
||||||
|
<Button variant="link" size="sm" onClick={() => onSelect(execution)}>
|
||||||
|
查看详情
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { Button, Input, RadioGroup, RadioGroupItem, Switch } from '../../components/ui'
|
||||||
|
import {
|
||||||
|
LEAVE_NOTIFICATION_TARGET_OPTIONS,
|
||||||
|
describeLeaveNotificationNotifyScope,
|
||||||
|
normalizeLeaveNotificationConfig,
|
||||||
|
type AutomationRule,
|
||||||
|
type LeaveNotificationConfig,
|
||||||
|
type LeaveNotificationNotifyScope,
|
||||||
|
type LeaveNotificationTargetType
|
||||||
|
} from '../../../../shared/automation'
|
||||||
|
import { insertGroupNamePlaceholder } from '../../../../shared/group-exit-monitor'
|
||||||
|
import { LeaveNotificationPreview } from './LeaveNotificationPreview'
|
||||||
|
import type { AutomationGroupOption } from './model/api'
|
||||||
|
import {
|
||||||
|
LeaveNotificationTargetPicker,
|
||||||
|
type LeaveNotificationContactOption
|
||||||
|
} from './LeaveNotificationTargetPicker'
|
||||||
|
import { LeaveNotificationTemplateEditor } from './LeaveNotificationTemplateEditor'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LeaveNotificationEditor —— 「自动化 → 规则 → 退群通知」。
|
||||||
|
*
|
||||||
|
* 这是**真实配置页**:保存会落盘成一条 singleton 规则(`builtin-leave-notification`),
|
||||||
|
* 由 `AutomationService.handleGroupExit` 在退群事件到来时执行。
|
||||||
|
*
|
||||||
|
* 职责边界在 UI 上一眼可见,是**两层**而不是一层:
|
||||||
|
* 退群监控 = 监测哪些群有人退出(范围由它管);
|
||||||
|
* 本规则 = ① 这批群里**哪些要通知**(二次勾选);② 通知**发到哪**。
|
||||||
|
*
|
||||||
|
* ① 就是旧版「退群监控 → 通知群聊」的那份逐群勾选。它曾在自动化迁移里被压成单值目标而丢失,
|
||||||
|
* 导致"规则一启用就全量通知";`notifyScope` 把它恢复回来,所以这里**有**勾选控件。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LeaveNotificationSaveInput {
|
||||||
|
enabled: boolean
|
||||||
|
config: LeaveNotificationConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaveNotificationEditorProps {
|
||||||
|
/** 已持久化的退群通知规则(singleton,永远存在)。 */
|
||||||
|
rule: AutomationRule
|
||||||
|
/** 「指定好友」的可选项(main 侧已过滤,只含可发送的个人联系人)。 */
|
||||||
|
contacts: LeaveNotificationContactOption[]
|
||||||
|
/** 真实已监控群聊数量(来自退群监控,自动化不维护副本)。 */
|
||||||
|
monitoredCount: number
|
||||||
|
/** 真实已监控群聊清单 —— 二次勾选的候选项,与 `monitoredCount` 同一次取数。 */
|
||||||
|
monitoredGroups: AutomationGroupOption[]
|
||||||
|
saving: boolean
|
||||||
|
/** 发送能力不完整时的一句话提示;为 undefined 表示能力正常。 */
|
||||||
|
sendCapabilityWarning?: string
|
||||||
|
/** 「管理监控群聊 →」—— 跳退群监控的管理群聊页,纯导航。 */
|
||||||
|
onOpenMonitoredGroups?: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
onSave: (input: LeaveNotificationSaveInput) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 草稿初值。
|
||||||
|
*
|
||||||
|
* 一律过一遍 `normalizeLeaveNotificationConfig`:它本来就负责"补齐缺失字段 +
|
||||||
|
* 清掉互相矛盾的配置",所以契约新增字段(例如 `notifyScope`)时这里不会漏。
|
||||||
|
* 手拼字面量就会漏。
|
||||||
|
*/
|
||||||
|
function initialConfig(rule: AutomationRule): LeaveNotificationConfig {
|
||||||
|
return normalizeLeaveNotificationConfig(rule.leaveNotification)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LeaveNotificationEditor({
|
||||||
|
rule,
|
||||||
|
contacts,
|
||||||
|
monitoredCount,
|
||||||
|
monitoredGroups,
|
||||||
|
saving,
|
||||||
|
sendCapabilityWarning,
|
||||||
|
onOpenMonitoredGroups,
|
||||||
|
onCancel,
|
||||||
|
onSave
|
||||||
|
}: LeaveNotificationEditorProps): React.ReactElement {
|
||||||
|
const [enabled, setEnabled] = React.useState(rule.enabled)
|
||||||
|
const [config, setConfig] = React.useState<LeaveNotificationConfig>(() => initialConfig(rule))
|
||||||
|
const [groupFilter, setGroupFilter] = React.useState('')
|
||||||
|
|
||||||
|
// 切换编辑对象 / 保存后回填时重建草稿,避免把上一份改动带过来。
|
||||||
|
React.useEffect(() => {
|
||||||
|
setEnabled(rule.enabled)
|
||||||
|
setConfig(initialConfig(rule))
|
||||||
|
setGroupFilter('')
|
||||||
|
}, [rule])
|
||||||
|
|
||||||
|
const targetType = config.target.type
|
||||||
|
const selectedContactId = String(config.target.contactId || '')
|
||||||
|
|
||||||
|
const selectedContactName = React.useMemo(
|
||||||
|
() => contacts.find((contact) => contact.id === selectedContactId)?.name ?? '',
|
||||||
|
[contacts, selectedContactId]
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 选了「指定好友」但那个联系人已经不在了(被删 / 不可发送 / 找不到)。 */
|
||||||
|
const contactMissing =
|
||||||
|
targetType === 'contact' && Boolean(selectedContactId) && !selectedContactName
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知不是发给「当前群聊」、但内容里又没有群名 → 收件人认不出是哪个群。
|
||||||
|
*
|
||||||
|
* 只在非「当前群聊」时要求:发给群内时,群名是冗余的。
|
||||||
|
*/
|
||||||
|
const missingGroupName =
|
||||||
|
targetType !== 'source_chat' && !config.template.includes('{groupName}')
|
||||||
|
|
||||||
|
/** 通知范围(二次勾选)。`config.notifyScope` 是唯一权威,这里只是取短名。 */
|
||||||
|
const notifyScope: LeaveNotificationNotifyScope =
|
||||||
|
config.notifyScope === 'selected' ? 'selected' : 'all'
|
||||||
|
|
||||||
|
const selectedNotifyIds = React.useMemo(
|
||||||
|
() => new Set(config.notifyRoomIds),
|
||||||
|
[config.notifyRoomIds]
|
||||||
|
)
|
||||||
|
|
||||||
|
const visibleMonitoredGroups = React.useMemo(() => {
|
||||||
|
const keyword = groupFilter.trim().toLowerCase()
|
||||||
|
if (!keyword) return monitoredGroups
|
||||||
|
return monitoredGroups.filter((group) => group.name.toLowerCase().includes(keyword))
|
||||||
|
}, [monitoredGroups, groupFilter])
|
||||||
|
|
||||||
|
const selectTarget = (next: LeaveNotificationTargetType): void => {
|
||||||
|
setConfig((current) => ({
|
||||||
|
...current,
|
||||||
|
// 换目标类型时清掉上一个类型才有的字段,避免存下互相矛盾的配置。
|
||||||
|
// 同时**清除迁移遗留的「待重选」标记** —— 用户已经做了选择。
|
||||||
|
target: next === 'contact' ? { type: next, contactId: current.target.contactId } : { type: next }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectNotifyScope = (next: LeaveNotificationNotifyScope): void => {
|
||||||
|
setConfig((current) => ({ ...current, notifyScope: next }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleNotifyRoom = (roomId: string): void => {
|
||||||
|
setConfig((current) => {
|
||||||
|
const chosen = new Set(current.notifyRoomIds)
|
||||||
|
if (chosen.has(roomId)) chosen.delete(roomId)
|
||||||
|
else chosen.add(roomId)
|
||||||
|
// 按候选项顺序落盘,勾选顺序不影响存下来的结果。
|
||||||
|
const ordered = monitoredGroups
|
||||||
|
.map((group) => group.id)
|
||||||
|
.filter((id) => chosen.has(id))
|
||||||
|
// 候选项还没加载出来时保留原样,避免把用户的勾选静默清空。
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
notifyRoomIds: monitoredGroups.length ? ordered : current.notifyRoomIds
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = (): void => {
|
||||||
|
onSave({
|
||||||
|
enabled,
|
||||||
|
config: {
|
||||||
|
...config,
|
||||||
|
// 勾选集收敛到「当前已监控」这一集合内:否则监控范围缩小之后,
|
||||||
|
// 会留下一批界面上看不见、且永远不可能命中的幽灵勾选。
|
||||||
|
notifyRoomIds: monitoredGroups.length
|
||||||
|
? monitoredGroups.map((group) => group.id).filter((id) => selectedNotifyIds.has(id))
|
||||||
|
: config.notifyRoomIds,
|
||||||
|
targetNeedsReview: undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="automation-editor">
|
||||||
|
<header className="automation-editor-header">
|
||||||
|
<div>
|
||||||
|
<h2>退群通知</h2>
|
||||||
|
<p>当已监控群聊检测到成员退出时,自动发送通知。</p>
|
||||||
|
<div className="automation-leave-status-row">
|
||||||
|
<span className="automation-field-label">启用这条自动化</span>
|
||||||
|
<Switch
|
||||||
|
checked={enabled}
|
||||||
|
onCheckedChange={setEnabled}
|
||||||
|
aria-label="启用这条自动化"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="automation-editor-actions">
|
||||||
|
<Button variant="ghost" onClick={onCancel} disabled={saving}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? '保存中…' : '保存'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{config.targetNeedsReview ? (
|
||||||
|
<p className="automation-notice warning">
|
||||||
|
旧版「通知群聊」是逐群配置的,无法无损转换成新的单一通知目标。已保留你的通知模板,
|
||||||
|
但<strong>在你重新选择通知目标之前不会发送任何通知</strong>。
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{contactMissing ? (
|
||||||
|
<p className="automation-notice warning">
|
||||||
|
之前选择的联系人已不存在或当前无法发送,请重新选择通知目标。
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{sendCapabilityWarning ? (
|
||||||
|
<p className="automation-notice warning">{sendCapabilityWarning}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="automation-editor-body">
|
||||||
|
<div className="automation-editor-form">
|
||||||
|
<section className="automation-section">
|
||||||
|
<div className="automation-section-heading">
|
||||||
|
<h3>1 · 什么时候触发</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="automation-inline-row">
|
||||||
|
<div>
|
||||||
|
<span className="automation-field-label">触发事件</span>
|
||||||
|
<span className="automation-static-value">检测到群成员退出</span>
|
||||||
|
<small>这是「退群监控」提供的事件,由本规则响应。</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="automation-inline-row">
|
||||||
|
<div>
|
||||||
|
<span className="automation-field-label">监控来源</span>
|
||||||
|
<span className="automation-static-value">退群监控</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
第一层:只读的监控范围摘要 + 唯一入口。
|
||||||
|
「哪些群被监控」完全由退群监控决定,本规则不重复维护一份。
|
||||||
|
*/}
|
||||||
|
<div className="automation-leave-groups-card">
|
||||||
|
<div>
|
||||||
|
<span className="automation-field-label">已监控群聊</span>
|
||||||
|
<strong>{monitoredCount} 个群聊</strong>
|
||||||
|
<small>监控范围由「退群监控」管理</small>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onOpenMonitoredGroups}
|
||||||
|
disabled={!onOpenMonitoredGroups}
|
||||||
|
>
|
||||||
|
管理监控群聊 →
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
第二层:在上面这批已监控群聊里**二次勾选**哪些要通知。
|
||||||
|
这就是旧版「退群监控 → 通知群聊」的那份逐群勾选 ——
|
||||||
|
没有它,规则一启用就等于给全部已监控群聊发通知。
|
||||||
|
*/}
|
||||||
|
<div className="automation-inline-row">
|
||||||
|
<div className="automation-leave-scope">
|
||||||
|
<span className="automation-field-label">通知范围</span>
|
||||||
|
<RadioGroup
|
||||||
|
value={notifyScope}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
selectNotifyScope(value as LeaveNotificationNotifyScope)
|
||||||
|
}
|
||||||
|
aria-label="通知范围"
|
||||||
|
className="automation-leave-scope-options"
|
||||||
|
>
|
||||||
|
<div className="automation-leave-radio option">
|
||||||
|
<RadioGroupItem value="all" id="leave-scope-all" />
|
||||||
|
<div className="automation-leave-radio-body">
|
||||||
|
<label htmlFor="leave-scope-all">全部已监控群聊</label>
|
||||||
|
<small>任一被监控的群有人退出都发通知。</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="automation-leave-radio option">
|
||||||
|
<RadioGroupItem
|
||||||
|
value="selected"
|
||||||
|
id="leave-scope-selected"
|
||||||
|
disabled={!monitoredGroups.length}
|
||||||
|
/>
|
||||||
|
<div className="automation-leave-radio-body">
|
||||||
|
<label htmlFor="leave-scope-selected">仅选中的群聊</label>
|
||||||
|
<small>只对下面勾选的群发通知。</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
<small className="automation-leave-scope-summary">
|
||||||
|
当前覆盖:{describeLeaveNotificationNotifyScope(config, monitoredCount)}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{notifyScope === 'selected' ? (
|
||||||
|
monitoredGroups.length ? (
|
||||||
|
<div className="automation-field">
|
||||||
|
<div className="automation-section-heading">
|
||||||
|
<span className="automation-field-label">勾选要通知的群聊</span>
|
||||||
|
<span className="automation-section-note">
|
||||||
|
{selectedNotifyIds.size ? `已选 ${selectedNotifyIds.size} 个` : '尚未勾选'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={groupFilter}
|
||||||
|
onChange={(event) => setGroupFilter(event.target.value)}
|
||||||
|
placeholder="搜索群聊"
|
||||||
|
aria-label="搜索群聊"
|
||||||
|
/>
|
||||||
|
<div className="automation-group-list" role="group" aria-label="通知群聊">
|
||||||
|
{visibleMonitoredGroups.length === 0 ? (
|
||||||
|
<p className="automation-group-empty">没有匹配「{groupFilter}」的群聊</p>
|
||||||
|
) : (
|
||||||
|
visibleMonitoredGroups.map((group) => {
|
||||||
|
const checked = selectedNotifyIds.has(group.id)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={group.id}
|
||||||
|
className={`automation-group-option ${checked ? 'selected' : ''}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => toggleNotifyRoom(group.id)}
|
||||||
|
/>
|
||||||
|
<span>{group.name}</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedNotifyIds.size === 0 ? (
|
||||||
|
<p className="automation-section-hint">
|
||||||
|
<span aria-hidden="true">ⓘ</span>
|
||||||
|
一个群都没勾选,这条规则不会发送任何通知。
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="automation-section-hint">
|
||||||
|
<span aria-hidden="true">ⓘ</span>
|
||||||
|
还没有设置监控范围。请先到「退群监控 → 管理群聊」选择要监控的群,
|
||||||
|
再回来勾选通知范围。
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="automation-section">
|
||||||
|
<div className="automation-section-heading">
|
||||||
|
<h3>2 · 通知发送到哪里</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RadioGroup
|
||||||
|
value={targetType}
|
||||||
|
onValueChange={(value) => selectTarget(value as LeaveNotificationTargetType)}
|
||||||
|
aria-label="通知发送到哪里"
|
||||||
|
className="automation-leave-targets"
|
||||||
|
>
|
||||||
|
{LEAVE_NOTIFICATION_TARGET_OPTIONS.map((option) => (
|
||||||
|
<div key={option.type} className="automation-leave-radio option">
|
||||||
|
<RadioGroupItem value={option.type} id={`leave-target-${option.type}`} />
|
||||||
|
<div className="automation-leave-radio-body">
|
||||||
|
<label htmlFor={`leave-target-${option.type}`}>{option.label}</label>
|
||||||
|
<small>{option.description}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
{targetType === 'contact' ? (
|
||||||
|
<div className="automation-field">
|
||||||
|
<span className="automation-field-label">选择好友</span>
|
||||||
|
<LeaveNotificationTargetPicker
|
||||||
|
contacts={contacts}
|
||||||
|
selectedId={selectedContactId}
|
||||||
|
onSelect={(id) =>
|
||||||
|
setConfig((current) => ({
|
||||||
|
...current,
|
||||||
|
target: { type: 'contact', contactId: id },
|
||||||
|
targetNeedsReview: undefined
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
ariaLabel="选择好友"
|
||||||
|
searchPlaceholder="搜索好友"
|
||||||
|
emptyText="没有可发送的联系人"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="automation-section">
|
||||||
|
<div className="automation-section-heading">
|
||||||
|
<h3>3 · 通知内容</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
护栏:通知发给「别人」时,不带群名的通知等于「张三退群了」。
|
||||||
|
主进程已替用户补过一次(一次性迁移),这里兜的是"用户后来主动删掉了"的情况 ——
|
||||||
|
只提示 + 一键补上,不强行改用户文本。
|
||||||
|
*/}
|
||||||
|
{missingGroupName ? (
|
||||||
|
<div className="automation-notice warning automation-notice-stack">
|
||||||
|
<p>这份内容里没有群聊名,收件人看不出是哪个群退的人。</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setConfig((current) => ({
|
||||||
|
...current,
|
||||||
|
template: insertGroupNamePlaceholder(current.template)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
补上群聊名
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<LeaveNotificationTemplateEditor
|
||||||
|
template={config.template}
|
||||||
|
onChange={(next) =>
|
||||||
|
setConfig((current) => ({ ...current, template: next }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LeaveNotificationPreview
|
||||||
|
config={config}
|
||||||
|
contactName={selectedContactName}
|
||||||
|
monitoredCount={monitoredCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import {
|
||||||
|
describeLeaveNotificationNotifyScope,
|
||||||
|
describeMonitoredScope,
|
||||||
|
leaveNotificationTargetLabel,
|
||||||
|
type LeaveNotificationConfig
|
||||||
|
} from '../../../../shared/automation'
|
||||||
|
import {
|
||||||
|
createGroupExitPreviewSample,
|
||||||
|
renderLeaveNotificationText
|
||||||
|
} from '../../../../shared/group-exit-event'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LeaveNotificationPreview —— 右侧「效果预览」。
|
||||||
|
*
|
||||||
|
* 与左侧表单逐项联动:改模板 → 气泡变;改发送目标 → 顶部收件会话与底部「通知目标」一起变。
|
||||||
|
*
|
||||||
|
* **绝不发送**:即使规则已经真实接通,这里也只做渲染 ——
|
||||||
|
* 它拿的是 `GROUP_EXIT_PREVIEW_SAMPLE` 样本事件,不碰 `WechatActionGateway`。
|
||||||
|
* 模板渲染复用 shared 的唯一实现,所以预览与实发不会出现两套结果。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LeaveNotificationPreviewProps {
|
||||||
|
config: LeaveNotificationConfig
|
||||||
|
/** 「指定好友」已选联系人的显示名;未选 / 不是该目标时传空串。 */
|
||||||
|
contactName: string
|
||||||
|
/** 真实已监控群聊数量(来自退群监控)。 */
|
||||||
|
monitoredCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LeaveNotificationPreview({
|
||||||
|
config,
|
||||||
|
contactName,
|
||||||
|
monitoredCount
|
||||||
|
}: LeaveNotificationPreviewProps): React.ReactElement {
|
||||||
|
// 样本事件只服务预览;时间取"此刻",不伪造历史日期。
|
||||||
|
const sample = React.useMemo(() => createGroupExitPreviewSample(Date.now()), [])
|
||||||
|
|
||||||
|
const chatTitle = React.useMemo((): string => {
|
||||||
|
switch (config.target.type) {
|
||||||
|
case 'source_chat':
|
||||||
|
return sample.groupName || '当前群聊'
|
||||||
|
case 'self':
|
||||||
|
return '我'
|
||||||
|
case 'file_transfer':
|
||||||
|
return leaveNotificationTargetLabel('file_transfer')
|
||||||
|
case 'contact':
|
||||||
|
return contactName.trim() || '未选择好友'
|
||||||
|
}
|
||||||
|
}, [config.target.type, contactName, sample.groupName])
|
||||||
|
|
||||||
|
const targetSummary = React.useMemo((): string => {
|
||||||
|
switch (config.target.type) {
|
||||||
|
case 'source_chat':
|
||||||
|
return '发生退群事件的群聊'
|
||||||
|
case 'self':
|
||||||
|
return '我'
|
||||||
|
case 'file_transfer':
|
||||||
|
return leaveNotificationTargetLabel('file_transfer')
|
||||||
|
case 'contact':
|
||||||
|
return contactName.trim() || '未选择好友'
|
||||||
|
}
|
||||||
|
}, [config.target.type, contactName])
|
||||||
|
|
||||||
|
const bubbleText = renderLeaveNotificationText(sample, config.template).trim() || '(模板为空)'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="automation-preview" aria-label="效果模拟预览">
|
||||||
|
<div className="automation-preview-heading">
|
||||||
|
<h3>效果预览</h3>
|
||||||
|
<span className="automation-preview-badge">仅预览,不会真实发送</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="automation-preview-phone">
|
||||||
|
<div className="automation-preview-chat-title" data-testid="leave-preview-recipient">
|
||||||
|
<span>{chatTitle}</span>
|
||||||
|
</div>
|
||||||
|
<div className="automation-preview-chat">
|
||||||
|
{/* 退群监控产生的系统事件,不来自任何消息文本 */}
|
||||||
|
<p className="automation-preview-system">检测到成员退出</p>
|
||||||
|
|
||||||
|
<div className="automation-chat-row outgoing">
|
||||||
|
<div className="automation-chat-body">
|
||||||
|
<span className="automation-chat-name">我</span>
|
||||||
|
<span className="automation-chat-bubble pre">{bubbleText}</span>
|
||||||
|
</div>
|
||||||
|
<span className="automation-chat-avatar self" aria-hidden="true">
|
||||||
|
我
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="automation-preview-facts">
|
||||||
|
<div>
|
||||||
|
<dt>触发事件</dt>
|
||||||
|
<dd>成员退出</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>监控来源</dt>
|
||||||
|
<dd>{describeMonitoredScope(monitoredCount)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>通知范围</dt>
|
||||||
|
<dd>{describeLeaveNotificationNotifyScope(config, monitoredCount)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>通知目标</dt>
|
||||||
|
<dd>{targetSummary}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { Input, RadioGroup, RadioGroupItem } from '../../components/ui'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LeaveNotificationTargetPicker —— 「指定好友」展开后的联系人选择器。
|
||||||
|
*
|
||||||
|
* **单选**:通知只能发给一个联系人,所以这里是 radio 而不是 checkbox ——
|
||||||
|
* 多选联系人不在范围内,也不该顺手加。
|
||||||
|
*
|
||||||
|
* 联系人清单由 main 侧提供(`listSendableContacts`),**已经过滤掉**
|
||||||
|
* 群聊 / 公众号 / 文件传输助手 / 自己。这里不再二次筛选,避免两处规则漂移。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LeaveNotificationContactOption {
|
||||||
|
/** 稳定 id(wxid)。保存与发送都用它,**不用显示名**。 */
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaveNotificationTargetPickerProps {
|
||||||
|
contacts: LeaveNotificationContactOption[]
|
||||||
|
selectedId: string
|
||||||
|
onSelect: (id: string) => void
|
||||||
|
searchPlaceholder: string
|
||||||
|
emptyText: string
|
||||||
|
ariaLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LeaveNotificationTargetPicker({
|
||||||
|
contacts,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
searchPlaceholder,
|
||||||
|
emptyText,
|
||||||
|
ariaLabel
|
||||||
|
}: LeaveNotificationTargetPickerProps): React.ReactElement {
|
||||||
|
const [filter, setFilter] = React.useState('')
|
||||||
|
|
||||||
|
const visibleContacts = React.useMemo(() => {
|
||||||
|
const needle = filter.trim().toLowerCase()
|
||||||
|
if (!needle) return contacts
|
||||||
|
return contacts.filter(
|
||||||
|
(contact) =>
|
||||||
|
contact.name.toLowerCase().includes(needle) || contact.id.toLowerCase().includes(needle)
|
||||||
|
)
|
||||||
|
}, [contacts, filter])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RadioGroup
|
||||||
|
value={selectedId}
|
||||||
|
onValueChange={onSelect}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className="automation-leave-contact-picker"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={filter}
|
||||||
|
onChange={(event) => setFilter(event.target.value)}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
aria-label={searchPlaceholder}
|
||||||
|
/>
|
||||||
|
<div className="automation-leave-contact-list">
|
||||||
|
{visibleContacts.length === 0 ? (
|
||||||
|
<p className="automation-group-empty">{emptyText}</p>
|
||||||
|
) : (
|
||||||
|
visibleContacts.map((contact) => (
|
||||||
|
<div key={contact.id} className="automation-leave-radio option">
|
||||||
|
<RadioGroupItem value={contact.id} id={`leave-contact-${contact.id}`} />
|
||||||
|
<label htmlFor={`leave-contact-${contact.id}`}>{contact.name}</label>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { Textarea } from '../../components/ui'
|
||||||
|
import {
|
||||||
|
GROUP_EXIT_NOTIFICATION_PLACEHOLDER_LABELS,
|
||||||
|
GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH,
|
||||||
|
GROUP_EXIT_NOTIFICATION_TEMPLATE_PLACEHOLDERS
|
||||||
|
} from '../../../../shared/group-exit-monitor'
|
||||||
|
|
||||||
|
/** 在光标处插入一个变量;没有光标位置就追加到末尾。纯 UI 辅助。 */
|
||||||
|
function insertTemplateVariable(
|
||||||
|
template: string,
|
||||||
|
variable: string,
|
||||||
|
selectionStart?: number | null,
|
||||||
|
selectionEnd?: number | null
|
||||||
|
): string {
|
||||||
|
const token = `{${variable}}`
|
||||||
|
const start = typeof selectionStart === 'number' ? selectionStart : template.length
|
||||||
|
const end = typeof selectionEnd === 'number' ? selectionEnd : start
|
||||||
|
return `${template.slice(0, start)}${token}${template.slice(end)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LeaveNotificationTemplateEditor —— 「3 · 通知内容」。
|
||||||
|
*
|
||||||
|
* 占位符清单直接复用 shared 常量 —— UI 能插的变量与真实解释逻辑必须是同一份,
|
||||||
|
* 否则会出现"插得进去、发出去不变"的变量。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LeaveNotificationTemplateEditorProps {
|
||||||
|
template: string
|
||||||
|
onChange: (next: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LeaveNotificationTemplateEditor({
|
||||||
|
template,
|
||||||
|
onChange
|
||||||
|
}: LeaveNotificationTemplateEditorProps): React.ReactElement {
|
||||||
|
const textareaRef = React.useRef<HTMLTextAreaElement | null>(null)
|
||||||
|
|
||||||
|
const insertVariable = (variable: string): void => {
|
||||||
|
const element = textareaRef.current
|
||||||
|
onChange(
|
||||||
|
insertTemplateVariable(
|
||||||
|
template,
|
||||||
|
variable,
|
||||||
|
element?.selectionStart ?? null,
|
||||||
|
element?.selectionEnd ?? null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="automation-leave-template">
|
||||||
|
<Textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
id="automation-leave-template"
|
||||||
|
aria-label="退群通知模板内容"
|
||||||
|
value={template}
|
||||||
|
rows={11}
|
||||||
|
maxLength={GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
className="automation-leave-template-editor"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="automation-leave-template-meta">
|
||||||
|
<span>支持</span>
|
||||||
|
<span className="automation-leave-template-chips">
|
||||||
|
{GROUP_EXIT_NOTIFICATION_TEMPLATE_PLACEHOLDERS.map((placeholder) => (
|
||||||
|
<button
|
||||||
|
key={placeholder}
|
||||||
|
type="button"
|
||||||
|
className="automation-leave-template-chip"
|
||||||
|
// 悬停就能看到含义 —— 光看 {groupRemark} 这种符号谁也猜不出它是什么。
|
||||||
|
title={`{${placeholder}}:${GROUP_EXIT_NOTIFICATION_PLACEHOLDER_LABELS[placeholder]}`}
|
||||||
|
onClick={() => insertVariable(placeholder)}
|
||||||
|
aria-label={`插入变量 ${placeholder}`}
|
||||||
|
>
|
||||||
|
{`{${placeholder}}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span className="automation-leave-template-count">
|
||||||
|
{template.length}/{GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
先把最容易混的两项说清楚,再说兜底。
|
||||||
|
实测有人把 {groupRemark} 当成群名(它其实是成员在本群的昵称),
|
||||||
|
于是以为通知里已经有群名了 —— 所以这句必须放在最前面、直说区别。
|
||||||
|
*/}
|
||||||
|
<p className="automation-leave-template-note">
|
||||||
|
{`{groupName}`} 是<strong>群聊名</strong>;{`{groupRemark}`} 是退群成员
|
||||||
|
<strong>在本群的昵称</strong>(不是群名)。取不到值时:{`{groupName}`}、{`{user}`}、
|
||||||
|
{`{wxid}`} 显示「未读取到」,{`{groupRemark}`} 显示「未设置」。
|
||||||
|
悬停变量可看含义;通知只发送到上面选定的目标。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import {
|
||||||
|
AUTOMATION_REPLY_DELAY_MAX_SECONDS,
|
||||||
|
DEFAULT_REPLY_TEXT,
|
||||||
|
KEYWORD_MATCH_MODE_LABELS,
|
||||||
|
createDefaultDailyReportRule,
|
||||||
|
normalizeReplyDelaySeconds,
|
||||||
|
normalizeRuleDraft,
|
||||||
|
type AutomationActionType,
|
||||||
|
type AutomationRule,
|
||||||
|
type AutomationRuleDraft,
|
||||||
|
type KeywordMatchMode
|
||||||
|
} from '../../../../shared/automation'
|
||||||
|
import { Button, Input, SegmentedControl, SegmentedControlItem, Switch } from '../../components/ui'
|
||||||
|
import { EffectPreview } from './EffectPreview'
|
||||||
|
import type { AutomationGroupOption } from './model/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RuleEditorPanel —— 规则编辑页(对应 Stitch 设计稿 1)。
|
||||||
|
*
|
||||||
|
* 结构固定五段:什么时候触发 → 在哪些聊天生效 → 触发后执行 → 高级设置 → 效果预览。
|
||||||
|
*
|
||||||
|
* 刻意**不出现**工程词:没有 dedup / idempotency / watermark / regex,
|
||||||
|
* 用户看到的只有「同一消息只执行一次」这类人话。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const KEYWORD_MODES: KeywordMatchMode[] = ['contains', 'exact', 'prefix']
|
||||||
|
|
||||||
|
const ACTION_META: Array<{
|
||||||
|
type: AutomationActionType
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
type: 'replyText',
|
||||||
|
title: '回复确认',
|
||||||
|
description: '先在群里回一句,告诉对方已经收到'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'generateReport',
|
||||||
|
title: '生成日报',
|
||||||
|
description: '用当前群的今日消息生成日报'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'sendReportImage',
|
||||||
|
title: '发送日报图片',
|
||||||
|
description: '把生成的日报图片发回触发消息所在的群'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
function draftFromRule(rule: AutomationRule | null): AutomationRuleDraft {
|
||||||
|
const base = rule ?? createDefaultDailyReportRule(Date.now())
|
||||||
|
return normalizeRuleDraft({
|
||||||
|
name: base.name,
|
||||||
|
enabled: base.enabled,
|
||||||
|
scope: base.scope,
|
||||||
|
conditions: base.conditions,
|
||||||
|
actions: base.actions,
|
||||||
|
cooldownSeconds: base.cooldownSeconds,
|
||||||
|
// 漏掉一个字段就会被 normalizeRuleDraft 的默认值悄悄覆盖 ——
|
||||||
|
// 比如把用户设成 0 的「回复前等待」重置回 2 秒。
|
||||||
|
replyDelaySeconds: base.replyDelaySeconds
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleEditorPanelProps {
|
||||||
|
mode: 'create' | 'edit'
|
||||||
|
rule: AutomationRule | null
|
||||||
|
groups: AutomationGroupOption[]
|
||||||
|
saving: boolean
|
||||||
|
onCancel: () => void
|
||||||
|
onSave: (draft: AutomationRuleDraft) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RuleEditorPanel({
|
||||||
|
mode,
|
||||||
|
rule,
|
||||||
|
groups,
|
||||||
|
saving,
|
||||||
|
onCancel,
|
||||||
|
onSave
|
||||||
|
}: RuleEditorPanelProps): React.ReactElement {
|
||||||
|
const [draft, setDraft] = React.useState<AutomationRuleDraft>(() => draftFromRule(rule))
|
||||||
|
const [groupFilter, setGroupFilter] = React.useState('')
|
||||||
|
const [validationError, setValidationError] = React.useState('')
|
||||||
|
|
||||||
|
// 切换编辑对象时重建草稿,避免把上一条规则的修改带过去。
|
||||||
|
React.useEffect(() => {
|
||||||
|
setDraft(draftFromRule(rule))
|
||||||
|
setValidationError('')
|
||||||
|
}, [rule])
|
||||||
|
|
||||||
|
const patchConditions = (patch: Partial<AutomationRuleDraft['conditions']>): void => {
|
||||||
|
setDraft((current) => ({ ...current, conditions: { ...current.conditions, ...patch } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const patchAction = (type: AutomationActionType, patch: { enabled?: boolean; text?: string }): void => {
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
actions: current.actions.map((action) =>
|
||||||
|
action.type === type ? { ...action, ...patch } : action
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionOf = (type: AutomationActionType): { enabled: boolean; text?: string } =>
|
||||||
|
draft.actions.find((action) => action.type === type) ?? { enabled: false }
|
||||||
|
|
||||||
|
const toggleGroup = (id: string): void => {
|
||||||
|
setDraft((current) => {
|
||||||
|
const selected = new Set(current.conditions.conversationIds)
|
||||||
|
if (selected.has(id)) selected.delete(id)
|
||||||
|
else selected.add(id)
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
conditions: { ...current.conditions, conversationIds: Array.from(selected) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleGroups = React.useMemo(() => {
|
||||||
|
const needle = groupFilter.trim().toLowerCase()
|
||||||
|
if (!needle) return groups
|
||||||
|
return groups.filter(
|
||||||
|
(group) =>
|
||||||
|
group.name.toLowerCase().includes(needle) || group.id.toLowerCase().includes(needle)
|
||||||
|
)
|
||||||
|
}, [groups, groupFilter])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生效范围的实际状态。
|
||||||
|
*
|
||||||
|
* 现在的交互是「勾选群 = 指定群聊;一个都不勾 = 所有群聊」,所以没有单独的
|
||||||
|
* 「所有群聊 / 指定群聊」单选控件 —— 不去动第 2 节的结构,只把这个状态显式说出来,
|
||||||
|
* 好让「所有群聊」那条例外提示挂在对的上下文里。
|
||||||
|
*/
|
||||||
|
const hasSelectedGroups = draft.conditions.conversationIds.length > 0
|
||||||
|
|
||||||
|
const handleSave = (): void => {
|
||||||
|
if (!draft.name.trim()) {
|
||||||
|
setValidationError('请填写自动化名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!draft.actions.some((action) => action.enabled)) {
|
||||||
|
setValidationError('至少要启用一个执行动作,否则规则命中后什么也不会发生')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setValidationError('')
|
||||||
|
onSave(draft)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="automation-editor">
|
||||||
|
<header className="automation-editor-header">
|
||||||
|
<div>
|
||||||
|
<h2>{mode === 'create' ? '新建自动化' : '编辑自动化'}</h2>
|
||||||
|
<p>命中条件后,TraceMemo 会按下面的顺序依次执行。</p>
|
||||||
|
</div>
|
||||||
|
<div className="automation-editor-actions">
|
||||||
|
<Button variant="ghost" onClick={onCancel} disabled={saving}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? '保存中…' : '保存'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{validationError ? <p className="automation-form-error">{validationError}</p> : null}
|
||||||
|
|
||||||
|
<div className="automation-editor-body">
|
||||||
|
<div className="automation-editor-form">
|
||||||
|
<section className="automation-section">
|
||||||
|
<div className="automation-section-heading">
|
||||||
|
<h3>1 · 什么时候触发</h3>
|
||||||
|
</div>
|
||||||
|
<div className="automation-field">
|
||||||
|
<label htmlFor="automation-name">名称</label>
|
||||||
|
<Input
|
||||||
|
id="automation-name"
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((current) => ({ ...current, name: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="给这条自动化起个名字"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="automation-inline-row">
|
||||||
|
<span className="automation-field-label">触发方式</span>
|
||||||
|
<span className="automation-static-value">收到消息</span>
|
||||||
|
</div>
|
||||||
|
<div className="automation-switch-row">
|
||||||
|
<div>
|
||||||
|
<span className="automation-field-label">必须真正 @我</span>
|
||||||
|
<small>根据微信底层 @ 元数据判断,不依赖普通昵称纯文本</small>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={draft.conditions.requireMentionMe}
|
||||||
|
onCheckedChange={(checked) => patchConditions({ requireMentionMe: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="automation-switch-row">
|
||||||
|
<div>
|
||||||
|
<span className="automation-field-label">启用这条自动化</span>
|
||||||
|
<small>关闭后规则会保留,但不会触发</small>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={draft.enabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setDraft((current) => ({ ...current, enabled: checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="automation-field">
|
||||||
|
<label htmlFor="automation-keyword">关键词</label>
|
||||||
|
<Input
|
||||||
|
id="automation-keyword"
|
||||||
|
value={draft.conditions.keyword}
|
||||||
|
onChange={(event) => patchConditions({ keyword: event.target.value })}
|
||||||
|
placeholder="例如:日报(留空表示不限关键词)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="automation-field">
|
||||||
|
<span className="automation-field-label">匹配方式</span>
|
||||||
|
<SegmentedControl
|
||||||
|
value={draft.conditions.keywordMatchMode}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
patchConditions({ keywordMatchMode: value as KeywordMatchMode })
|
||||||
|
}
|
||||||
|
aria-label="关键词匹配方式"
|
||||||
|
>
|
||||||
|
{KEYWORD_MODES.map((mode) => (
|
||||||
|
<SegmentedControlItem key={mode} value={mode}>
|
||||||
|
{KEYWORD_MATCH_MODE_LABELS[mode]}
|
||||||
|
</SegmentedControlItem>
|
||||||
|
))}
|
||||||
|
</SegmentedControl>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="automation-section">
|
||||||
|
<div className="automation-section-heading">
|
||||||
|
<h3>2 · 在哪些聊天生效</h3>
|
||||||
|
<span className="automation-section-note">
|
||||||
|
{hasSelectedGroups
|
||||||
|
? `已选 ${draft.conditions.conversationIds.length} 个群`
|
||||||
|
: '所有群聊'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={groupFilter}
|
||||||
|
onChange={(event) => setGroupFilter(event.target.value)}
|
||||||
|
placeholder="搜索群聊"
|
||||||
|
aria-label="搜索群聊"
|
||||||
|
/>
|
||||||
|
<div className="automation-group-list" role="group" aria-label="生效群聊">
|
||||||
|
{groups.length === 0 ? (
|
||||||
|
<p className="automation-group-empty">
|
||||||
|
还没有读取到群聊。请先在「档案」中连接并解锁微信数据库。
|
||||||
|
</p>
|
||||||
|
) : visibleGroups.length === 0 ? (
|
||||||
|
<p className="automation-group-empty">没有匹配「{groupFilter}」的群聊</p>
|
||||||
|
) : (
|
||||||
|
visibleGroups.map((group) => {
|
||||||
|
const checked = draft.conditions.conversationIds.includes(group.id)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={group.id}
|
||||||
|
className={`automation-group-option ${checked ? 'selected' : ''}`}
|
||||||
|
>
|
||||||
|
<input type="checkbox" checked={checked} onChange={() => toggleGroup(group.id)} />
|
||||||
|
<span>{group.name}</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hasSelectedGroups ? null : (
|
||||||
|
// 只在「所有群聊」这个上下文里说明真实边界。
|
||||||
|
// 不解释底层原因(表事件 / 会话回读),也不写「100% 覆盖」这种绝对承诺。
|
||||||
|
<p className="automation-section-hint">
|
||||||
|
<span aria-hidden="true">ⓘ</span>
|
||||||
|
当前版本在多个会话同时收到消息时,极少数自动化触发可能遗漏。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="automation-section">
|
||||||
|
<div className="automation-section-heading">
|
||||||
|
<h3>3 · 触发后执行</h3>
|
||||||
|
<span className="automation-section-note">按下列顺序执行</span>
|
||||||
|
</div>
|
||||||
|
{/* 第一步不是动作,而是「等多久」—— 所以放在动作列表之前,不混进 ACTION_META。 */}
|
||||||
|
<div className="automation-inline-row">
|
||||||
|
<div>
|
||||||
|
<span className="automation-field-label">回复前等待(秒)</span>
|
||||||
|
<small>命中后先等这么久再回复,避免秒回显得像机器人;0 = 立刻回复</small>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={AUTOMATION_REPLY_DELAY_MAX_SECONDS}
|
||||||
|
value={String(draft.replyDelaySeconds)}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = Number(event.target.value)
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
replyDelaySeconds: normalizeReplyDelaySeconds(
|
||||||
|
Number.isFinite(next) ? next : 0
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
className="automation-number-input"
|
||||||
|
aria-label="回复前等待秒数"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{ACTION_META.map((meta) => {
|
||||||
|
const action = actionOf(meta.type)
|
||||||
|
return (
|
||||||
|
<div key={meta.type} className="automation-action-row">
|
||||||
|
<div className="automation-action-main">
|
||||||
|
<div className="automation-switch-row compact">
|
||||||
|
<div>
|
||||||
|
<span className="automation-field-label">{meta.title}</span>
|
||||||
|
<small>{meta.description}</small>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={action.enabled}
|
||||||
|
onCheckedChange={(checked) => patchAction(meta.type, { enabled: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{meta.type === 'replyText' && action.enabled ? (
|
||||||
|
<Input
|
||||||
|
value={action.text ?? ''}
|
||||||
|
onChange={(event) => patchAction(meta.type, { text: event.target.value })}
|
||||||
|
placeholder={DEFAULT_REPLY_TEXT}
|
||||||
|
aria-label="回复内容"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="automation-section">
|
||||||
|
<div className="automation-section-heading">
|
||||||
|
<h3>4 · 高级设置</h3>
|
||||||
|
</div>
|
||||||
|
<div className="automation-inline-row">
|
||||||
|
<div>
|
||||||
|
<span className="automation-field-label">触发间隔(秒)</span>
|
||||||
|
{/* 这句就是本产品的门语义定义,不要写 debounce / mutex / in-flight 这类技术词。 */}
|
||||||
|
<small>
|
||||||
|
触发后,在当前任务执行期间及设定间隔内,不再处理本规则在该会话中的新消息。
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={String(draft.cooldownSeconds)}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = Number(event.target.value)
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
cooldownSeconds: Number.isFinite(next) ? Math.max(0, Math.floor(next)) : 0
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
className="automation-number-input"
|
||||||
|
aria-label="触发间隔秒数"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="automation-switch-row">
|
||||||
|
<div>
|
||||||
|
<span className="automation-field-label">忽略自己发送的消息</span>
|
||||||
|
<small>
|
||||||
|
{draft.conditions.ignoreSelf
|
||||||
|
? '推荐保持开启,可避免自动回复被再次触发'
|
||||||
|
: '关闭后可能形成自动回复循环,请确认你知道后果'}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={draft.conditions.ignoreSelf}
|
||||||
|
onCheckedChange={(checked) => patchConditions({ ignoreSelf: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="automation-static-note">同一消息只执行一次。</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EffectPreview draft={draft} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import {
|
||||||
|
AUTOMATION_RULE_TEMPLATES,
|
||||||
|
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
|
||||||
|
describeLeaveNotificationNotifyScope,
|
||||||
|
describeLeaveNotificationTarget,
|
||||||
|
describeRuleActions,
|
||||||
|
describeRuleTrigger,
|
||||||
|
type AutomationRule
|
||||||
|
} from '../../../../shared/automation'
|
||||||
|
import { Button, Spinner, Switch } from '../../components/ui'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RuleListPanel —— 规则列表(「规则」tab)。
|
||||||
|
*
|
||||||
|
* 两个区块:
|
||||||
|
* 1. **已配置的自动化** —— 全部是**真规则**,带启停开关;
|
||||||
|
* 2. **模板入口** —— 尚未接通的能力必须**如实标注**为「即将支持」。
|
||||||
|
*
|
||||||
|
* 「退群通知」现在是真规则:状态按真实 rule 展示(运行中 / 已关闭 / 配置异常),
|
||||||
|
* 不再有「待配置」「UI 预览」这类占位标识。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LeaveNotificationCardModel {
|
||||||
|
rule: AutomationRule
|
||||||
|
/** 真实已监控群聊数量(来自退群监控)。 */
|
||||||
|
monitoredCount: number
|
||||||
|
/** 「指定好友」目标的显示名;未选 / 不是该目标时为空串。 */
|
||||||
|
contactName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时日报在首页的**汇总卡**数据。
|
||||||
|
*
|
||||||
|
* 它是多条规则,不能像 singleton 那样当成一条展示(也不能给它一个启停开关)。
|
||||||
|
*/
|
||||||
|
export interface ScheduledReportCardModel {
|
||||||
|
total: number
|
||||||
|
running: number
|
||||||
|
nextRunLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleListPanelProps {
|
||||||
|
rules: AutomationRule[]
|
||||||
|
groups: Array<{ id: string; name: string }>
|
||||||
|
loading: boolean
|
||||||
|
busyRuleId: string | null
|
||||||
|
leaveNotification: LeaveNotificationCardModel | null
|
||||||
|
scheduledReport: ScheduledReportCardModel | null
|
||||||
|
onToggle: (rule: AutomationRule, enabled: boolean) => void
|
||||||
|
onEdit: (rule: AutomationRule) => void
|
||||||
|
onDelete: (rule: AutomationRule) => void
|
||||||
|
onCreate: () => void
|
||||||
|
/** 打开「退群通知」规则类型。 */
|
||||||
|
onEditLeaveNotification: () => void
|
||||||
|
/** 打开「定时日报」规则类型(多条,所以是"管理"而不是"编辑")。 */
|
||||||
|
onManageScheduledReport: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把规则里的 conversationIds 翻成群名;找不到时显示占位而不是裸 id。 */
|
||||||
|
function scopeLabel(rule: AutomationRule, groups: Array<{ id: string; name: string }>): string {
|
||||||
|
const selected = rule.conditions.conversationIds
|
||||||
|
if (!selected.length) return '所有群聊'
|
||||||
|
const names = selected
|
||||||
|
.map((id) => groups.find((group) => group.id === id)?.name)
|
||||||
|
.filter((name): name is string => Boolean(name))
|
||||||
|
if (!names.length) return `已选 ${selected.length} 个群`
|
||||||
|
if (names.length <= 2) return names.join('、')
|
||||||
|
return `${names.slice(0, 2).join('、')} 等 ${names.length} 个群`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退群通知的真实状态。
|
||||||
|
*
|
||||||
|
* 「配置异常」只对应一种真实情况:迁移时旧「通知群聊」无法无损映射,
|
||||||
|
* 规则被标成待重选目标 —— 这时它不会发送,需要用户处理。
|
||||||
|
*/
|
||||||
|
function leaveStatus(rule: AutomationRule): { label: string; tone: 'on' | 'off' | 'pending' } {
|
||||||
|
if (rule.leaveNotification?.targetNeedsReview) return { label: '配置异常', tone: 'pending' }
|
||||||
|
return rule.enabled ? { label: '运行中', tone: 'on' } : { label: '已关闭', tone: 'off' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RuleListPanel({
|
||||||
|
rules,
|
||||||
|
groups,
|
||||||
|
loading,
|
||||||
|
busyRuleId,
|
||||||
|
leaveNotification,
|
||||||
|
scheduledReport,
|
||||||
|
onToggle,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onCreate,
|
||||||
|
onEditLeaveNotification,
|
||||||
|
onManageScheduledReport
|
||||||
|
}: RuleListPanelProps): React.ReactElement {
|
||||||
|
// 退群通知有自己的卡片,别在「已配置的自动化」里重复渲染一遍。
|
||||||
|
const configuredRules = rules.filter((rule) => rule.id !== BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
|
||||||
|
const leaveRule = leaveNotification?.rule ?? null
|
||||||
|
const leaveState = leaveRule ? leaveStatus(leaveRule) : null
|
||||||
|
const configuredCount = configuredRules.length + (leaveRule ? 1 : 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="automation-rule-list">
|
||||||
|
<section className="automation-rule-group">
|
||||||
|
<div className="automation-section-heading">
|
||||||
|
<h2>已配置的自动化</h2>
|
||||||
|
<span className="automation-section-note">{configuredCount} 条</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="automation-loading">
|
||||||
|
<Spinner />
|
||||||
|
<span>正在读取规则…</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{!configuredRules.length && !leaveRule ? (
|
||||||
|
<div className="automation-empty-card">
|
||||||
|
<p>还没有任何自动化。</p>
|
||||||
|
<Button onClick={onCreate}>新建自动化</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{configuredRules.map((rule) => (
|
||||||
|
<article
|
||||||
|
key={rule.id}
|
||||||
|
className={`automation-rule-card ${rule.enabled ? '' : 'disabled'}`}
|
||||||
|
>
|
||||||
|
<div className="automation-rule-card-main">
|
||||||
|
<div className="automation-rule-card-title">
|
||||||
|
<h3>{rule.name}</h3>
|
||||||
|
<span className={`automation-rule-state ${rule.enabled ? 'on' : 'off'}`}>
|
||||||
|
{rule.enabled ? '运行中' : '已停用'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<dl className="automation-rule-meta">
|
||||||
|
<div>
|
||||||
|
<dt>触发</dt>
|
||||||
|
<dd>{describeRuleTrigger(rule)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>范围</dt>
|
||||||
|
<dd>{scopeLabel(rule, groups)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>动作</dt>
|
||||||
|
<dd>{describeRuleActions(rule)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>间隔</dt>
|
||||||
|
<dd>{rule.cooldownSeconds > 0 ? `${rule.cooldownSeconds} 秒` : '不限制'}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div className="automation-rule-card-side">
|
||||||
|
<Switch
|
||||||
|
checked={rule.enabled}
|
||||||
|
disabled={busyRuleId === rule.id}
|
||||||
|
onCheckedChange={(checked) => onToggle(rule, checked)}
|
||||||
|
aria-label={`${rule.name} 启停`}
|
||||||
|
/>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => onEdit(rule)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDelete(rule)}
|
||||||
|
disabled={busyRuleId === rule.id}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{leaveRule && leaveState ? (
|
||||||
|
<article
|
||||||
|
className={`automation-rule-card ${leaveRule.enabled && leaveState.tone === 'on' ? '' : 'disabled'}`}
|
||||||
|
>
|
||||||
|
<div className="automation-rule-card-main">
|
||||||
|
<div className="automation-rule-card-title">
|
||||||
|
<h3>{leaveRule.name}</h3>
|
||||||
|
<span className={`automation-rule-state ${leaveState.tone}`}>
|
||||||
|
{leaveState.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<dl className="automation-rule-meta">
|
||||||
|
<div>
|
||||||
|
<dt>触发</dt>
|
||||||
|
<dd>{describeRuleTrigger(leaveRule)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>通知范围</dt>
|
||||||
|
<dd>
|
||||||
|
{describeLeaveNotificationNotifyScope(
|
||||||
|
leaveRule.leaveNotification,
|
||||||
|
leaveNotification?.monitoredCount ?? 0
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>动作</dt>
|
||||||
|
<dd>{describeRuleActions(leaveRule)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>目标</dt>
|
||||||
|
<dd>
|
||||||
|
{describeLeaveNotificationTarget(
|
||||||
|
leaveRule.leaveNotification,
|
||||||
|
leaveNotification?.contactName
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div className="automation-rule-card-side">
|
||||||
|
<Switch
|
||||||
|
checked={leaveRule.enabled}
|
||||||
|
disabled={busyRuleId === leaveRule.id}
|
||||||
|
onCheckedChange={(checked) => onToggle(leaveRule, checked)}
|
||||||
|
aria-label={`${leaveRule.name} 启停`}
|
||||||
|
/>
|
||||||
|
<Button variant="outline" size="sm" onClick={onEditLeaveNotification}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
) : null}
|
||||||
|
{scheduledReport ? (
|
||||||
|
<article className="automation-rule-card">
|
||||||
|
<div className="automation-rule-card-main">
|
||||||
|
<div className="automation-rule-card-title">
|
||||||
|
<h3>定时日报</h3>
|
||||||
|
{/* 汇总卡:它代表多条规则,所以只报数量,不给单个启停开关。 */}
|
||||||
|
<span
|
||||||
|
className={`automation-rule-state ${scheduledReport.running > 0 ? 'on' : 'off'}`}
|
||||||
|
>
|
||||||
|
{scheduledReport.total} 条规则 · {scheduledReport.running} 条运行中
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<dl className="automation-rule-meta">
|
||||||
|
<div>
|
||||||
|
<dt>触发</dt>
|
||||||
|
<dd>按设定时间</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>下次执行</dt>
|
||||||
|
<dd>{scheduledReport.nextRunLabel}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div className="automation-rule-card-side">
|
||||||
|
<Button variant="outline" size="sm" onClick={onManageScheduledReport}>
|
||||||
|
管理定时日报 →
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="automation-rule-group">
|
||||||
|
<div className="automation-section-heading">
|
||||||
|
<h2>更多模板</h2>
|
||||||
|
</div>
|
||||||
|
<div className="automation-template-grid">
|
||||||
|
{AUTOMATION_RULE_TEMPLATES.map((template) => (
|
||||||
|
<article key={template.id} className="automation-template-card">
|
||||||
|
<div className="automation-template-title">
|
||||||
|
<h3>{template.name}</h3>
|
||||||
|
<span className="automation-template-flag">即将支持</span>
|
||||||
|
</div>
|
||||||
|
<p>{template.description}</p>
|
||||||
|
{template.unavailableReason ? <small>{template.unavailableReason}</small> : null}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
RadioGroup,
|
||||||
|
RadioGroupItem,
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
Switch
|
||||||
|
} from '../../components/ui'
|
||||||
|
import { MessageTypeSelector } from '../../components/reports/MessageTypeSelector'
|
||||||
|
import {
|
||||||
|
DEFAULT_REPORT_IMAGE_POSTFIX_TEXT,
|
||||||
|
SCHEDULED_REPORT_MEMBER_NAME_OPTIONS,
|
||||||
|
SCHEDULED_REPORT_POSTFIX_MAX_LENGTH,
|
||||||
|
SCHEDULED_REPORT_RANGE_OPTIONS,
|
||||||
|
SCHEDULED_REPORT_TARGET_OPTIONS,
|
||||||
|
SCHEDULED_REPORT_TEMPLATE_SELECT_OPTIONS,
|
||||||
|
composeScheduleTime,
|
||||||
|
formatScheduledSlot,
|
||||||
|
parseScheduleTime,
|
||||||
|
resolveGroupDisplayName,
|
||||||
|
type ScheduledReportDraft,
|
||||||
|
type ScheduledReportTargetType
|
||||||
|
} from './model/scheduled-report-model'
|
||||||
|
import { LeaveNotificationTargetPicker } from './LeaveNotificationTargetPicker'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ScheduledReportEditor —— 「自动化 → 规则 → 定时日报」的编辑器。
|
||||||
|
*
|
||||||
|
* 三段:什么时候触发 / 生成什么日报 / 生成后发送到哪里。
|
||||||
|
*
|
||||||
|
* Section 2 承载完整的日报配置(纳入的消息类型 / 日报内容 / 模型配置 /
|
||||||
|
* 日报模板 / 成员名称 / 生成超时),字段全部来自真实模型 —— 不发明,也不删减。
|
||||||
|
*
|
||||||
|
* 草稿仍然是 renderer local state,但**保存是真的落盘**(父层调 `automation:*`)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ScheduledReportEditorProps {
|
||||||
|
initialDraft: ScheduledReportDraft
|
||||||
|
/** 可选群聊(自动化既有的群列表,与其它规则类型同一来源)。 */
|
||||||
|
groups: Array<{ id: string; name: string }>
|
||||||
|
/** 「指定好友」的候选项(与退群通知共用同一个来源与组件)。 */
|
||||||
|
sendableContacts: Array<{ id: string; name: string }>
|
||||||
|
saving: boolean
|
||||||
|
/** 返回「定时日报」规则列表(**不是**退回自动化首页)。 */
|
||||||
|
onBack: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
onSave: (draft: ScheduledReportDraft) => void
|
||||||
|
/** 复用现有的模型配置入口。 */
|
||||||
|
onOpenModelSettings?: () => void
|
||||||
|
/**
|
||||||
|
* 迁移遗留的旧目标原文(只作展示)。
|
||||||
|
*
|
||||||
|
* 有值时说明这条规则是迁移过来的、当时的目标无法自动对应 ——
|
||||||
|
* 界面必须让用户**看见原来的目标是什么**再重选。
|
||||||
|
*/
|
||||||
|
legacyTarget?: string
|
||||||
|
/** 微信异常通知的入口(底部一块,与退群通知同一套开关组件)。 */
|
||||||
|
notificationSlot?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScheduledReportEditor({
|
||||||
|
initialDraft,
|
||||||
|
groups,
|
||||||
|
sendableContacts,
|
||||||
|
saving,
|
||||||
|
onBack,
|
||||||
|
onCancel,
|
||||||
|
onSave,
|
||||||
|
onOpenModelSettings,
|
||||||
|
legacyTarget,
|
||||||
|
notificationSlot
|
||||||
|
}: ScheduledReportEditorProps): React.ReactElement {
|
||||||
|
const [draft, setDraft] = React.useState<ScheduledReportDraft>(initialDraft)
|
||||||
|
const [groupFilter, setGroupFilter] = React.useState('')
|
||||||
|
|
||||||
|
const patch = (next: Partial<ScheduledReportDraft>): void =>
|
||||||
|
setDraft((current) => ({ ...current, ...next }))
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 时间的两个输入框保留**本地文本态**。
|
||||||
|
* 受控回写会在输入过程中把中间态规范化掉,用户就永远拼不出想要的数字;
|
||||||
|
* 规范化推迟到失焦。
|
||||||
|
*/
|
||||||
|
const [hourText, setHourText] = React.useState(() =>
|
||||||
|
String(parseScheduleTime(initialDraft.scheduleTime)[0]).padStart(2, '0')
|
||||||
|
)
|
||||||
|
const [minuteText, setMinuteText] = React.useState(() =>
|
||||||
|
String(parseScheduleTime(initialDraft.scheduleTime)[1]).padStart(2, '0')
|
||||||
|
)
|
||||||
|
|
||||||
|
const commitHour = (raw: string): void => {
|
||||||
|
const digits = raw.replace(/\D/g, '').slice(0, 2)
|
||||||
|
setHourText(digits)
|
||||||
|
if (digits === '') return
|
||||||
|
const value = Number(digits)
|
||||||
|
if (Number.isNaN(value)) return
|
||||||
|
patch({ scheduleTime: composeScheduleTime(value, parseScheduleTime(draft.scheduleTime)[1]) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitMinute = (raw: string): void => {
|
||||||
|
const digits = raw.replace(/\D/g, '').slice(0, 2)
|
||||||
|
setMinuteText(digits)
|
||||||
|
if (digits === '') return
|
||||||
|
const value = Number(digits)
|
||||||
|
if (Number.isNaN(value)) return
|
||||||
|
patch({ scheduleTime: composeScheduleTime(parseScheduleTime(draft.scheduleTime)[0], value) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 候选群 = 自动化现有的群列表 **并上** 当前规则已在用的来源群。
|
||||||
|
*
|
||||||
|
* ⚠️ `value` 一律是**稳定会话 id**(`xxx@chatroom`),不是群名 ——
|
||||||
|
* 群名会改,把群名当 id 存下来的规则改一次名就发错群。
|
||||||
|
* 迁移过来的老规则里可能仍是群名 / md5,这时作为"额外候选项"补进列表,
|
||||||
|
* 界面上用可读名展示,**绝不把裸标识印给用户**。
|
||||||
|
*/
|
||||||
|
const groupOptions = React.useMemo(() => {
|
||||||
|
const known = new Set(groups.map((group) => group.id))
|
||||||
|
const extras = Array.from(
|
||||||
|
new Set([initialDraft.group].filter((value) => Boolean(value) && !known.has(value)))
|
||||||
|
).map((value) => ({ id: value, name: resolveGroupDisplayName(value, groups) }))
|
||||||
|
return [...groups, ...extras]
|
||||||
|
}, [groups, initialDraft.group])
|
||||||
|
|
||||||
|
const visibleGroups = React.useMemo(() => {
|
||||||
|
const needle = groupFilter.trim().toLowerCase()
|
||||||
|
if (!needle) return groupOptions
|
||||||
|
return groupOptions.filter(
|
||||||
|
(group) =>
|
||||||
|
group.name.toLowerCase().includes(needle) || group.id.toLowerCase().includes(needle)
|
||||||
|
)
|
||||||
|
}, [groupOptions, groupFilter])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存前置校验。
|
||||||
|
*
|
||||||
|
* 不允许存一条跑不起来的规则:没有名称 / 没有来源群 / 选了「指定好友」
|
||||||
|
* 却没选人,这三种在运行期必然失败,与其让用户过几天在日志里发现,不如现在说清。
|
||||||
|
*/
|
||||||
|
const saveBlocker = !draft.name.trim()
|
||||||
|
? '请填写任务名称'
|
||||||
|
: !draft.group
|
||||||
|
? '请选择日报来源群'
|
||||||
|
: draft.targetType === 'contact' && !draft.targetContactId
|
||||||
|
? '请选择要发送的指定好友'
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const selectTargetType = (next: ScheduledReportTargetType): void => {
|
||||||
|
// 切换目标类型时清空已选联系人,避免看起来"已经选好了"却发不出去。
|
||||||
|
patch({
|
||||||
|
targetType: next,
|
||||||
|
targetContactId: next === 'contact' ? draft.targetContactId : ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="automation-editor">
|
||||||
|
<button type="button" className="automation-editor-back" onClick={onBack}>
|
||||||
|
← 返回定时日报
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<header className="automation-editor-header">
|
||||||
|
<div>
|
||||||
|
<h2>{draft.name.trim() || '新建定时日报'}</h2>
|
||||||
|
<p>按设定时间自动生成日报,并发送到指定微信会话。</p>
|
||||||
|
<div className="automation-leave-status-row">
|
||||||
|
<span className="automation-field-label">启用这条自动化</span>
|
||||||
|
<Switch
|
||||||
|
checked={draft.enabled}
|
||||||
|
onCheckedChange={(checked) => patch({ enabled: checked })}
|
||||||
|
aria-label="启用这条自动化"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="automation-editor-actions">
|
||||||
|
<Button variant="ghost" onClick={onCancel} disabled={saving}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => onSave(draft)}
|
||||||
|
disabled={saving || Boolean(saveBlocker)}
|
||||||
|
title={saveBlocker || undefined}
|
||||||
|
>
|
||||||
|
{saving ? '保存中…' : '保存'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{saveBlocker ? (
|
||||||
|
<p className="automation-notice warning" role="status" data-testid="scheduled-save-blocker">
|
||||||
|
{saveBlocker}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{legacyTarget ? (
|
||||||
|
<p className="automation-notice warning" role="status" data-testid="scheduled-legacy-target">
|
||||||
|
这条规则是升级前创建的,当时的发送目标({legacyTarget})无法自动对应到新的选项。
|
||||||
|
请重新选择发送目标后再保存。
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="automation-editor-body">
|
||||||
|
<div className="automation-editor-form">
|
||||||
|
<section className="automation-section">
|
||||||
|
<div className="automation-section-heading">
|
||||||
|
<h3>1 · 什么时候触发</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="automation-inline-row">
|
||||||
|
<div>
|
||||||
|
<span className="automation-field-label">执行频率</span>
|
||||||
|
<span className="automation-static-value">每天</span>
|
||||||
|
<small>当前定时日报只支持每天执行一次。</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="automation-inline-row">
|
||||||
|
<div>
|
||||||
|
<span className="automation-field-label">执行时间</span>
|
||||||
|
<div className="automation-scheduled-time">
|
||||||
|
<Input
|
||||||
|
aria-label="执行时间小时"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={hourText}
|
||||||
|
onChange={(event) => commitHour(event.target.value)}
|
||||||
|
onBlur={() =>
|
||||||
|
setHourText(String(parseScheduleTime(draft.scheduleTime)[0]).padStart(2, '0'))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span aria-hidden="true">:</span>
|
||||||
|
<Input
|
||||||
|
aria-label="执行时间分钟"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={minuteText}
|
||||||
|
onChange={(event) => commitMinute(event.target.value)}
|
||||||
|
onBlur={() =>
|
||||||
|
setMinuteText(
|
||||||
|
String(parseScheduleTime(draft.scheduleTime)[1]).padStart(2, '0')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="automation-inline-row">
|
||||||
|
<div>
|
||||||
|
<span className="automation-field-label">预计下次执行</span>
|
||||||
|
<span className="automation-static-value" data-testid="scheduled-next-run">
|
||||||
|
{formatScheduledSlot(draft.scheduleTime)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---------- 2 · 完整日报配置(与旧「定时日报」对齐,一项不删)---------- */}
|
||||||
|
<section className="automation-section">
|
||||||
|
<div className="automation-section-heading">
|
||||||
|
<h3>2 · 生成什么日报</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="automation-field">
|
||||||
|
<span className="automation-field-label">任务名称</span>
|
||||||
|
<Input
|
||||||
|
aria-label="定时日报任务名称"
|
||||||
|
value={draft.name}
|
||||||
|
placeholder="例如:TraceMemo 每日晚报"
|
||||||
|
onChange={(event) => patch({ name: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="automation-field">
|
||||||
|
<span className="automation-field-label">日报来源</span>
|
||||||
|
<small>生成哪个群的日报。</small>
|
||||||
|
<RadioGroup
|
||||||
|
value={draft.group}
|
||||||
|
onValueChange={(value) => patch({ group: value })}
|
||||||
|
aria-label="日报来源"
|
||||||
|
className="automation-leave-contact-picker"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={groupFilter}
|
||||||
|
onChange={(event) => setGroupFilter(event.target.value)}
|
||||||
|
placeholder="搜索群聊"
|
||||||
|
aria-label="搜索群聊"
|
||||||
|
/>
|
||||||
|
<div className="automation-leave-contact-list">
|
||||||
|
{visibleGroups.length === 0 ? (
|
||||||
|
<p className="automation-group-empty">没有可选的群聊</p>
|
||||||
|
) : (
|
||||||
|
visibleGroups.map((group) => (
|
||||||
|
<div key={group.id} className="automation-leave-radio option">
|
||||||
|
<RadioGroupItem value={group.id} id={`scheduled-source-${group.id}`} />
|
||||||
|
<label htmlFor={`scheduled-source-${group.id}`}>{group.name}</label>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="automation-field">
|
||||||
|
<span className="automation-field-label">日报范围</span>
|
||||||
|
<Select
|
||||||
|
value={draft.reportRange}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
patch({ reportRange: value as ScheduledReportDraft['reportRange'] })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger aria-label="日报范围">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SCHEDULED_REPORT_RANGE_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 复用旧日报页的 MessageTypeSelector:类型 / 说明 /「至少选择一种」都来自真实定义。 */}
|
||||||
|
<MessageTypeSelector
|
||||||
|
value={draft.messageTypes}
|
||||||
|
disabled={saving}
|
||||||
|
onChange={(next) => patch({ messageTypes: next })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="automation-field">
|
||||||
|
<span className="automation-field-label">日报内容</span>
|
||||||
|
<small>
|
||||||
|
与普通日报相同,固定生成今日话题、重要消息、问题与解答、实用资源、精彩对话、
|
||||||
|
活跃成员、活跃时间分布、关键词和内容密度。
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="automation-inline-row automation-model-row">
|
||||||
|
<div>
|
||||||
|
<span className="automation-field-label">模型配置</span>
|
||||||
|
<small>使用当前日报默认的文字总结和图片理解模型。</small>
|
||||||
|
</div>
|
||||||
|
{onOpenModelSettings ? (
|
||||||
|
<Button variant="link" size="sm" onClick={onOpenModelSettings}>
|
||||||
|
更改模型
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="automation-field">
|
||||||
|
<span className="automation-field-label">日报模板</span>
|
||||||
|
<Select
|
||||||
|
value={draft.templateId}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
patch({ templateId: value as ScheduledReportDraft['templateId'] })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger aria-label="日报模板">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SCHEDULED_REPORT_TEMPLATE_SELECT_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="automation-field">
|
||||||
|
<span className="automation-field-label">成员名称</span>
|
||||||
|
<Select
|
||||||
|
value={draft.memberNameMode}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
patch({ memberNameMode: value as ScheduledReportDraft['memberNameMode'] })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger aria-label="成员名称">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SCHEDULED_REPORT_MEMBER_NAME_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="automation-field">
|
||||||
|
<span className="automation-field-label">日报生成超时</span>
|
||||||
|
<div className="automation-scheduled-timeout">
|
||||||
|
<Input
|
||||||
|
aria-label="日报生成超时"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={String(draft.timeoutSeconds)}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = Number(event.target.value.replace(/\D/g, '').slice(0, 4))
|
||||||
|
patch({ timeoutSeconds: Number.isNaN(next) ? draft.timeoutSeconds : next })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>秒</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---------- 3 · 发送目标四选一(不提供"指定其他群聊")---------- */}
|
||||||
|
<section className="automation-section">
|
||||||
|
<div className="automation-section-heading">
|
||||||
|
<h3>3 · 生成后发送到哪里</h3>
|
||||||
|
</div>
|
||||||
|
<p className="automation-section-lead">
|
||||||
|
生成哪个群的日报,与把日报发到哪里,是两件事。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<RadioGroup
|
||||||
|
value={draft.targetType}
|
||||||
|
onValueChange={(value) => selectTargetType(value as ScheduledReportTargetType)}
|
||||||
|
aria-label="生成后发送到哪里"
|
||||||
|
className="automation-leave-targets"
|
||||||
|
>
|
||||||
|
{SCHEDULED_REPORT_TARGET_OPTIONS.map((option) => (
|
||||||
|
<div key={option.type} className="automation-leave-radio option">
|
||||||
|
<RadioGroupItem value={option.type} id={`scheduled-target-${option.type}`} />
|
||||||
|
<div className="automation-leave-radio-body">
|
||||||
|
<label htmlFor={`scheduled-target-${option.type}`}>{option.label}</label>
|
||||||
|
<small>{option.description}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
{draft.targetType === 'contact' ? (
|
||||||
|
<div className="automation-field">
|
||||||
|
<span className="automation-field-label">指定好友</span>
|
||||||
|
<LeaveNotificationTargetPicker
|
||||||
|
contacts={sendableContacts}
|
||||||
|
selectedId={draft.targetContactId}
|
||||||
|
onSelect={(id) => patch({ targetContactId: id })}
|
||||||
|
ariaLabel="指定好友"
|
||||||
|
searchPlaceholder="搜索好友"
|
||||||
|
emptyText="没有可发送的联系人"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* 后置词:从「手动发送日报图片」那个输入框原样搬运过来,只是改成按规则存。 */}
|
||||||
|
<div className="automation-field">
|
||||||
|
<span className="automation-field-label">发送后置词</span>
|
||||||
|
<Input
|
||||||
|
aria-label="发送后置词"
|
||||||
|
value={draft.postfixText}
|
||||||
|
maxLength={SCHEDULED_REPORT_POSTFIX_MAX_LENGTH}
|
||||||
|
placeholder={DEFAULT_REPORT_IMAGE_POSTFIX_TEXT}
|
||||||
|
disabled={saving}
|
||||||
|
onChange={(event) => patch({ postfixText: event.target.value })}
|
||||||
|
/>
|
||||||
|
<small>图片确认发送成功后,会发送一条文本消息;留空则只发图片。</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="automation-editor-footnote">
|
||||||
|
微信数据库和聊天记录默认从本机读取。所选内容将发送至你配置的模型服务进行处理,
|
||||||
|
TraceMemo 本身不额外保存或转发内容。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="automation-editor-side">
|
||||||
|
{notificationSlot}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { Button, Switch, useToast } from '../../components/ui'
|
||||||
|
import type {
|
||||||
|
ScheduledReportNotificationCapability,
|
||||||
|
ScheduledReportNotificationSettings
|
||||||
|
} from '../../../../shared/scheduled-report'
|
||||||
|
import type { AutomationRule } from '../../../../shared/automation'
|
||||||
|
import { automationApi } from './model/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ScheduledReportNotificationPanel —— **微信异常通知**的入口。
|
||||||
|
*
|
||||||
|
* 这是「定时日报」功能的一项**全局能力**(不是某条规则的配置):
|
||||||
|
* 定时任务在生成 / 发送失败时,经 Agent Hub 给用户推一条微信。
|
||||||
|
*
|
||||||
|
* 迁移前它挂在旧「日报 → 定时日报」页面上;那个页面退役后,入口挪到这里 ——
|
||||||
|
* 能力本身一行没改,绝不因为删页面而静默丢掉。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ScheduledReportNotificationPanelProps {
|
||||||
|
/** 当前正在编辑的规则(用于「发送测试错误通知」;新建时可传 null)。 */
|
||||||
|
rule: AutomationRule | null
|
||||||
|
/** 异常通知依赖 Agent Hub:未就绪时给一个可操作的去处。 */
|
||||||
|
onOpenAgentHub?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScheduledReportNotificationPanel({
|
||||||
|
rule,
|
||||||
|
onOpenAgentHub
|
||||||
|
}: ScheduledReportNotificationPanelProps): React.ReactElement {
|
||||||
|
const { toast } = useToast()
|
||||||
|
const [settings, setSettings] = React.useState<ScheduledReportNotificationSettings>({
|
||||||
|
enabled: false
|
||||||
|
})
|
||||||
|
const [capability, setCapability] = React.useState<ScheduledReportNotificationCapability>({
|
||||||
|
ready: false
|
||||||
|
})
|
||||||
|
const [loading, setLoading] = React.useState(true)
|
||||||
|
const [saving, setSaving] = React.useState(false)
|
||||||
|
const [testing, setTesting] = React.useState(false)
|
||||||
|
|
||||||
|
const load = React.useCallback(async (): Promise<void> => {
|
||||||
|
const [nextSettings, nextCapability] = await Promise.all([
|
||||||
|
automationApi.getScheduledReportNotificationSettings(),
|
||||||
|
automationApi.getScheduledReportNotificationCapability()
|
||||||
|
])
|
||||||
|
setSettings(nextSettings)
|
||||||
|
setCapability(nextCapability)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let disposed = false
|
||||||
|
setLoading(true)
|
||||||
|
void load().finally(() => {
|
||||||
|
if (!disposed) setLoading(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
disposed = true
|
||||||
|
}
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const handleToggle = async (enabled: boolean): Promise<void> => {
|
||||||
|
setSaving(true)
|
||||||
|
const result = await automationApi.setScheduledReportNotificationEnabled(enabled)
|
||||||
|
setSaving(false)
|
||||||
|
if (!result.success) {
|
||||||
|
// 开启失败时 main 侧会带出真实原因(Agent Hub 未连接 / 未绑定接收者 / 测试发送失败)。
|
||||||
|
toast({
|
||||||
|
description: result.error || '异常通知设置失败',
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 4000
|
||||||
|
})
|
||||||
|
await load()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSettings(result.data)
|
||||||
|
toast({
|
||||||
|
description: result.data.enabled ? '已开启微信异常通知' : '已关闭微信异常通知',
|
||||||
|
duration: 2800
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTest = async (): Promise<void> => {
|
||||||
|
if (!rule) {
|
||||||
|
toast({ description: '请先保存这条定时日报,再发送测试通知', variant: 'destructive', duration: 3200 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setTesting(true)
|
||||||
|
const result = await automationApi.testScheduledReportErrorNotification(rule.id)
|
||||||
|
setTesting(false)
|
||||||
|
toast({
|
||||||
|
description: result.success ? '测试通知已发送,请查看微信' : result.error || '测试通知发送失败',
|
||||||
|
variant: result.success ? undefined : 'destructive',
|
||||||
|
duration: 4000
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const capabilityHint = capability.ready
|
||||||
|
? capability.recipient
|
||||||
|
? `通知将发送给:${capability.recipient}`
|
||||||
|
: '通知接收者已就绪'
|
||||||
|
: capability.error || '异常通知能力尚未就绪。'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="automation-section automation-notification-panel">
|
||||||
|
<div className="automation-section-heading">
|
||||||
|
<h3>微信异常通知</h3>
|
||||||
|
</div>
|
||||||
|
<p className="automation-section-lead">
|
||||||
|
定时日报生成或发送失败时,通过 Agent Hub 给你发一条微信。关闭后不会再推送。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="automation-inline-row">
|
||||||
|
<div>
|
||||||
|
<span className="automation-field-label">开启异常通知</span>
|
||||||
|
<small>{capabilityHint}</small>
|
||||||
|
{!capability.ready && onOpenAgentHub ? (
|
||||||
|
<Button variant="link" size="sm" onClick={onOpenAgentHub}>
|
||||||
|
去连接 Agent Hub
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={settings.enabled}
|
||||||
|
disabled={loading || saving || (!capability.ready && !settings.enabled)}
|
||||||
|
onCheckedChange={(checked) => void handleToggle(checked)}
|
||||||
|
aria-label="开启微信异常通知"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{settings.enabled ? (
|
||||||
|
<div className="automation-inline-row">
|
||||||
|
<div>
|
||||||
|
<span className="automation-field-label">发送测试错误通知</span>
|
||||||
|
<small>验证 Agent Hub 推送链路是否通畅。</small>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={testing || !rule}
|
||||||
|
onClick={() => void handleTest()}
|
||||||
|
>
|
||||||
|
{testing ? '发送中…' : '发送测试'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { Button, Spinner, Switch } from '../../components/ui'
|
||||||
|
import {
|
||||||
|
calculateNextRunAt,
|
||||||
|
normalizeScheduledReportConfig,
|
||||||
|
type AutomationRule
|
||||||
|
} from '../../../../shared/automation'
|
||||||
|
import {
|
||||||
|
formatNextRunAt,
|
||||||
|
scheduledReportRangeLabel,
|
||||||
|
scheduledReportTargetDisplayName,
|
||||||
|
scheduledReportTemplateLabel
|
||||||
|
} from './model/scheduled-report-model'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ScheduledReportRuleList —— 「自动化 → 规则 → 定时日报」的**规则列表**。
|
||||||
|
*
|
||||||
|
* 为什么这里是列表而不是直接进编辑器:定时日报是**多条**规则
|
||||||
|
* (与 singleton 的「退群通知」不同),所以必须先选一条,或新建一条。
|
||||||
|
*
|
||||||
|
* 数据是 `AutomationRuleStore` 里的真实 `scheduled_report` 规则;
|
||||||
|
* 立即执行 / 启停 / 删除全部**真写入**(由父层调 `automation:*` IPC)。
|
||||||
|
*
|
||||||
|
* `onBack` 是这一层**必须**有的逃生口:进了「定时日报」这一类型后,只有
|
||||||
|
* 列表与编辑器两种形态,而列表本身没有「取消」——如果这里不给退出入口,
|
||||||
|
* 用户唯一的出路就是切到别的规则类型 tab,再从那边的「取消」绕回来。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ScheduledReportRuleListProps {
|
||||||
|
rules: AutomationRule[]
|
||||||
|
loading: boolean
|
||||||
|
/** 正在"操作中"的规则 id(视觉反馈,防止重复点击)。 */
|
||||||
|
busyRuleId: string | null
|
||||||
|
/** 群标识 → 显示名(**绝不**把内部标识露给用户)。 */
|
||||||
|
resolveGroupDisplay: (raw: string) => string
|
||||||
|
/** 退出本类型、回到规则面板(三个类型卡片那一层)。 */
|
||||||
|
onBack?: () => void
|
||||||
|
onCreate: () => void
|
||||||
|
onEdit: (rule: AutomationRule) => void
|
||||||
|
onRunNow: (rule: AutomationRule) => void
|
||||||
|
onToggle: (rule: AutomationRule, enabled: boolean) => void
|
||||||
|
onDelete: (rule: AutomationRule) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScheduledReportRuleList({
|
||||||
|
rules,
|
||||||
|
loading,
|
||||||
|
busyRuleId,
|
||||||
|
resolveGroupDisplay,
|
||||||
|
onBack,
|
||||||
|
onCreate,
|
||||||
|
onEdit,
|
||||||
|
onRunNow,
|
||||||
|
onToggle,
|
||||||
|
onDelete
|
||||||
|
}: ScheduledReportRuleListProps): React.ReactElement {
|
||||||
|
const runningCount = rules.filter((rule) => rule.enabled).length
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="automation-rule-list">
|
||||||
|
{/* 没有 onBack 就不渲染死按钮:按钮点不动比没有按钮更糟。 */}
|
||||||
|
{onBack ? (
|
||||||
|
<button type="button" className="automation-editor-back" onClick={onBack}>
|
||||||
|
← 返回规则列表
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="automation-rule-group">
|
||||||
|
<div className="automation-section-heading">
|
||||||
|
<h2>定时日报</h2>
|
||||||
|
<span className="automation-section-note">
|
||||||
|
{rules.length > 0 ? `${rules.length} 条规则` : '尚未配置'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="automation-section-lead">
|
||||||
|
按设定时间自动生成日报,并发送到指定微信会话。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="automation-scheduled-toolbar">
|
||||||
|
<Button onClick={onCreate}>+ 新建定时日报</Button>
|
||||||
|
{rules.length > 0 ? (
|
||||||
|
<span className="automation-section-note">
|
||||||
|
运行中 {runningCount} / {rules.length}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="automation-loading">
|
||||||
|
<Spinner />
|
||||||
|
<span>正在读取定时日报…</span>
|
||||||
|
</div>
|
||||||
|
) : rules.length === 0 ? (
|
||||||
|
<div className="automation-empty-card">
|
||||||
|
<p>还没有定时日报</p>
|
||||||
|
<p>创建一条自动化,让 TraceMemo 在指定时间生成并发送日报。</p>
|
||||||
|
<Button onClick={onCreate}>+ 新建定时日报</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
rules.map((rule) => {
|
||||||
|
const config = normalizeScheduledReportConfig(rule.scheduledReport)
|
||||||
|
const needsReview = config.targetNeedsReview === true
|
||||||
|
const busy = busyRuleId === rule.id
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
key={rule.id}
|
||||||
|
className={`automation-rule-card ${rule.enabled ? '' : 'disabled'}`}
|
||||||
|
>
|
||||||
|
<div className="automation-rule-card-main">
|
||||||
|
<div className="automation-rule-card-title">
|
||||||
|
<h3>{rule.name}</h3>
|
||||||
|
{/* 状态只出现两处:这个 badge + 右侧 toggle,不做第三重表达。 */}
|
||||||
|
<span className={`automation-rule-state ${rule.enabled ? 'on' : 'off'}`}>
|
||||||
|
{rule.enabled ? '运行中' : '已暂停'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{needsReview ? (
|
||||||
|
// 迁移过来但目标无法无损映射:如实提示,绝不冒充"配置正常"。
|
||||||
|
<p className="automation-rule-warning" role="status">
|
||||||
|
这条规则的发送目标需要重新选择(迁移自旧版本,原目标无法自动对应)。
|
||||||
|
{config.legacyTarget ? (
|
||||||
|
<small>原来的目标:{config.legacyTarget}</small>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<dl className="automation-rule-meta">
|
||||||
|
<div>
|
||||||
|
<dt>触发</dt>
|
||||||
|
<dd>每天 {config.schedule.time}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>日报</dt>
|
||||||
|
<dd>{scheduledReportRangeLabel(config.report.range)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>来源</dt>
|
||||||
|
<dd>{resolveGroupDisplay(config.report.sourceConversationId) || '未设置'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>模板</dt>
|
||||||
|
<dd>{scheduledReportTemplateLabel(config.report.templateId)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>发送到</dt>
|
||||||
|
<dd>{scheduledReportTargetDisplayName(config, resolveGroupDisplay)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>下次执行</dt>
|
||||||
|
<dd>
|
||||||
|
{rule.enabled && !needsReview
|
||||||
|
? formatNextRunAt(calculateNextRunAt(config.schedule.time, now))
|
||||||
|
: '—'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div className="automation-rule-card-side">
|
||||||
|
<Switch
|
||||||
|
checked={rule.enabled}
|
||||||
|
disabled={busy}
|
||||||
|
onCheckedChange={(checked) => onToggle(rule, checked)}
|
||||||
|
aria-label={`${rule.name} 启停`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={busy || needsReview}
|
||||||
|
onClick={() => onRunNow(rule)}
|
||||||
|
>
|
||||||
|
立即执行
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={busy} onClick={() => onEdit(rule)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onDelete(rule)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 消息驱动的规则不在这里维护;显式说明,避免用户以为它消失了。 */}
|
||||||
|
<p className="automation-section-footnote">
|
||||||
|
消息驱动的「@我生成日报」规则在上一个类型里维护;这里只列出时间驱动的定时日报。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import type {
|
||||||
|
AutomationExecution,
|
||||||
|
AutomationRule,
|
||||||
|
AutomationRuleDraft,
|
||||||
|
AutomationStatusSummary,
|
||||||
|
ScheduledRuleRunOutcome
|
||||||
|
} from '../../../../../shared/automation'
|
||||||
|
import type {
|
||||||
|
ScheduledReportExecution,
|
||||||
|
ScheduledReportNotification,
|
||||||
|
ScheduledReportNotificationCapability,
|
||||||
|
ScheduledReportNotificationSettings,
|
||||||
|
ScheduledReportNotificationSettingsResult,
|
||||||
|
ScheduledReportResult
|
||||||
|
} from '../../../../../shared/scheduled-report'
|
||||||
|
import type { GroupExitMonitorState } from '../../../../../shared/group-exit-monitor'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `window.api` 的薄封装。
|
||||||
|
*
|
||||||
|
* 存在的理由:preload 暴露的方法在「数据库还没解锁」「preload 与渲染层版本不一致」
|
||||||
|
* 等情况下可能缺失。直接 `window.api.getAutomationStatus()` 会抛
|
||||||
|
* `not a function`,整个页面白屏 —— 用户看到的就是一个坏掉的菜单。
|
||||||
|
* 这里统一降级,让页面始终能渲染出一个**如实说明自己不可用**的状态。
|
||||||
|
*
|
||||||
|
* 只读查询失败 → 返回安全默认值;写操作失败 → 返回 `null` / `false`,
|
||||||
|
* 由调用方决定提示文案(**绝不**假装成功)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface AutomationGroupOption {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UNAVAILABLE_STATUS: AutomationStatusSummary = {
|
||||||
|
listening: false,
|
||||||
|
listeningDegraded: true,
|
||||||
|
todayExecutions: 0,
|
||||||
|
todaySuccesses: 0,
|
||||||
|
sendCapability: {
|
||||||
|
supported: false,
|
||||||
|
ready: false,
|
||||||
|
canSendText: false,
|
||||||
|
canSendImage: false,
|
||||||
|
message: '自动化能力尚未就绪'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiTarget(): Record<string, unknown> | undefined {
|
||||||
|
if (typeof window === 'undefined') return undefined
|
||||||
|
const target = window.api as unknown as Record<string, unknown> | undefined
|
||||||
|
return target ?? undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用一个可能不存在的 preload 方法。
|
||||||
|
*
|
||||||
|
* - 方法缺失 / 抛异常 / reject / 返回空值 → 一律返回 `fallback`。
|
||||||
|
* - 刻意不区分「方法不存在」与「调用失败」:对界面而言,两者都只是「暂时拿不到」。
|
||||||
|
*/
|
||||||
|
function invoke<T>(name: string, fallback: T, ...args: unknown[]): Promise<T> {
|
||||||
|
const target = apiTarget()
|
||||||
|
const fn = target?.[name]
|
||||||
|
if (typeof fn !== 'function') return Promise.resolve(fallback)
|
||||||
|
try {
|
||||||
|
const result = (fn as (...input: unknown[]) => Promise<T>).apply(target, args)
|
||||||
|
if (!result || typeof result.then !== 'function') return Promise.resolve(fallback)
|
||||||
|
return result.then(
|
||||||
|
(value) => (value === undefined || value === null ? fallback : value),
|
||||||
|
() => fallback
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return Promise.resolve(fallback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 自动化接口是否可用(preload 未更新时为 false)。 */
|
||||||
|
export function isAutomationApiAvailable(): boolean {
|
||||||
|
return typeof apiTarget()?.getAutomationStatus === 'function'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const automationApi = {
|
||||||
|
getStatus: (): Promise<AutomationStatusSummary> =>
|
||||||
|
invoke('getAutomationStatus', UNAVAILABLE_STATUS),
|
||||||
|
|
||||||
|
listRules: (): Promise<AutomationRule[]> => invoke('listAutomationRules', []),
|
||||||
|
|
||||||
|
createRule: (draft: AutomationRuleDraft): Promise<AutomationRule | null> =>
|
||||||
|
invoke<AutomationRule | null>('createAutomationRule', null, draft),
|
||||||
|
|
||||||
|
updateRule: (id: string, draft: AutomationRuleDraft): Promise<AutomationRule | null> =>
|
||||||
|
invoke<AutomationRule | null>('updateAutomationRule', null, id, draft),
|
||||||
|
|
||||||
|
deleteRule: (id: string): Promise<boolean> => invoke('deleteAutomationRule', false, id),
|
||||||
|
|
||||||
|
setRuleEnabled: (id: string, enabled: boolean): Promise<AutomationRule | null> =>
|
||||||
|
invoke<AutomationRule | null>('setAutomationRuleEnabled', null, id, enabled),
|
||||||
|
|
||||||
|
listExecutions: (limit = 100): Promise<AutomationExecution[]> =>
|
||||||
|
invoke('listAutomationExecutions', [], { limit }),
|
||||||
|
|
||||||
|
clearExecutions: (): Promise<boolean> => invoke('clearAutomationExecutions', false),
|
||||||
|
|
||||||
|
listGroups: (): Promise<AutomationGroupOption[]> => invoke('listAutomationGroups', []),
|
||||||
|
|
||||||
|
/** 保存「退群通知」规则(singleton upsert,id 由 main 侧固定)。 */
|
||||||
|
saveLeaveNotificationRule: (draft: AutomationRuleDraft): Promise<AutomationRule | null> =>
|
||||||
|
invoke<AutomationRule | null>('saveLeaveNotificationRule', null, draft),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「指定好友」的可选项。
|
||||||
|
*
|
||||||
|
* 过滤(群聊 / 公众号 / 文件传输助手 / 自己)在 main 侧完成 ——
|
||||||
|
* 那里才知道当前登录账号是谁。渲染层不再筛一遍,避免两套规则漂移。
|
||||||
|
*/
|
||||||
|
listSendableContacts: (): Promise<AutomationGroupOption[]> =>
|
||||||
|
invoke('listSendableContacts', []),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 真实已监控群聊数量。
|
||||||
|
*
|
||||||
|
* 数据源是**退群监控**,自动化不维护副本(不做二次群过滤)。
|
||||||
|
* 取数与退群监控页保持同一表达式,否则两个页面会对同一个数字给出不同答案。
|
||||||
|
*/
|
||||||
|
async getMonitoredGroupCount(): Promise<number> {
|
||||||
|
const state = await invoke<GroupExitMonitorState | null>('getGroupExitMonitorState', null)
|
||||||
|
if (!state) return 0
|
||||||
|
if (state.monitorSelectionConfigured) return (state.monitoredRoomIds || []).length
|
||||||
|
return Number(state.monitoredGroupCount) || 0
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 真实已监控群聊清单(含显示名),供退群通知做**二次勾选**。
|
||||||
|
*
|
||||||
|
* 与 `getMonitoredGroupCount` **同一处取数**:清单和数字必须来自同一个快照,
|
||||||
|
* 否则会出现"卡片说 5 个群、勾选列表只有 3 个"这种自相矛盾的界面。
|
||||||
|
*
|
||||||
|
* 名字从群列表里取(`listAutomationGroups`)。取不到的 roomId **保留原样**而不是丢掉 ——
|
||||||
|
* 那个群仍然被监控着,丢掉它等于悄悄缩小用户的范围。
|
||||||
|
*
|
||||||
|
* 未显式设置监控范围时返回空数组:此时没有"被监控的群"这个集合,
|
||||||
|
* 二次筛选无从谈起,由调用方退化成"全部"并如实说明。
|
||||||
|
*/
|
||||||
|
async getMonitoredGroups(): Promise<AutomationGroupOption[]> {
|
||||||
|
const [state, groups] = await Promise.all([
|
||||||
|
invoke<GroupExitMonitorState | null>('getGroupExitMonitorState', null),
|
||||||
|
invoke<AutomationGroupOption[]>('listAutomationGroups', [])
|
||||||
|
])
|
||||||
|
if (!state?.monitorSelectionConfigured) return []
|
||||||
|
const nameById = new Map(groups.map((group) => [group.id, group.name]))
|
||||||
|
return (state.monitoredRoomIds || [])
|
||||||
|
.map((roomId) => ({ id: roomId, name: nameById.get(roomId) || roomId }))
|
||||||
|
.sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 定时日报「立即执行」:与 scheduler 走同一条执行链路(manual trigger)。 */
|
||||||
|
runScheduledReportRule: (
|
||||||
|
ruleId: string
|
||||||
|
): Promise<{ success: boolean; error?: string; data?: ScheduledRuleRunOutcome }> =>
|
||||||
|
invoke<{ success: boolean; error?: string; data?: ScheduledRuleRunOutcome }>(
|
||||||
|
'runScheduledReportRule',
|
||||||
|
{ success: false, error: '定时日报执行接口尚未就绪' },
|
||||||
|
ruleId
|
||||||
|
),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 旧执行记录**只读存档**。
|
||||||
|
*
|
||||||
|
* 旧记录是 7 态 + stage 模型,新执行日志是 3 态 + 通用步骤 —— 两者无法无损互转,
|
||||||
|
* 所以历史记录原样保留、只读展示,不迁进新日志。
|
||||||
|
*/
|
||||||
|
listScheduledReportLegacyExecutions: (ruleId?: string): Promise<ScheduledReportExecution[]> =>
|
||||||
|
invoke('listScheduledReportLegacyExecutions', [], ruleId),
|
||||||
|
|
||||||
|
/** 微信异常通知(随定时日报功能保留的全局能力)。 */
|
||||||
|
getScheduledReportNotificationSettings: (): Promise<ScheduledReportNotificationSettings> =>
|
||||||
|
invoke('getScheduledReportNotificationSettings', { enabled: false }),
|
||||||
|
|
||||||
|
getScheduledReportNotificationCapability: (): Promise<ScheduledReportNotificationCapability> =>
|
||||||
|
invoke('getScheduledReportNotificationCapability', { ready: false }),
|
||||||
|
|
||||||
|
setScheduledReportNotificationEnabled: (
|
||||||
|
enabled: boolean
|
||||||
|
): Promise<ScheduledReportNotificationSettingsResult> =>
|
||||||
|
invoke<ScheduledReportNotificationSettingsResult>(
|
||||||
|
'setScheduledReportNotificationEnabled',
|
||||||
|
{ success: false, data: { enabled: false }, error: '异常通知设置接口尚未就绪' },
|
||||||
|
enabled
|
||||||
|
),
|
||||||
|
|
||||||
|
testScheduledReportErrorNotification: (
|
||||||
|
ruleId: string
|
||||||
|
): Promise<ScheduledReportResult<ScheduledReportNotification>> =>
|
||||||
|
invoke<ScheduledReportResult<ScheduledReportNotification>>(
|
||||||
|
'testScheduledReportErrorNotification',
|
||||||
|
{ success: false, error: '调试接口尚未就绪' },
|
||||||
|
ruleId
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { AutomationStep, AutomationStepStatus } from '../../../../../shared/automation'
|
||||||
|
|
||||||
|
/** 时间 / 耗时的展示格式化。全部是纯函数,便于单测。 */
|
||||||
|
|
||||||
|
const pad = (value: number): string => String(value).padStart(2, '0')
|
||||||
|
|
||||||
|
/** `2026-09-20 21:03` —— 执行日志列表用,够精确又不啰嗦。 */
|
||||||
|
export function formatTriggerTime(timestamp: number): string {
|
||||||
|
if (!timestamp) return '—'
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
return (
|
||||||
|
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
|
||||||
|
`${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `21:03:47` —— 详情抽屉用,需要到秒才能看清步骤先后。 */
|
||||||
|
export function formatClockTime(timestamp?: number): string {
|
||||||
|
if (!timestamp) return '—'
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 耗时:低于 1 秒显示毫秒,否则显示秒,避免「1200ms」这种要心算的写法。 */
|
||||||
|
export function formatDuration(durationMs?: number): string {
|
||||||
|
if (!Number.isFinite(durationMs) || (durationMs ?? 0) < 0) return '—'
|
||||||
|
const value = durationMs as number
|
||||||
|
if (value < 1000) return `${Math.round(value)} ms`
|
||||||
|
if (value < 60_000) return `${(value / 1000).toFixed(1)} 秒`
|
||||||
|
return `${Math.floor(value / 60_000)} 分 ${Math.round((value % 60_000) / 1000)} 秒`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STEP_STATUS_LABELS: Record<AutomationStepStatus, string> = {
|
||||||
|
pending: '等待中',
|
||||||
|
running: '进行中',
|
||||||
|
success: '已完成',
|
||||||
|
failed: '失败',
|
||||||
|
skipped: '已跳过'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 步骤圆点的语义色,供 SCSS 选择器使用。 */
|
||||||
|
export function stepStatusTone(status: AutomationStepStatus): string {
|
||||||
|
if (status === 'success') return 'success'
|
||||||
|
if (status === 'failed') return 'failed'
|
||||||
|
if (status === 'skipped') return 'skipped'
|
||||||
|
return 'pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `skipped` 步骤的说明。
|
||||||
|
*
|
||||||
|
* 用户最容易困惑的就是「为什么这一步没跑」—— 必须区分
|
||||||
|
* 「规则本来就没配这个动作」「上一步挂了所以跳过」「被规则自身的冷却/去重拦下」。
|
||||||
|
*
|
||||||
|
* **优先用服务端给的 `skipReason`**:它知道确切原因(例如「前置步骤失败(日报未生成)」),
|
||||||
|
* 下面这套推断只是兜底,不许在这里重新发明原因。
|
||||||
|
*/
|
||||||
|
export function describeSkippedStep(step: AutomationStep, steps: AutomationStep[]): string {
|
||||||
|
if (step.skipReason) return step.skipReason
|
||||||
|
const index = steps.findIndex((item) => item.key === step.key)
|
||||||
|
const blockedByFailure = steps
|
||||||
|
.slice(0, index < 0 ? 0 : index)
|
||||||
|
.some((item) => item.status === 'failed')
|
||||||
|
if (blockedByFailure) return '上一步失败,已跳过'
|
||||||
|
if (step.key === 'reply') return '规则未启用「回复确认」'
|
||||||
|
if (step.key === 'report') return '规则未启用「生成日报」'
|
||||||
|
if (step.key === 'send') return '规则未启用「发送日报图片」'
|
||||||
|
return '未执行'
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_REPORT_TEMPLATE,
|
||||||
|
REPORT_TEMPLATES,
|
||||||
|
getReportTemplate,
|
||||||
|
type SelectableReportTemplateId
|
||||||
|
} from '../../../../../shared/report-templates'
|
||||||
|
import type {
|
||||||
|
ScheduledReportMemberNameMode,
|
||||||
|
ScheduledReportMessageType,
|
||||||
|
ScheduledReportRange
|
||||||
|
} from '../../../../../shared/scheduled-report'
|
||||||
|
import {
|
||||||
|
DEFAULT_REPORT_IMAGE_POSTFIX_TEXT
|
||||||
|
} from '../../../../../shared/personal-wechat'
|
||||||
|
import {
|
||||||
|
AUTOMATION_REPLY_DELAY_DEFAULT_SECONDS,
|
||||||
|
SCHEDULED_REPORT_DEFAULT_MEMBER_NAME_MODE,
|
||||||
|
SCHEDULED_REPORT_DEFAULT_RANGE,
|
||||||
|
SCHEDULED_REPORT_DEFAULT_TEMPLATE_ID,
|
||||||
|
SCHEDULED_REPORT_DEFAULT_TIMEOUT_SECONDS,
|
||||||
|
SCHEDULED_REPORT_DEFAULT_TARGET_TYPE,
|
||||||
|
SCHEDULED_REPORT_MEMBER_NAME_OPTIONS,
|
||||||
|
SCHEDULED_REPORT_POSTFIX_MAX_LENGTH,
|
||||||
|
SCHEDULED_REPORT_RANGE_LABELS,
|
||||||
|
SCHEDULED_REPORT_TARGET_OPTIONS,
|
||||||
|
normalizeScheduledReportConfig,
|
||||||
|
scheduledReportTargetLabel,
|
||||||
|
type AutomationRule,
|
||||||
|
type AutomationRuleDraft,
|
||||||
|
type ScheduledReportAutomationConfig,
|
||||||
|
type ScheduledReportTargetType
|
||||||
|
} from '../../../../../shared/automation'
|
||||||
|
import { type SummaryMessageType } from '../../../utils/group-report'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时日报编辑器的**真实模型**(唯一一份)。
|
||||||
|
*
|
||||||
|
* 与迁移前的 `scheduled-report-preview.ts` 的根本区别:
|
||||||
|
* 那时这一层只读旧 `ScheduledReportTask`、保存不落盘;现在
|
||||||
|
* `scheduled_report` 已经是真实的 `AutomationRuleType`,
|
||||||
|
* 编辑器产出的草稿会经 `automation:*` IPC **真写入** `AutomationRuleStore`。
|
||||||
|
*
|
||||||
|
* 所以这里只剩两类东西:
|
||||||
|
* 1. 草稿 ↔ `ScheduledReportAutomationConfig` 的双向映射;
|
||||||
|
* 2. 纯展示格式化函数(时间 / 范围 / 模板名)。
|
||||||
|
*
|
||||||
|
* **不再有** `loadScheduledReportTasks()` 这类"只读预览数据层"。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑器草稿。
|
||||||
|
*
|
||||||
|
* 字段与 `ScheduledReportAutomationConfig` 一一对应(外加 `name` / `enabled`),
|
||||||
|
* 唯一多出来的是 `targetContactId`:真实模型里它是 `target.contactId`,
|
||||||
|
* 摊平只是为了表单好用。
|
||||||
|
*/
|
||||||
|
export interface ScheduledReportDraft {
|
||||||
|
name: string
|
||||||
|
enabled: boolean
|
||||||
|
/** 每天的执行时间 `HH:mm`。 */
|
||||||
|
scheduleTime: string
|
||||||
|
/** 日报来源群:生成**哪个群**的日报(稳定会话 id)。 */
|
||||||
|
group: string
|
||||||
|
reportRange: ScheduledReportRange
|
||||||
|
messageTypes: SummaryMessageType[]
|
||||||
|
memberNameMode: ScheduledReportMemberNameMode
|
||||||
|
timeoutSeconds: number
|
||||||
|
templateId: SelectableReportTemplateId
|
||||||
|
targetType: ScheduledReportTargetType
|
||||||
|
targetContactId: string
|
||||||
|
/**
|
||||||
|
* 日报图片发送成功后补发的那句话(「后置词」)。
|
||||||
|
*
|
||||||
|
* 与手动发送日报图片里的后置词同一件事,但这里**按规则存**。
|
||||||
|
* 空字符串 = 只发图片。
|
||||||
|
*/
|
||||||
|
postfixText: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新建规则时「纳入的消息类型」的默认值:**只勾选「文本」**。
|
||||||
|
*
|
||||||
|
* 以前这里是全选(对齐迁移前的旧「定时日报」页面)。改成只选文本是刻意的产品口径:
|
||||||
|
* 日报默认只吃文本,想要图片 / 语音等再手动勾 —— 与日报页(今日日报)的默认值保持一致。
|
||||||
|
*
|
||||||
|
* 存量规则**不受影响**:`createDraftFromRule` 读的是规则自己存的 `messageTypes`,
|
||||||
|
* 只有「新建」才会用到这个默认值。
|
||||||
|
*/
|
||||||
|
export const SCHEDULED_REPORT_DEFAULT_MESSAGE_TYPES: SummaryMessageType[] = ['text']
|
||||||
|
|
||||||
|
/** 日报范围选项(文案来自共享层,避免同一份配置出现两种叫法)。 */
|
||||||
|
export const SCHEDULED_REPORT_RANGE_OPTIONS: ReadonlyArray<{
|
||||||
|
value: ScheduledReportRange
|
||||||
|
label: string
|
||||||
|
}> = (['today', 'yesterday', '7days', 'recent24h'] as ScheduledReportRange[]).map((value) => ({
|
||||||
|
value,
|
||||||
|
label: SCHEDULED_REPORT_RANGE_LABELS[value]
|
||||||
|
}))
|
||||||
|
|
||||||
|
/** 成员名称选项(共享层唯一来源)。 */
|
||||||
|
export { SCHEDULED_REPORT_MEMBER_NAME_OPTIONS, SCHEDULED_REPORT_TARGET_OPTIONS }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后置词输入框的约束与占位文案。
|
||||||
|
*
|
||||||
|
* 两个都从共享层转出,**刻意不让编辑器直接 import shared** ——
|
||||||
|
* 这样「手动发送」与「定时日报」两个入口的 200 字上限和占位句只有一份定义。
|
||||||
|
*/
|
||||||
|
export { SCHEDULED_REPORT_POSTFIX_MAX_LENGTH, DEFAULT_REPORT_IMAGE_POSTFIX_TEXT }
|
||||||
|
|
||||||
|
/** 发送目标四选一(共享层唯一来源)。 */
|
||||||
|
export type { ScheduledReportTargetType }
|
||||||
|
|
||||||
|
/** 模板选项(默认模板 + 社区模板)。 */
|
||||||
|
export const SCHEDULED_REPORT_TEMPLATE_SELECT_OPTIONS: ReadonlyArray<{
|
||||||
|
value: SelectableReportTemplateId
|
||||||
|
label: string
|
||||||
|
}> = [
|
||||||
|
{ value: DEFAULT_REPORT_TEMPLATE.id, label: DEFAULT_REPORT_TEMPLATE.name },
|
||||||
|
...REPORT_TEMPLATES.map((template) => ({
|
||||||
|
value: template.id,
|
||||||
|
label: `${template.label} · ${template.name}`
|
||||||
|
}))
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 与旧页面同语义的范围文案。 */
|
||||||
|
export function scheduledReportRangeLabel(range: ScheduledReportRange): string {
|
||||||
|
return SCHEDULED_REPORT_RANGE_LABELS[range] ?? SCHEDULED_REPORT_RANGE_LABELS.today
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模板的展示名(列表 / 编辑器 / 预览必须是同一个称呼)。 */
|
||||||
|
export function scheduledReportTemplateLabel(templateId?: string): string {
|
||||||
|
return getReportTemplate(templateId).name
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发送目标的展示名(列表用;不再需要"target 是否等于 group"的猜测)。 */
|
||||||
|
export function scheduledReportTargetDisplayName(
|
||||||
|
config: ScheduledReportAutomationConfig | undefined,
|
||||||
|
resolveGroupDisplay: (raw: string) => string
|
||||||
|
): string {
|
||||||
|
const target = config?.target
|
||||||
|
if (!target) return ''
|
||||||
|
if (target.type === 'source_chat') {
|
||||||
|
const group = resolveGroupDisplay(config.report.sourceConversationId)
|
||||||
|
return group ? `${scheduledReportTargetLabel('source_chat')}(${group})` : scheduledReportTargetLabel('source_chat')
|
||||||
|
}
|
||||||
|
if (target.type === 'contact') return scheduledReportTargetLabel('contact')
|
||||||
|
return scheduledReportTargetLabel(target.type)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新建草稿:目标默认「自己」,避免任何形式的"没选就发到群里"。 */
|
||||||
|
export function createEmptyScheduledReportDraft(): ScheduledReportDraft {
|
||||||
|
return {
|
||||||
|
name: '',
|
||||||
|
enabled: true,
|
||||||
|
scheduleTime: '18:30',
|
||||||
|
group: '',
|
||||||
|
reportRange: SCHEDULED_REPORT_DEFAULT_RANGE,
|
||||||
|
messageTypes: [...SCHEDULED_REPORT_DEFAULT_MESSAGE_TYPES],
|
||||||
|
memberNameMode: SCHEDULED_REPORT_DEFAULT_MEMBER_NAME_MODE,
|
||||||
|
timeoutSeconds: SCHEDULED_REPORT_DEFAULT_TIMEOUT_SECONDS,
|
||||||
|
templateId: SCHEDULED_REPORT_DEFAULT_TEMPLATE_ID,
|
||||||
|
targetType: SCHEDULED_REPORT_DEFAULT_TARGET_TYPE,
|
||||||
|
targetContactId: '',
|
||||||
|
// 刻意留空:新建规则默认**不**补发任何文本,用户明确要才填。
|
||||||
|
postfixText: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 真实规则 → 编辑器草稿。 */
|
||||||
|
export function createDraftFromRule(rule: AutomationRule): ScheduledReportDraft {
|
||||||
|
const config = normalizeScheduledReportConfig(rule.scheduledReport)
|
||||||
|
return {
|
||||||
|
name: rule.name,
|
||||||
|
enabled: rule.enabled,
|
||||||
|
scheduleTime: config.schedule.time,
|
||||||
|
group: config.report.sourceConversationId,
|
||||||
|
reportRange: config.report.range,
|
||||||
|
messageTypes: config.report.messageTypes.length
|
||||||
|
? (config.report.messageTypes as SummaryMessageType[])
|
||||||
|
: [...SCHEDULED_REPORT_DEFAULT_MESSAGE_TYPES],
|
||||||
|
memberNameMode: config.report.memberNameMode,
|
||||||
|
timeoutSeconds: config.report.timeoutSeconds,
|
||||||
|
templateId: config.report.templateId,
|
||||||
|
targetType: config.target.type,
|
||||||
|
targetContactId: config.target.contactId ?? '',
|
||||||
|
postfixText: config.postfixText ?? ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 草稿 → `AutomationRuleDraft`(真正落盘的那一份)。
|
||||||
|
*
|
||||||
|
* 定时日报由**时间驱动**,不参与消息匹配;`trigger` / `scope` / `conditions` /
|
||||||
|
* `actions` / `cooldownSeconds` 这几个字段保留只是为了 schema 完整
|
||||||
|
* (`matchAutomationRule` 对 `scheduled_report` 是硬分派,永不读它们)。
|
||||||
|
*
|
||||||
|
* 显式清掉 `targetNeedsReview`:用户手动保存一次即代表"目标已确认"。
|
||||||
|
* 旧的 `legacyTarget` / `legacySourceGroup` 也在这里丢弃 ——
|
||||||
|
* 它们只服务于"让用户看清原来配的是什么",保存之后就不再需要。
|
||||||
|
*/
|
||||||
|
export function draftToAutomationRuleDraft(draft: ScheduledReportDraft): AutomationRuleDraft {
|
||||||
|
return {
|
||||||
|
name: draft.name.trim(),
|
||||||
|
enabled: draft.enabled,
|
||||||
|
ruleType: 'scheduled_report',
|
||||||
|
trigger: 'message',
|
||||||
|
scope: 'group',
|
||||||
|
conditions: {
|
||||||
|
requireMentionMe: false,
|
||||||
|
keyword: '',
|
||||||
|
keywordMatchMode: 'contains',
|
||||||
|
conversationIds: [],
|
||||||
|
ignoreSelf: true
|
||||||
|
},
|
||||||
|
actions: [],
|
||||||
|
// 不套用消息型规则的 cooldown:定时规则有自己的 ruleId inFlight 锁。
|
||||||
|
cooldownSeconds: 0,
|
||||||
|
replyDelaySeconds: AUTOMATION_REPLY_DELAY_DEFAULT_SECONDS,
|
||||||
|
scheduledReport: normalizeScheduledReportConfig({
|
||||||
|
schedule: { time: draft.scheduleTime },
|
||||||
|
report: {
|
||||||
|
sourceConversationId: draft.group,
|
||||||
|
range: draft.reportRange,
|
||||||
|
messageTypes: draft.messageTypes as ScheduledReportMessageType[],
|
||||||
|
templateId: draft.templateId,
|
||||||
|
memberNameMode: draft.memberNameMode,
|
||||||
|
timeoutSeconds: draft.timeoutSeconds
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
type: draft.targetType,
|
||||||
|
...(draft.targetType === 'contact' ? { contactId: draft.targetContactId } : {})
|
||||||
|
},
|
||||||
|
postfixText: draft.postfixText
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 时间展示:`今日 18:30` / `明日 18:30`。
|
||||||
|
*
|
||||||
|
* 只服务编辑器里的"预计下次执行"(草稿还没落盘,没有真实 `nextRunAt` 可读)。
|
||||||
|
*/
|
||||||
|
export function formatScheduledSlot(scheduleTime: string, now = new Date()): string {
|
||||||
|
const [hour, minute] = parseScheduleTime(scheduleTime)
|
||||||
|
const slot = new Date(now)
|
||||||
|
slot.setSeconds(0, 0)
|
||||||
|
slot.setHours(hour, minute)
|
||||||
|
if (slot.getTime() <= now.getTime()) slot.setDate(slot.getDate() + 1)
|
||||||
|
const isToday = slot.toDateString() === now.toDateString()
|
||||||
|
return `${isToday ? '今日' : '明日'} ${pad2(hour)}:${pad2(minute)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把真实的 `nextRunAt`(ISO)格式化成旧页面同款文案。 */
|
||||||
|
export function formatNextRunAt(value?: string): string {
|
||||||
|
if (!value) return '尚未执行'
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return '时间未知'
|
||||||
|
const now = new Date()
|
||||||
|
const dayLabel =
|
||||||
|
date.toDateString() === now.toDateString() ? '今日' : date.toLocaleDateString('zh-CN')
|
||||||
|
return `${dayLabel} ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseScheduleTime(value: string): [number, number] {
|
||||||
|
const matched = /^(\d{1,2}):(\d{2})$/.exec(String(value ?? '').trim())
|
||||||
|
if (!matched) return [18, 30]
|
||||||
|
const hour = Math.min(23, Math.max(0, Number(matched[1])))
|
||||||
|
const minute = Math.min(59, Math.max(0, Number(matched[2])))
|
||||||
|
return [hour, minute]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function composeScheduleTime(hour: number, minute: number): string {
|
||||||
|
return `${pad2(Math.min(23, Math.max(0, hour)))}:${pad2(Math.min(59, Math.max(0, minute)))}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把规则里存的群标识解析成**能给用户看**的名字。
|
||||||
|
*
|
||||||
|
* 迁移过来的老数据可能仍是会话 md5 / 群名 / roomId 三种形态之一;
|
||||||
|
* 直接渲染就会把内部标识暴露给用户。能对上群名就用群名;
|
||||||
|
* 明显是内部标识又对不上时显示「未知群聊」,**绝不显示裸 id**。
|
||||||
|
*/
|
||||||
|
export function resolveGroupDisplayName(
|
||||||
|
raw: string,
|
||||||
|
groups: Array<{ id: string; name: string }>
|
||||||
|
): string {
|
||||||
|
const value = String(raw ?? '').trim()
|
||||||
|
if (!value) return ''
|
||||||
|
const matched = groups.find((group) => group.id === value) ?? groups.find((g) => g.name === value)
|
||||||
|
if (matched) return matched.name
|
||||||
|
return looksLikeInternalIdentifier(value) ? '未知群聊' : value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 内部标识形态:room id / 16 进制 hash / 纯数字串。 */
|
||||||
|
function looksLikeInternalIdentifier(value: string): boolean {
|
||||||
|
return (
|
||||||
|
value.includes('@chatroom') || /^[0-9a-f]{16,}$/i.test(value) || /^\d{6,}$/.test(value)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad2(value: number): string {
|
||||||
|
return String(value).padStart(2, '0')
|
||||||
|
}
|
||||||
@@ -4,16 +4,12 @@ import {
|
|||||||
Checkbox,
|
Checkbox,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
Input,
|
Input,
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
Switch,
|
Switch,
|
||||||
Textarea,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger
|
TooltipTrigger
|
||||||
@@ -21,20 +17,37 @@ import {
|
|||||||
import type { Contact } from '../../../../shared/types'
|
import type { Contact } from '../../../../shared/types'
|
||||||
import {
|
import {
|
||||||
GROUP_EXIT_NOTIFICATION_TEMPLATE,
|
GROUP_EXIT_NOTIFICATION_TEMPLATE,
|
||||||
GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH,
|
|
||||||
renderGroupExitMonitorNotification,
|
renderGroupExitMonitorNotification,
|
||||||
validateGroupExitNotificationTemplate,
|
|
||||||
type GroupExitMonitorEvent,
|
type GroupExitMonitorEvent,
|
||||||
type GroupExitMonitorState
|
type GroupExitMonitorState
|
||||||
} from '../../../../shared/group-exit-monitor'
|
} from '../../../../shared/group-exit-monitor'
|
||||||
|
import { BUILTIN_LEAVE_NOTIFICATION_RULE_ID } from '../../../../shared/automation'
|
||||||
import type { PersonalWechatSendCapability } from '../../../../shared/personal-wechat'
|
import type { PersonalWechatSendCapability } from '../../../../shared/personal-wechat'
|
||||||
|
import { LeaveMonitorAutomationEntry } from './LeaveMonitorAutomationEntry'
|
||||||
|
|
||||||
type GroupSelectionFilter = 'all' | 'selected' | 'unselected'
|
type GroupSelectionFilter = 'all' | 'selected' | 'unselected'
|
||||||
|
|
||||||
|
/** 从「自动化 → 退群通知」跳回来时直接打开哪一页。 */
|
||||||
|
export interface GroupExitMonitorOpenViewRequest {
|
||||||
|
view: 'events' | 'manage'
|
||||||
|
requestId: number
|
||||||
|
}
|
||||||
|
|
||||||
interface GroupExitMonitorWorkspaceProps {
|
interface GroupExitMonitorWorkspaceProps {
|
||||||
dbReady: boolean
|
dbReady: boolean
|
||||||
contacts?: Contact[]
|
contacts?: Contact[]
|
||||||
onOpenSendSettings?: () => void
|
onOpenSendSettings?: () => void
|
||||||
|
/** 深链请求:直接落到「退群监控 → 管理群聊」。 */
|
||||||
|
openViewRequest?: GroupExitMonitorOpenViewRequest | null
|
||||||
|
/**
|
||||||
|
* 深链请求已被消费(父层据此把它清掉)。
|
||||||
|
*
|
||||||
|
* **不给这个回调,深链就会变成常驻状态**:本组件在切换一级菜单时会被卸载,
|
||||||
|
* 而请求还挂在父层,于是下次正常点「退群监控」也会被重放,直接落到「管理群聊」。
|
||||||
|
*/
|
||||||
|
onOpenViewRequestHandled?: () => void
|
||||||
|
/** 「退群通知自动化 →」:跳到自动化里对应的规则类型(纯导航)。 */
|
||||||
|
onOpenLeaveNotificationAutomation?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_STATE: GroupExitMonitorState = {
|
const EMPTY_STATE: GroupExitMonitorState = {
|
||||||
@@ -45,7 +58,6 @@ const EMPTY_STATE: GroupExitMonitorState = {
|
|||||||
monitoredGroupCount: 0,
|
monitoredGroupCount: 0,
|
||||||
monitorSelectionConfigured: true,
|
monitorSelectionConfigured: true,
|
||||||
monitoredRoomIds: [],
|
monitoredRoomIds: [],
|
||||||
notificationRoomIds: [],
|
|
||||||
lastReadAt: 0,
|
lastReadAt: 0,
|
||||||
unreadCount: 0
|
unreadCount: 0
|
||||||
}
|
}
|
||||||
@@ -85,56 +97,6 @@ const sendCapabilityLabel = (capability: PersonalWechatSendCapability | null): s
|
|||||||
const sendCapabilityTone = (capability: PersonalWechatSendCapability | null): string =>
|
const sendCapabilityTone = (capability: PersonalWechatSendCapability | null): string =>
|
||||||
capability?.ready && capability.capabilities.text ? 'ready' : 'unready'
|
capability?.ready && capability.capabilities.text ? 'ready' : 'unready'
|
||||||
|
|
||||||
function EventNotificationStatus({
|
|
||||||
event,
|
|
||||||
onOpenSendSettings,
|
|
||||||
onResend,
|
|
||||||
resending
|
|
||||||
}: {
|
|
||||||
event: GroupExitMonitorState['events'][number]
|
|
||||||
onOpenSendSettings?: () => void
|
|
||||||
onResend?: () => void
|
|
||||||
resending?: boolean
|
|
||||||
}): React.ReactElement {
|
|
||||||
const status = event.notificationStatus || event.notification?.status || 'not_requested'
|
|
||||||
const details =
|
|
||||||
status === 'sent'
|
|
||||||
? { label: '已通知当前群聊', tone: 'sent', description: '' }
|
|
||||||
: status === 'pending'
|
|
||||||
? { label: '正在通知当前群聊', tone: 'pending', description: '' }
|
|
||||||
: status === 'blocked'
|
|
||||||
? { label: '通知已拦截', tone: 'blocked', description: '该操作未通过发送策略检查。' }
|
|
||||||
: status === 'failed'
|
|
||||||
? {
|
|
||||||
label: '通知未发送',
|
|
||||||
tone: 'failed',
|
|
||||||
description:
|
|
||||||
event.notification?.errorCode === 'SEND_CAPABILITY_UNAVAILABLE' ||
|
|
||||||
event.notification?.errorCode === 'SEND_NOT_READY'
|
|
||||||
? '当前微信发送能力不可用,退群事件已正常记录。'
|
|
||||||
: '退群事件已正常记录,但通知发送失败。'
|
|
||||||
}
|
|
||||||
: { label: '仅记录', tone: 'recorded', description: '' }
|
|
||||||
return (
|
|
||||||
<div className={`exit-monitor-event-notification ${details.tone}`}>
|
|
||||||
<span>{details.label}</span>
|
|
||||||
{details.description ? <small>{details.description}</small> : null}
|
|
||||||
{(event.notification?.errorCode === 'SEND_CAPABILITY_UNAVAILABLE' ||
|
|
||||||
event.notification?.errorCode === 'SEND_NOT_READY') &&
|
|
||||||
onOpenSendSettings ? (
|
|
||||||
<Button variant="link" size="sm" onClick={onOpenSendSettings}>
|
|
||||||
去设置
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{status !== 'sent' && status !== 'pending' && onResend ? (
|
|
||||||
<Button variant="link" size="sm" onClick={onResend} disabled={resending}>
|
|
||||||
{resending ? '发送中...' : '重新发送'}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SendCapabilityStatus({
|
function SendCapabilityStatus({
|
||||||
capability,
|
capability,
|
||||||
className = ''
|
className = ''
|
||||||
@@ -160,88 +122,12 @@ function SendCapabilityStatus({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function GroupExitNotificationTemplate({
|
/*
|
||||||
template,
|
* 「退群监测模板」编辑入口**已从这里移除**。
|
||||||
saving,
|
*
|
||||||
error,
|
* 模板的唯一编辑处现在是「自动化 → 规则 → 退群通知 → 3 · 通知内容」。
|
||||||
onSave
|
* 退群监控只产生退群事实,不再持有通知模板 —— 两处可编辑必然漂移。
|
||||||
}: {
|
*/
|
||||||
template: string
|
|
||||||
saving: boolean
|
|
||||||
error: string
|
|
||||||
onSave: (template: string) => Promise<boolean>
|
|
||||||
}): React.ReactElement {
|
|
||||||
const [open, setOpen] = React.useState(false)
|
|
||||||
const [draft, setDraft] = React.useState(template)
|
|
||||||
const [validationError, setValidationError] = React.useState('')
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!open) setDraft(template)
|
|
||||||
}, [open, template])
|
|
||||||
|
|
||||||
const handleOpenChange = (nextOpen: boolean): void => {
|
|
||||||
setOpen(nextOpen)
|
|
||||||
setValidationError('')
|
|
||||||
if (nextOpen) setDraft(template)
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveDraft = async (): Promise<void> => {
|
|
||||||
const result = validateGroupExitNotificationTemplate(draft)
|
|
||||||
if (!result.valid || !result.template) {
|
|
||||||
setValidationError(result.error || '模板无效')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setValidationError('')
|
|
||||||
const saved = await onSave(result.template)
|
|
||||||
if (saved) setOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button variant="outline" size="sm" aria-label="查看退群监测模板">
|
|
||||||
模板
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent align="end" className="exit-monitor-template-popover">
|
|
||||||
<div className="exit-monitor-template-heading">
|
|
||||||
<div>
|
|
||||||
<span className="exit-monitor-eyebrow">消息格式</span>
|
|
||||||
<h2>退群监测模板</h2>
|
|
||||||
</div>
|
|
||||||
<span className="exit-monitor-template-status">发送时使用</span>
|
|
||||||
</div>
|
|
||||||
<Textarea
|
|
||||||
id="exit-monitor-notification-template"
|
|
||||||
aria-label="退群监测模板内容"
|
|
||||||
value={draft}
|
|
||||||
rows={10}
|
|
||||||
maxLength={GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH}
|
|
||||||
onChange={(event) => setDraft(event.target.value)}
|
|
||||||
className="exit-monitor-template-editor"
|
|
||||||
/>
|
|
||||||
<div className="exit-monitor-template-meta">
|
|
||||||
<span>
|
|
||||||
支持 {`{user}`}、{`{groupRemark}`}、{`{wxid}`}、{`{previousCount}`}、{`{currentCount}`}
|
|
||||||
、{`{time}`}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{draft.length}/{GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p>退群时会替换占位符;群备注指退群前的群内昵称,没有群备注时显示“未设置”。</p>
|
|
||||||
{validationError || error ? (
|
|
||||||
<p className="exit-monitor-template-error">{validationError || error}</p>
|
|
||||||
) : null}
|
|
||||||
<div className="exit-monitor-template-actions">
|
|
||||||
<Button size="sm" onClick={() => void saveDraft()} disabled={saving}>
|
|
||||||
{saving ? '保存中...' : '保存模板'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function GroupAvatar({ contact }: { contact: Contact }): React.ReactElement {
|
function GroupAvatar({ contact }: { contact: Contact }): React.ReactElement {
|
||||||
const name = contactDisplayName(contact)
|
const name = contactDisplayName(contact)
|
||||||
@@ -256,7 +142,6 @@ function GroupAvatar({ contact }: { contact: Contact }): React.ReactElement {
|
|||||||
interface ManageGroupsProps {
|
interface ManageGroupsProps {
|
||||||
groups: Contact[]
|
groups: Contact[]
|
||||||
selectedRoomIds: Set<string>
|
selectedRoomIds: Set<string>
|
||||||
notificationRoomIds: Set<string>
|
|
||||||
sendCapability: PersonalWechatSendCapability | null
|
sendCapability: PersonalWechatSendCapability | null
|
||||||
keyword: string
|
keyword: string
|
||||||
listFilter: GroupSelectionFilter
|
listFilter: GroupSelectionFilter
|
||||||
@@ -265,20 +150,14 @@ interface ManageGroupsProps {
|
|||||||
onKeywordChange: (keyword: string) => void
|
onKeywordChange: (keyword: string) => void
|
||||||
onListFilterChange: (filter: GroupSelectionFilter) => void
|
onListFilterChange: (filter: GroupSelectionFilter) => void
|
||||||
onToggle: (roomId: string) => void
|
onToggle: (roomId: string) => void
|
||||||
onToggleNotification: (roomId: string) => void
|
|
||||||
onToggleAll: () => void
|
onToggleAll: () => void
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
notificationTemplate: string
|
|
||||||
templateSaving: boolean
|
|
||||||
templateError: string
|
|
||||||
onSaveTemplate: (template: string) => Promise<boolean>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ManageGroups({
|
function ManageGroups({
|
||||||
groups,
|
groups,
|
||||||
selectedRoomIds,
|
selectedRoomIds,
|
||||||
notificationRoomIds,
|
|
||||||
sendCapability,
|
sendCapability,
|
||||||
keyword,
|
keyword,
|
||||||
listFilter,
|
listFilter,
|
||||||
@@ -287,14 +166,9 @@ function ManageGroups({
|
|||||||
onKeywordChange,
|
onKeywordChange,
|
||||||
onListFilterChange,
|
onListFilterChange,
|
||||||
onToggle,
|
onToggle,
|
||||||
onToggleNotification,
|
|
||||||
onToggleAll,
|
onToggleAll,
|
||||||
onSave,
|
onSave,
|
||||||
onCancel,
|
onCancel
|
||||||
notificationTemplate,
|
|
||||||
templateSaving,
|
|
||||||
templateError,
|
|
||||||
onSaveTemplate
|
|
||||||
}: ManageGroupsProps): React.ReactElement {
|
}: ManageGroupsProps): React.ReactElement {
|
||||||
const lowerKeyword = keyword.trim().toLowerCase()
|
const lowerKeyword = keyword.trim().toLowerCase()
|
||||||
const filteredGroups = React.useMemo(() => {
|
const filteredGroups = React.useMemo(() => {
|
||||||
@@ -326,12 +200,6 @@ function ManageGroups({
|
|||||||
<p>选择需要持续关注成员变化的群聊,保存后会重新建立成员快照。</p>
|
<p>选择需要持续关注成员变化的群聊,保存后会重新建立成员快照。</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="exit-monitor-manage-header-status">
|
<div className="exit-monitor-manage-header-status">
|
||||||
<GroupExitNotificationTemplate
|
|
||||||
template={notificationTemplate}
|
|
||||||
saving={templateSaving}
|
|
||||||
error={templateError}
|
|
||||||
onSave={onSaveTemplate}
|
|
||||||
/>
|
|
||||||
<SendCapabilityStatus capability={sendCapability} />
|
<SendCapabilityStatus capability={sendCapability} />
|
||||||
<div className="exit-monitor-manage-count" aria-live="polite">
|
<div className="exit-monitor-manage-count" aria-live="polite">
|
||||||
已选择 <strong>{selectedRoomIds.size}</strong> 个群聊
|
已选择 <strong>{selectedRoomIds.size}</strong> 个群聊
|
||||||
@@ -394,7 +262,6 @@ function ManageGroups({
|
|||||||
const roomId = contactRoomId(contact)
|
const roomId = contactRoomId(contact)
|
||||||
const name = contactDisplayName(contact)
|
const name = contactDisplayName(contact)
|
||||||
const checked = selectedRoomIds.has(roomId)
|
const checked = selectedRoomIds.has(roomId)
|
||||||
const notificationChecked = notificationRoomIds.has(roomId)
|
|
||||||
return (
|
return (
|
||||||
<div key={roomId} className={`exit-monitor-group-row ${checked ? 'selected' : ''}`}>
|
<div key={roomId} className={`exit-monitor-group-row ${checked ? 'selected' : ''}`}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -412,15 +279,6 @@ function ManageGroups({
|
|||||||
<span className="exit-monitor-group-row-status">
|
<span className="exit-monitor-group-row-status">
|
||||||
{checked ? '监控中' : '未选择'}
|
{checked ? '监控中' : '未选择'}
|
||||||
</span>
|
</span>
|
||||||
<label className="exit-monitor-group-notification">
|
|
||||||
<Checkbox
|
|
||||||
checked={notificationChecked}
|
|
||||||
disabled={!checked}
|
|
||||||
onCheckedChange={() => onToggleNotification(roomId)}
|
|
||||||
aria-label={`是否通知${name}`}
|
|
||||||
/>
|
|
||||||
<span>通知群聊</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -435,7 +293,9 @@ function ManageGroups({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer className="exit-monitor-manage-footer">
|
<footer className="exit-monitor-manage-footer">
|
||||||
<span>保存后将从新的成员快照开始记录退群动态;通知仅发送到已勾选群聊。</span>
|
<span>
|
||||||
|
保存后将从新的成员快照开始记录退群动态;退群通知发到哪里,请在「自动化 → 退群通知」里配置。
|
||||||
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<Button variant="ghost" onClick={onCancel} disabled={saving}>
|
<Button variant="ghost" onClick={onCancel} disabled={saving}>
|
||||||
取消
|
取消
|
||||||
@@ -452,7 +312,10 @@ function ManageGroups({
|
|||||||
export function GroupExitMonitorWorkspace({
|
export function GroupExitMonitorWorkspace({
|
||||||
dbReady,
|
dbReady,
|
||||||
contacts = [],
|
contacts = [],
|
||||||
onOpenSendSettings
|
onOpenSendSettings,
|
||||||
|
openViewRequest,
|
||||||
|
onOpenViewRequestHandled,
|
||||||
|
onOpenLeaveNotificationAutomation
|
||||||
}: GroupExitMonitorWorkspaceProps): React.ReactElement {
|
}: GroupExitMonitorWorkspaceProps): React.ReactElement {
|
||||||
const [state, setState] = React.useState<GroupExitMonitorState>(EMPTY_STATE)
|
const [state, setState] = React.useState<GroupExitMonitorState>(EMPTY_STATE)
|
||||||
const [checking, setChecking] = React.useState(false)
|
const [checking, setChecking] = React.useState(false)
|
||||||
@@ -462,21 +325,23 @@ export function GroupExitMonitorWorkspace({
|
|||||||
const [sendCapability, setSendCapability] = React.useState<PersonalWechatSendCapability | null>(
|
const [sendCapability, setSendCapability] = React.useState<PersonalWechatSendCapability | null>(
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
const [templateSaving, setTemplateSaving] = React.useState(false)
|
|
||||||
const [templateError, setTemplateError] = React.useState('')
|
|
||||||
const [selectedRoomId, setSelectedRoomId] = React.useState('all')
|
const [selectedRoomId, setSelectedRoomId] = React.useState('all')
|
||||||
const [view, setView] = React.useState<'events' | 'manage'>('events')
|
const [view, setView] = React.useState<'events' | 'manage'>('events')
|
||||||
const [resendingEventId, setResendingEventId] = React.useState<string | null>(null)
|
|
||||||
const [copiedEventId, setCopiedEventId] = React.useState('')
|
const [copiedEventId, setCopiedEventId] = React.useState('')
|
||||||
|
/**
|
||||||
|
* 「复制退群信息」用的模板。
|
||||||
|
*
|
||||||
|
* 已迁到自动化规则;这里只是把它读回来给剪贴板用(见下面的 effect)。
|
||||||
|
*/
|
||||||
|
const [copyNoticeTemplate, setCopyNoticeTemplate] = React.useState(
|
||||||
|
GROUP_EXIT_NOTIFICATION_TEMPLATE
|
||||||
|
)
|
||||||
const copyResetTimer = React.useRef<number | null>(null)
|
const copyResetTimer = React.useRef<number | null>(null)
|
||||||
const [manageKeyword, setManageKeyword] = React.useState('')
|
const [manageKeyword, setManageKeyword] = React.useState('')
|
||||||
const [manageFilter, setManageFilter] = React.useState<GroupSelectionFilter>('all')
|
const [manageFilter, setManageFilter] = React.useState<GroupSelectionFilter>('all')
|
||||||
const [selectedManageRoomIds, setSelectedManageRoomIds] = React.useState<Set<string>>(
|
const [selectedManageRoomIds, setSelectedManageRoomIds] = React.useState<Set<string>>(
|
||||||
() => new Set()
|
() => new Set()
|
||||||
)
|
)
|
||||||
const [selectedManageNotificationRoomIds, setSelectedManageNotificationRoomIds] = React.useState<
|
|
||||||
Set<string>
|
|
||||||
>(() => new Set())
|
|
||||||
|
|
||||||
const groups = React.useMemo(() => {
|
const groups = React.useMemo(() => {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
@@ -526,6 +391,44 @@ export function GroupExitMonitorWorkspace({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「自动化 → 退群通知 → 管理监控群聊」深链过来时,直接落到管理群聊页。
|
||||||
|
*
|
||||||
|
* 请求是**一次性的**:落位之后立刻回报父层把它清掉。以前只 `setView` 不回报,
|
||||||
|
* 请求就一直留在父层;而 `renderCurrentWorkspace()` 是 switch,切一级菜单会卸载
|
||||||
|
* 本组件 —— 于是下次正常点「退群监控」时,这条陈旧的 `{view:'manage'}` 被重放,
|
||||||
|
* 用户看到的就是"点退群监控却进了管理群聊"。
|
||||||
|
*/
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!openViewRequest) return
|
||||||
|
setView(openViewRequest.view)
|
||||||
|
onOpenViewRequestHandled?.()
|
||||||
|
}, [openViewRequest, onOpenViewRequestHandled])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「复制退群信息」用的模板。
|
||||||
|
*
|
||||||
|
* 模板**唯一来源**是「自动化 → 退群通知」规则 —— 退群监控自己不再持有模板。
|
||||||
|
* 读不到就回落默认模板(复制功能本身不依赖自动化是否启用)。
|
||||||
|
*/
|
||||||
|
React.useEffect(() => {
|
||||||
|
const api = typeof window !== 'undefined' ? window.api : undefined
|
||||||
|
if (!api || typeof api.listAutomationRules !== 'function') return
|
||||||
|
let disposed = false
|
||||||
|
void api
|
||||||
|
.listAutomationRules()
|
||||||
|
.then((rules) => {
|
||||||
|
if (disposed) return
|
||||||
|
const rule = rules.find((item) => item.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
|
||||||
|
const template = rule?.leaveNotification?.template?.trim()
|
||||||
|
setCopyNoticeTemplate(template || GROUP_EXIT_NOTIFICATION_TEMPLATE)
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
return () => {
|
||||||
|
disposed = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
React.useEffect(
|
React.useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
if (copyResetTimer.current !== null) window.clearTimeout(copyResetTimer.current)
|
if (copyResetTimer.current !== null) window.clearTimeout(copyResetTimer.current)
|
||||||
@@ -572,21 +475,15 @@ export function GroupExitMonitorWorkspace({
|
|||||||
}, [state.events])
|
}, [state.events])
|
||||||
const setupEmpty =
|
const setupEmpty =
|
||||||
state.monitorSelectionConfigured === true && !(state.monitoredRoomIds || []).length
|
state.monitorSelectionConfigured === true && !(state.monitoredRoomIds || []).length
|
||||||
const notificationTemplate = state.notificationTemplate || GROUP_EXIT_NOTIFICATION_TEMPLATE
|
|
||||||
|
|
||||||
const openManage = (): void => {
|
const openManage = (): void => {
|
||||||
const availableRoomIds = new Set(groups.map(contactRoomId))
|
const availableRoomIds = new Set(groups.map(contactRoomId))
|
||||||
const defaultIds = state.monitorSelectionConfigured ? state.monitoredRoomIds || [] : []
|
const defaultIds = state.monitorSelectionConfigured ? state.monitoredRoomIds || [] : []
|
||||||
const selectedIds = new Set(defaultIds.filter((roomId) => availableRoomIds.has(roomId)))
|
const selectedIds = new Set(defaultIds.filter((roomId) => availableRoomIds.has(roomId)))
|
||||||
const notificationIds = new Set(
|
|
||||||
(state.notificationRoomIds || []).filter((roomId) => selectedIds.has(roomId))
|
|
||||||
)
|
|
||||||
setSelectedManageRoomIds(selectedIds)
|
setSelectedManageRoomIds(selectedIds)
|
||||||
setSelectedManageNotificationRoomIds(notificationIds)
|
|
||||||
setManageKeyword('')
|
setManageKeyword('')
|
||||||
setManageFilter('all')
|
setManageFilter('all')
|
||||||
setError('')
|
setError('')
|
||||||
setTemplateError('')
|
|
||||||
setView('manage')
|
setView('manage')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -615,26 +512,15 @@ export function GroupExitMonitorWorkspace({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resendEvent = async (eventId: string): Promise<void> => {
|
|
||||||
if (resendingEventId || typeof window.api.resendGroupExitMonitorEvent !== 'function') return
|
|
||||||
setResendingEventId(eventId)
|
|
||||||
setError('')
|
|
||||||
try {
|
|
||||||
setState(await window.api.resendGroupExitMonitorEvent(eventId))
|
|
||||||
} catch (reason) {
|
|
||||||
setError(reason instanceof Error ? reason.message : String(reason))
|
|
||||||
} finally {
|
|
||||||
setResendingEventId(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 手动复制退群信息:按“管理群聊”里保存的模板拼接当前事件,只写系统剪贴板,
|
* 手动复制退群信息:按「自动化 → 退群通知」里的模板拼接当前事件,只写系统剪贴板,
|
||||||
* 不触发任何微信发送,用户自行决定粘贴到哪里。
|
* 不触发任何微信发送,用户自行决定粘贴到哪里。
|
||||||
|
*
|
||||||
|
* 发送与重发**都不在这里** —— 通知由自动化执行,重发走执行日志。
|
||||||
*/
|
*/
|
||||||
const copyEventNotification = async (event: GroupExitMonitorEvent): Promise<void> => {
|
const copyEventNotification = async (event: GroupExitMonitorEvent): Promise<void> => {
|
||||||
const api = typeof window !== 'undefined' ? window.api : undefined
|
const api = typeof window !== 'undefined' ? window.api : undefined
|
||||||
const text = renderGroupExitMonitorNotification(event, notificationTemplate)
|
const text = renderGroupExitMonitorNotification(event, copyNoticeTemplate)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
const result = api && typeof api.copyText === 'function' ? await api.copyText(text) : null
|
const result = api && typeof api.copyText === 'function' ? await api.copyText(text) : null
|
||||||
@@ -656,13 +542,11 @@ export function GroupExitMonitorWorkspace({
|
|||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
|
// 只保存**监控范围**:通知配置属于自动化,不从这一页写。
|
||||||
const selectedIds = groups
|
const selectedIds = groups
|
||||||
.map(contactRoomId)
|
.map(contactRoomId)
|
||||||
.filter((roomId) => selectedManageRoomIds.has(roomId))
|
.filter((roomId) => selectedManageRoomIds.has(roomId))
|
||||||
const notificationIds = selectedIds.filter((roomId) =>
|
const nextState = await api.setGroupExitMonitorGroups(selectedIds)
|
||||||
selectedManageNotificationRoomIds.has(roomId)
|
|
||||||
)
|
|
||||||
const nextState = await api.setGroupExitMonitorGroups(selectedIds, notificationIds)
|
|
||||||
setState(nextState)
|
setState(nextState)
|
||||||
setView('events')
|
setView('events')
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
@@ -672,26 +556,6 @@ export function GroupExitMonitorWorkspace({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveNotificationTemplate = async (template: string): Promise<boolean> => {
|
|
||||||
const api = window.api
|
|
||||||
if (templateSaving || typeof api.setGroupExitMonitorNotificationTemplate !== 'function') {
|
|
||||||
setTemplateError('当前版本不支持保存退群监测模板')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
setTemplateSaving(true)
|
|
||||||
setTemplateError('')
|
|
||||||
try {
|
|
||||||
const nextState = await api.setGroupExitMonitorNotificationTemplate(template)
|
|
||||||
setState(nextState)
|
|
||||||
return true
|
|
||||||
} catch (reason) {
|
|
||||||
setTemplateError(reason instanceof Error ? reason.message : String(reason))
|
|
||||||
return false
|
|
||||||
} finally {
|
|
||||||
setTemplateSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const setMonitorEnabled = async (enabled: boolean): Promise<void> => {
|
const setMonitorEnabled = async (enabled: boolean): Promise<void> => {
|
||||||
const api = window.api
|
const api = window.api
|
||||||
if (togglingEnabled || typeof api.setGroupExitMonitorEnabled !== 'function') return
|
if (togglingEnabled || typeof api.setGroupExitMonitorEnabled !== 'function') return
|
||||||
@@ -707,20 +571,12 @@ export function GroupExitMonitorWorkspace({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toggleManageGroup = (roomId: string): void => {
|
const toggleManageGroup = (roomId: string): void => {
|
||||||
const wasSelected = selectedManageRoomIds.has(roomId)
|
|
||||||
setSelectedManageRoomIds((current) => {
|
setSelectedManageRoomIds((current) => {
|
||||||
const next = new Set(current)
|
const next = new Set(current)
|
||||||
if (next.has(roomId)) next.delete(roomId)
|
if (next.has(roomId)) next.delete(roomId)
|
||||||
else next.add(roomId)
|
else next.add(roomId)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
if (wasSelected) {
|
|
||||||
setSelectedManageNotificationRoomIds((notifications) => {
|
|
||||||
const nextNotifications = new Set(notifications)
|
|
||||||
nextNotifications.delete(roomId)
|
|
||||||
return nextNotifications
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleAllManageGroups = (): void => {
|
const toggleAllManageGroups = (): void => {
|
||||||
@@ -734,17 +590,6 @@ export function GroupExitMonitorWorkspace({
|
|||||||
})
|
})
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
if (!shouldSelect) setSelectedManageNotificationRoomIds(new Set())
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleManageNotification = (roomId: string): void => {
|
|
||||||
if (!selectedManageRoomIds.has(roomId)) return
|
|
||||||
setSelectedManageNotificationRoomIds((current) => {
|
|
||||||
const next = new Set(current)
|
|
||||||
if (next.has(roomId)) next.delete(roomId)
|
|
||||||
else next.add(roomId)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (view === 'manage') {
|
if (view === 'manage') {
|
||||||
@@ -752,7 +597,6 @@ export function GroupExitMonitorWorkspace({
|
|||||||
<ManageGroups
|
<ManageGroups
|
||||||
groups={groups}
|
groups={groups}
|
||||||
selectedRoomIds={selectedManageRoomIds}
|
selectedRoomIds={selectedManageRoomIds}
|
||||||
notificationRoomIds={selectedManageNotificationRoomIds}
|
|
||||||
sendCapability={sendCapability}
|
sendCapability={sendCapability}
|
||||||
keyword={manageKeyword}
|
keyword={manageKeyword}
|
||||||
listFilter={manageFilter}
|
listFilter={manageFilter}
|
||||||
@@ -761,14 +605,9 @@ export function GroupExitMonitorWorkspace({
|
|||||||
onKeywordChange={setManageKeyword}
|
onKeywordChange={setManageKeyword}
|
||||||
onListFilterChange={setManageFilter}
|
onListFilterChange={setManageFilter}
|
||||||
onToggle={toggleManageGroup}
|
onToggle={toggleManageGroup}
|
||||||
onToggleNotification={toggleManageNotification}
|
|
||||||
onToggleAll={toggleAllManageGroups}
|
onToggleAll={toggleAllManageGroups}
|
||||||
onSave={() => void saveGroups()}
|
onSave={() => void saveGroups()}
|
||||||
onCancel={() => setView('events')}
|
onCancel={() => setView('events')}
|
||||||
notificationTemplate={notificationTemplate}
|
|
||||||
templateSaving={templateSaving}
|
|
||||||
templateError={templateError}
|
|
||||||
onSaveTemplate={saveNotificationTemplate}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -806,6 +645,19 @@ export function GroupExitMonitorWorkspace({
|
|||||||
{statusLabel}
|
{statusLabel}
|
||||||
</span>
|
</span>
|
||||||
<SendCapabilityStatus capability={sendCapability} />
|
<SendCapabilityStatus capability={sendCapability} />
|
||||||
|
{/*
|
||||||
|
发送能力不完整时给一个可操作的去处。
|
||||||
|
这里只提示,不阻断任何编辑。
|
||||||
|
*/}
|
||||||
|
{sendCapability &&
|
||||||
|
!(sendCapability.ready && sendCapability.capabilities?.text) &&
|
||||||
|
onOpenSendSettings ? (
|
||||||
|
<Button variant="link" size="sm" onClick={onOpenSendSettings}>
|
||||||
|
去设置发送能力
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{/* 检测到退群之后做什么,归自动化管;这里只是个入口。 */}
|
||||||
|
<LeaveMonitorAutomationEntry onOpen={onOpenLeaveNotificationAutomation} />
|
||||||
<Button variant="outline" size="sm" onClick={openManage}>
|
<Button variant="outline" size="sm" onClick={openManage}>
|
||||||
管理群聊
|
管理群聊
|
||||||
</Button>
|
</Button>
|
||||||
@@ -921,12 +773,10 @@ export function GroupExitMonitorWorkspace({
|
|||||||
群人数 {event.previousCount} 人 → {event.currentCount} 人
|
群人数 {event.previousCount} 人 → {event.currentCount} 人
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<EventNotificationStatus
|
{/*
|
||||||
event={event}
|
这里**不再显示通知状态**:发送结果归「自动化 → 执行日志」。
|
||||||
onOpenSendSettings={onOpenSendSettings}
|
退群监控只对"谁、哪个群、什么时候、人数怎么变"负责。
|
||||||
onResend={() => void resendEvent(event.id)}
|
*/}
|
||||||
resending={resendingEventId === event.id}
|
|
||||||
/>
|
|
||||||
<dl className="exit-monitor-event-details">
|
<dl className="exit-monitor-event-details">
|
||||||
<div>
|
<div>
|
||||||
<dt>微信名</dt>
|
<dt>微信名</dt>
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Button } from '../../components/ui'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LeaveMonitorAutomationEntry —— 退群监控页上的「退群通知自动化」入口。
|
||||||
|
*
|
||||||
|
* 只做导航:跳到「自动化 → 规则 → 退群通知」。
|
||||||
|
* 它不变更退群监控的任何配置,也不触发发送。
|
||||||
|
*
|
||||||
|
* 之所以把职责拆开说清楚:退群监控负责「谁退出了」,
|
||||||
|
* 自动化负责「检测到之后做什么」—— 入口名字必须体现这个分界。
|
||||||
|
*/
|
||||||
|
export function LeaveMonitorAutomationEntry({
|
||||||
|
onOpen
|
||||||
|
}: {
|
||||||
|
onOpen?: () => void
|
||||||
|
}): React.ReactElement | null {
|
||||||
|
if (!onOpen) return null
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onOpen}
|
||||||
|
aria-label="配置退群通知自动化"
|
||||||
|
className="exit-monitor-automation-entry"
|
||||||
|
>
|
||||||
|
退群通知自动化 →
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,7 +13,21 @@ import {
|
|||||||
} from '../../components/ui'
|
} from '../../components/ui'
|
||||||
|
|
||||||
type StatusFilter = 'all' | WechatActionStatus
|
type StatusFilter = 'all' | WechatActionStatus
|
||||||
type PurposeFilter = 'all' | 'member_left_notification' | 'scheduled_report' | 'other'
|
type PurposeFilter = 'all' | 'leave_notification' | 'scheduled_report' | 'other'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「退群通知」的发送用途。
|
||||||
|
*
|
||||||
|
* - `automation_leave_notification`:迁移后由自动化发出的退群通知(**当前唯一生产者**);
|
||||||
|
* - `member_left_notification`:迁移前退群监控自己发的,只在**历史审计记录**里存在。
|
||||||
|
*
|
||||||
|
* 两个都归到「退群通知」这一个筛选项下 —— 否则用户翻旧日志时会发现
|
||||||
|
* 明明写着「退群通知」却筛不出来。
|
||||||
|
*/
|
||||||
|
const LEAVE_NOTIFICATION_PURPOSES: readonly string[] = [
|
||||||
|
'automation_leave_notification',
|
||||||
|
'member_left_notification'
|
||||||
|
]
|
||||||
|
|
||||||
const sourceLabel = (source: ActionLogEntry['source']): string => {
|
const sourceLabel = (source: ActionLogEntry['source']): string => {
|
||||||
if (source === 'member_monitor') return '退群监控'
|
if (source === 'member_monitor') return '退群监控'
|
||||||
@@ -27,7 +41,7 @@ const triggerLabel = (triggerType: ActionLogEntry['triggerType']): string =>
|
|||||||
triggerType === 'user' ? '用户触发' : '自动化'
|
triggerType === 'user' ? '用户触发' : '自动化'
|
||||||
|
|
||||||
const purposeLabel = (purpose: WechatActionPurpose): string => {
|
const purposeLabel = (purpose: WechatActionPurpose): string => {
|
||||||
if (purpose === 'member_left_notification') return '退群通知'
|
if (LEAVE_NOTIFICATION_PURPOSES.includes(purpose)) return '退群通知'
|
||||||
if (purpose === 'scheduled_report') return '定时日报'
|
if (purpose === 'scheduled_report') return '定时日报'
|
||||||
if (purpose === 'tts_voice') return '文字转语音'
|
if (purpose === 'tts_voice') return '文字转语音'
|
||||||
return purpose || '其它动作'
|
return purpose || '其它动作'
|
||||||
@@ -51,7 +65,12 @@ const statusDetails = (entry: ActionLogEntry): { label: string; className: strin
|
|||||||
const matchesPurpose = (entry: ActionLogEntry, filter: PurposeFilter): boolean => {
|
const matchesPurpose = (entry: ActionLogEntry, filter: PurposeFilter): boolean => {
|
||||||
if (filter === 'all') return true
|
if (filter === 'all') return true
|
||||||
if (filter === 'other') {
|
if (filter === 'other') {
|
||||||
return entry.purpose !== 'member_left_notification' && entry.purpose !== 'scheduled_report'
|
return (
|
||||||
|
!LEAVE_NOTIFICATION_PURPOSES.includes(entry.purpose) && entry.purpose !== 'scheduled_report'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (filter === 'leave_notification') {
|
||||||
|
return LEAVE_NOTIFICATION_PURPOSES.includes(entry.purpose)
|
||||||
}
|
}
|
||||||
return entry.purpose === filter
|
return entry.purpose === filter
|
||||||
}
|
}
|
||||||
@@ -133,7 +152,7 @@ export function LogsWorkspace(): React.ReactElement {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">全部类型</SelectItem>
|
<SelectItem value="all">全部类型</SelectItem>
|
||||||
<SelectItem value="member_left_notification">退群通知</SelectItem>
|
<SelectItem value="leave_notification">退群通知</SelectItem>
|
||||||
<SelectItem value="scheduled_report">定时日报</SelectItem>
|
<SelectItem value="scheduled_report">定时日报</SelectItem>
|
||||||
<SelectItem value="other">其它动作</SelectItem>
|
<SelectItem value="other">其它动作</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { AIModelPage } from './pages/AIModelPage'
|
|||||||
import { RecallProtectionPage } from './pages/RecallProtectionPage'
|
import { RecallProtectionPage } from './pages/RecallProtectionPage'
|
||||||
import { AdvancedPage } from './pages/AdvancedPage'
|
import { AdvancedPage } from './pages/AdvancedPage'
|
||||||
import { CacheCleanupPage } from './pages/CacheCleanupPage'
|
import { CacheCleanupPage } from './pages/CacheCleanupPage'
|
||||||
|
import { LocalIndexPage } from './pages/LocalIndexPage'
|
||||||
import { AppearancePage } from './pages/AppearancePage'
|
import { AppearancePage } from './pages/AppearancePage'
|
||||||
import { AboutPage } from './pages/AboutPage'
|
import { AboutPage } from './pages/AboutPage'
|
||||||
import { VoiceRecognitionPage } from './pages/VoiceRecognitionPage'
|
import { VoiceRecognitionPage } from './pages/VoiceRecognitionPage'
|
||||||
@@ -120,6 +121,8 @@ export function SettingsWorkspace({
|
|||||||
return <AdvancedPage onNotice={onNotice} />
|
return <AdvancedPage onNotice={onNotice} />
|
||||||
case 'cache-cleanup':
|
case 'cache-cleanup':
|
||||||
return <CacheCleanupPage onNotice={onNotice} />
|
return <CacheCleanupPage onNotice={onNotice} />
|
||||||
|
case 'local-index':
|
||||||
|
return <LocalIndexPage onNotice={onNotice} dbReady={dbReady} />
|
||||||
case 'storage-export':
|
case 'storage-export':
|
||||||
return (
|
return (
|
||||||
<SettingsEmptyState
|
<SettingsEmptyState
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export type IndexStatusTone = 'ok' | 'warn' | 'error' | 'idle'
|
||||||
|
|
||||||
|
interface IndexStatusBadgeProps {
|
||||||
|
label: string
|
||||||
|
tone: IndexStatusTone
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 索引状态徽章(`● 已完成` 这种)。
|
||||||
|
*
|
||||||
|
* 存在的意义是把**状态**从标题里拆出来:之前状态是和卡片标题混在同一行的文字,
|
||||||
|
* 既没有视觉权重差、也无法一眼扫到。
|
||||||
|
*
|
||||||
|
* 颜色只用项目既有的 theme token(`--wxex-success` / `--wxex-warning` / `--wxex-danger`),
|
||||||
|
* 不另起一套彩虹色 —— 否则主题切换时会和整个应用脱节。
|
||||||
|
*/
|
||||||
|
export function IndexStatusBadge({ label, tone }: IndexStatusBadgeProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<span className={`local-index-badge is-${tone}`}>
|
||||||
|
<span className="local-index-badge-dot" aria-hidden />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -77,12 +77,8 @@ export function VoiceEncodingEnvironmentSection({
|
|||||||
onNotice(
|
onNotice(
|
||||||
result.environment.message || 'pilk 已安装,但仍有语音编码环境未就绪。请重新检查。'
|
result.environment.message || 'pilk 已安装,但仍有语音编码环境未就绪。请重新检查。'
|
||||||
)
|
)
|
||||||
} else if (result.restarted) {
|
|
||||||
onNotice('语音编码环境已修复,语音发送服务已重启')
|
|
||||||
} else if (result.restartError) {
|
|
||||||
onNotice(`语音编码环境已修复,但语音发送服务重启失败:${result.restartError}`)
|
|
||||||
} else {
|
} else {
|
||||||
onNotice('语音编码环境已修复。重新启动语音发送服务后生效。')
|
onNotice('语音编码环境已修复。')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotice(error instanceof Error ? error.message : 'pilk 安装失败')
|
onNotice(error instanceof Error ? error.message : 'pilk 安装失败')
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ export const SETTINGS_NAVIGATION: SettingsNavigationGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
// “存储与导出”暂不开放;保留 category/render case 以兼容已有页面状态。
|
// “存储与导出”暂不开放;保留 category/render case 以兼容已有页面状态。
|
||||||
// { id: 'storage-export', label: '存储与导出' },
|
// { id: 'storage-export', label: '存储与导出' },
|
||||||
|
// 「本地索引」= 问问微信的聊天记录索引 + 图片文字索引。
|
||||||
|
// 放在设置里是因为它属于「本地数据管理」,而原来只藏在问问微信页面里,
|
||||||
|
// 用户根本不知道它是什么、更不知道还要手动同步。
|
||||||
|
{ id: 'local-index', label: '本地索引' },
|
||||||
{ id: 'cache-cleanup', label: '缓存与清理' }
|
{ id: 'cache-cleanup', label: '缓存与清理' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type SettingsCategoryId =
|
|||||||
| 'local-api'
|
| 'local-api'
|
||||||
| 'storage-export'
|
| 'storage-export'
|
||||||
| 'cache-cleanup'
|
| 'cache-cleanup'
|
||||||
|
| 'local-index'
|
||||||
| 'appearance'
|
| 'appearance'
|
||||||
| 'advanced'
|
| 'advanced'
|
||||||
| 'about'
|
| 'about'
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { ImageTextIndexSummary } from '../../../components/search/ImageTextIndexSummary'
|
||||||
|
import { KnowledgeIndexCard } from '../../../components/search/KnowledgeIndexCard'
|
||||||
|
|
||||||
|
interface LocalIndexPageProps {
|
||||||
|
onNotice: (message: string) => void
|
||||||
|
dbReady: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 · 本地索引。
|
||||||
|
*
|
||||||
|
* 把「问问微信」背后的两份索引放到设置里集中管理 —— 之前它们只藏在搜索页,
|
||||||
|
* 用户既不知道有这么个东西,也不知道它需要手动同步。
|
||||||
|
*
|
||||||
|
* 信息层级(从上到下):页面标题 → 一句话说明 → 两张索引卡片。
|
||||||
|
* 卡片本身由 `KnowledgeIndexCard` / `ImageTextIndexSummary` 各自负责,
|
||||||
|
* 本页只管**容器宽度与卡片间距**,不碰卡片内部布局。
|
||||||
|
*/
|
||||||
|
export function LocalIndexPage({ onNotice, dbReady }: LocalIndexPageProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="local-index-page">
|
||||||
|
<header className="local-index-head">
|
||||||
|
<h2>本地索引</h2>
|
||||||
|
<p>
|
||||||
|
TraceMemo 的搜索、问问微信和图片文字识别依赖本地索引。
|
||||||
|
所有索引数据仅保存在本机,可随时从微信原始数据重新建立。
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<div className="local-index-cards">
|
||||||
|
<KnowledgeIndexCard dbReady={dbReady} onNotice={onNotice} />
|
||||||
|
<ImageTextIndexSummary dbReady={dbReady} onNotice={onNotice} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,41 +1,12 @@
|
|||||||
import { useCallback, useEffect, useRef, useState, type ReactElement } from 'react'
|
import { useCallback, useEffect, useState, type ReactElement } from 'react'
|
||||||
import type {
|
import type {
|
||||||
PersonalWechatSendCapability,
|
PersonalWechatSendCapability,
|
||||||
PersonalWechatSenderStatus
|
PersonalWechatSenderStatus
|
||||||
} from '../../../../../shared/personal-wechat'
|
} from '../../../../../shared/personal-wechat'
|
||||||
import type {
|
import { Button, Input, Skeleton } from '../../../components/ui'
|
||||||
PersonalWechatRuntimeProgressEvent,
|
|
||||||
PersonalWechatRuntimeStatus
|
|
||||||
} from '../../../../../shared/personal-wechat-runtime'
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
Input,
|
|
||||||
Skeleton,
|
|
||||||
Switch
|
|
||||||
} from '../../../components/ui'
|
|
||||||
import { PersonalWechatSetupGuide } from '../../../components/chat/PersonalWechatSetupGuide'
|
import { PersonalWechatSetupGuide } from '../../../components/chat/PersonalWechatSetupGuide'
|
||||||
import { PersonalWechatSupportedVersionsContent } from '../../../components/chat/PersonalWechatSupportedVersionsContent'
|
|
||||||
import { isMac, isWindows } from '../../../utils/runtime-environment'
|
import { isMac, isWindows } from '../../../utils/runtime-environment'
|
||||||
|
|
||||||
const RUNTIME_STATUS_LABELS: Record<PersonalWechatRuntimeStatus['state'], string> = {
|
|
||||||
missing: '未下载',
|
|
||||||
downloading: '下载中',
|
|
||||||
ready: '已就绪',
|
|
||||||
invalid: '需要修复',
|
|
||||||
error: '下载失败',
|
|
||||||
unsupported: '暂不支持'
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBytes(value: number): string {
|
|
||||||
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`
|
|
||||||
return `${(value / 1024 / 1024).toFixed(1)} MB`
|
|
||||||
}
|
|
||||||
|
|
||||||
const REPOSITORY_URL = 'https://github.com/Wxw-Gu/TraceMemo'
|
const REPOSITORY_URL = 'https://github.com/Wxw-Gu/TraceMemo'
|
||||||
// README 就是仓库首页,跳转后需要的正是「交流与反馈」这一节(含群二维码)。
|
// README 就是仓库首页,跳转后需要的正是「交流与反馈」这一节(含群二维码)。
|
||||||
const GROUP_README_URL = `${REPOSITORY_URL}#-交流与反馈`
|
const GROUP_README_URL = `${REPOSITORY_URL}#-交流与反馈`
|
||||||
@@ -69,6 +40,26 @@ function boundToCurrentWechat(status: PersonalWechatSenderStatus | null): boolea
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SendCapabilityAuthorizationCard(): ReactElement {
|
||||||
|
return (
|
||||||
|
<section className="settings-card grid gap-2">
|
||||||
|
<p>发送能力属授权制,需要联系群主。请先加入交流群,然后在群内添加群主申请授权。</p>
|
||||||
|
<p className="settings-footnote">
|
||||||
|
进群请点击{' '}
|
||||||
|
<a
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
href={GROUP_README_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
这里
|
||||||
|
</a>{' '}
|
||||||
|
跳转。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function PersonalWechatSendPage({
|
export function PersonalWechatSendPage({
|
||||||
onNotice,
|
onNotice,
|
||||||
onOpenTextToSpeechSettings
|
onOpenTextToSpeechSettings
|
||||||
@@ -78,12 +69,7 @@ export function PersonalWechatSendPage({
|
|||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const [capability, setCapability] = useState<PersonalWechatSendCapability | null>(null)
|
const [capability, setCapability] = useState<PersonalWechatSendCapability | null>(null)
|
||||||
const [senderStatus, setSenderStatus] = useState<PersonalWechatSenderStatus | null>(null)
|
const [senderStatus, setSenderStatus] = useState<PersonalWechatSenderStatus | null>(null)
|
||||||
const [runtimeStatus, setRuntimeStatus] = useState<PersonalWechatRuntimeStatus | null>(null)
|
|
||||||
const [runtimeProgress, setRuntimeProgress] = useState<PersonalWechatRuntimeProgressEvent | null>(
|
|
||||||
null
|
|
||||||
)
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [runtimeBusy, setRuntimeBusy] = useState(false)
|
|
||||||
const [binding, setBinding] = useState(false)
|
const [binding, setBinding] = useState(false)
|
||||||
const [detecting, setDetecting] = useState(false)
|
const [detecting, setDetecting] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -93,12 +79,6 @@ export function PersonalWechatSendPage({
|
|||||||
const [windowsPortInput, setWindowsPortInput] = useState('')
|
const [windowsPortInput, setWindowsPortInput] = useState('')
|
||||||
const [windowsDetectedPort, setWindowsDetectedPort] = useState('')
|
const [windowsDetectedPort, setWindowsDetectedPort] = useState('')
|
||||||
const [windowsEndpointBusy, setWindowsEndpointBusy] = useState(false)
|
const [windowsEndpointBusy, setWindowsEndpointBusy] = useState(false)
|
||||||
const [keepOneBotProcess, setKeepOneBotProcess] = useState(false)
|
|
||||||
const [keepProcessBusy, setKeepProcessBusy] = useState(false)
|
|
||||||
const [showWechatVersions, setShowWechatVersions] = useState(false)
|
|
||||||
const runtimeVersionsTriggerRef = useRef<HTMLButtonElement | null>(null)
|
|
||||||
const personalWechatRuntimeSupported = isMac && Boolean(runtimeStatus?.supported)
|
|
||||||
|
|
||||||
const refresh = useCallback(async (): Promise<void> => {
|
const refresh = useCallback(async (): Promise<void> => {
|
||||||
if (isWindows) {
|
if (isWindows) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -106,15 +86,12 @@ export function PersonalWechatSendPage({
|
|||||||
}
|
}
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
const [nextCapability, nextSender, nextRuntime] = await Promise.all([
|
const [nextCapability, nextSender] = await Promise.all([
|
||||||
window.api.getPersonalWechatSendCapability(),
|
window.api.getPersonalWechatSendCapability(),
|
||||||
window.api.getPersonalWechatSenderStatus(),
|
window.api.getPersonalWechatSenderStatus()
|
||||||
window.api.getPersonalWechatRuntimeStatus()
|
|
||||||
])
|
])
|
||||||
setCapability(nextCapability)
|
setCapability(nextCapability)
|
||||||
setSenderStatus(nextSender)
|
setSenderStatus(nextSender)
|
||||||
setRuntimeStatus(nextRuntime)
|
|
||||||
setRuntimeProgress(nextRuntime.state === 'downloading' ? nextRuntime : null)
|
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setError(reason instanceof Error ? reason.message : '微信发送能力读取失败')
|
setError(reason instanceof Error ? reason.message : '微信发送能力读取失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -125,31 +102,8 @@ export function PersonalWechatSendPage({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isWindows) return
|
if (isWindows) return
|
||||||
void refresh()
|
void refresh()
|
||||||
const subscribe = window.api.onPersonalWechatRuntimeProgress
|
|
||||||
if (!subscribe) return
|
|
||||||
const unsubscribe = subscribe((status) => {
|
|
||||||
setRuntimeProgress(status)
|
|
||||||
setRuntimeStatus(status)
|
|
||||||
if (status.state === 'ready') void refresh()
|
|
||||||
})
|
|
||||||
return () => unsubscribe?.()
|
|
||||||
}, [refresh])
|
}, [refresh])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isMac) return undefined
|
|
||||||
let active = true
|
|
||||||
const readKeepProcess = window.api.getPersonalWechatKeepOneBotProcess
|
|
||||||
if (typeof readKeepProcess !== 'function') return undefined
|
|
||||||
void readKeepProcess()
|
|
||||||
.then((keep) => {
|
|
||||||
if (active && typeof keep === 'boolean') setKeepOneBotProcess(keep)
|
|
||||||
})
|
|
||||||
.catch(() => undefined)
|
|
||||||
return () => {
|
|
||||||
active = false
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isWindows || !senderStatus || senderStatus.canSend || senderStatus.state === 'error') {
|
if (isWindows || !senderStatus || senderStatus.canSend || senderStatus.state === 'error') {
|
||||||
return undefined
|
return undefined
|
||||||
@@ -187,80 +141,6 @@ export function PersonalWechatSendPage({
|
|||||||
}
|
}
|
||||||
}, [refresh])
|
}, [refresh])
|
||||||
|
|
||||||
const downloadRuntime = async (): Promise<void> => {
|
|
||||||
if (runtimeBusy || !personalWechatRuntimeSupported) return
|
|
||||||
setRuntimeBusy(true)
|
|
||||||
setError('')
|
|
||||||
setRuntimeStatus((current) =>
|
|
||||||
current ? { ...current, state: 'downloading', downloadedBytes: 0, progress: 0 } : current
|
|
||||||
)
|
|
||||||
try {
|
|
||||||
const result = await window.api.downloadPersonalWechatRuntime()
|
|
||||||
setRuntimeStatus(result.status)
|
|
||||||
if (!result.success) setError(result.error || '微信发送组件准备失败')
|
|
||||||
else onNotice('微信发送组件已准备好')
|
|
||||||
} catch (reason) {
|
|
||||||
setError(reason instanceof Error ? reason.message : '微信发送组件准备失败')
|
|
||||||
} finally {
|
|
||||||
setRuntimeBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cancelRuntimeDownload = async (): Promise<void> => {
|
|
||||||
if (!personalWechatRuntimeSupported) return
|
|
||||||
await window.api.cancelPersonalWechatRuntimeDownload()
|
|
||||||
onNotice('正在取消发送组件下载')
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeRuntime = async (): Promise<void> => {
|
|
||||||
if (!personalWechatRuntimeSupported || !runtimeStatus?.removable || runtimeBusy) return
|
|
||||||
if (!window.confirm('卸载微信发送组件?以后需要发送个人微信消息时可以重新下载。')) return
|
|
||||||
setRuntimeBusy(true)
|
|
||||||
try {
|
|
||||||
setRuntimeStatus(await window.api.removePersonalWechatRuntime())
|
|
||||||
onNotice('微信发送组件已卸载')
|
|
||||||
} catch (reason) {
|
|
||||||
onNotice(reason instanceof Error ? `发送组件卸载失败:${reason.message}` : '发送组件卸载失败')
|
|
||||||
} finally {
|
|
||||||
setRuntimeBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const openRuntimeDirectory = async (): Promise<void> => {
|
|
||||||
if (!personalWechatRuntimeSupported) return
|
|
||||||
const result = await window.api.openPersonalWechatRuntimeDirectory()
|
|
||||||
if (!result.success) onNotice(result.error || '无法打开发送组件目录')
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshRuntime = async (): Promise<void> => {
|
|
||||||
if (!isMac || runtimeBusy) return
|
|
||||||
try {
|
|
||||||
setRuntimeStatus(await window.api.getPersonalWechatRuntimeStatus())
|
|
||||||
} catch (reason) {
|
|
||||||
setError(reason instanceof Error ? reason.message : '微信发送组件状态读取失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleKeepOneBotProcessChange = async (keep: boolean): Promise<void> => {
|
|
||||||
if (!isMac || keepProcessBusy) return
|
|
||||||
const saveKeepProcess = window.api.setPersonalWechatKeepOneBotProcess
|
|
||||||
if (typeof saveKeepProcess !== 'function') {
|
|
||||||
onNotice('请重启 TraceMemo 后再使用“保留 OneBot 进程”')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setKeepProcessBusy(true)
|
|
||||||
setKeepOneBotProcess(keep)
|
|
||||||
try {
|
|
||||||
const saved = await saveKeepProcess(keep)
|
|
||||||
if (typeof saved === 'boolean') setKeepOneBotProcess(saved)
|
|
||||||
} catch (reason) {
|
|
||||||
setKeepOneBotProcess(!keep)
|
|
||||||
onNotice(reason instanceof Error ? reason.message : String(reason))
|
|
||||||
} finally {
|
|
||||||
setKeepProcessBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const bindWechat = async (): Promise<void> => {
|
const bindWechat = async (): Promise<void> => {
|
||||||
if (binding) return
|
if (binding) return
|
||||||
setBinding(true)
|
setBinding(true)
|
||||||
@@ -386,6 +266,7 @@ export function PersonalWechatSendPage({
|
|||||||
const pageMessage = isWindows
|
const pageMessage = isWindows
|
||||||
? windowsSenderStatus?.message || '请输入端口并检测 Windows 微信发送能力'
|
? windowsSenderStatus?.message || '请输入端口并检测 Windows 微信发送能力'
|
||||||
: capability?.message || '微信发送能力暂不可用'
|
: capability?.message || '微信发送能力暂不可用'
|
||||||
|
const macUnavailable = isMac && senderStatus?.state === 'runtime_missing'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -398,7 +279,7 @@ export function PersonalWechatSendPage({
|
|||||||
<span
|
<span
|
||||||
className={`settings-status-badge ${loading ? 'checking' : pageReady ? '' : pageStatus === 'unsupported' ? 'unavailable' : 'warning'}`}
|
className={`settings-status-badge ${loading ? 'checking' : pageReady ? '' : pageStatus === 'unsupported' ? 'unavailable' : 'warning'}`}
|
||||||
>
|
>
|
||||||
{loading ? '检测中' : capabilityLabel[pageStatus]}
|
{loading ? '检测中' : macUnavailable ? '暂不可用' : capabilityLabel[pageStatus]}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<div className="settings-page-scroll">
|
<div className="settings-page-scroll">
|
||||||
@@ -407,48 +288,55 @@ export function PersonalWechatSendPage({
|
|||||||
<Skeleton className="h-28 w-full" />
|
<Skeleton className="h-28 w-full" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<h2 className="settings-section-heading">发送能力</h2>
|
{/* macOS 走下方「配置状态」SetupGuide 统一展示,这里只在 Windows/其它
|
||||||
<section className="settings-card">
|
* 平台显示旧「发送能力」卡(Mac 上两者重复,已按用户要求移除)。 */}
|
||||||
<div className="flex items-start justify-between gap-4">
|
{!isMac ? (
|
||||||
<div>
|
<>
|
||||||
<span className="settings-card-kicker">个人微信</span>
|
<h2 className="settings-section-heading">发送能力</h2>
|
||||||
<strong className="mt-1 block text-base">{pageMessage}</strong>
|
<section className="settings-card">
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<div className="flex items-start justify-between gap-4">
|
||||||
{pageStatus === 'unsupported' && isMac
|
<div>
|
||||||
? 'Intel Mac 不支持个人微信发送。'
|
<span className="settings-card-kicker">个人微信</span>
|
||||||
: pageStatus === 'unsupported' && !isWindows
|
<strong className="mt-1 block text-base">{pageMessage}</strong>
|
||||||
? '微信消息发送目前仅支持 macOS 和 Windows。'
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
: isWindows
|
{pageStatus === 'unsupported'
|
||||||
? 'Windows 通过本机微信发送接口工作,请先配置并检测接口端口。'
|
? '微信消息发送目前仅支持 macOS 和 Windows。'
|
||||||
: '档案中的文字、图片和语音发送,以及定时日报发送,都会使用这项能力。'}
|
: isWindows
|
||||||
</p>
|
? 'Windows 通过本机微信发送接口工作,请先配置并检测接口端口。'
|
||||||
</div>
|
: '档案中的文字、图片和语音发送,以及定时日报发送,都会使用这项能力。'}
|
||||||
<Button variant="outline" size="sm" onClick={() => void detectCapability()}>
|
</p>
|
||||||
{detecting ? '刷新中…' : isWindows ? '重新检测' : '刷新状态'}
|
</div>
|
||||||
</Button>
|
<Button variant="outline" size="sm" onClick={() => void detectCapability()}>
|
||||||
</div>
|
{detecting ? '刷新中…' : isWindows ? '重新检测' : '刷新状态'}
|
||||||
<div className="mt-5 grid grid-cols-3 gap-2" aria-label="微信发送能力明细">
|
</Button>
|
||||||
{(
|
|
||||||
[
|
|
||||||
['文字', pageCapabilities.text],
|
|
||||||
['图片', pageCapabilities.image],
|
|
||||||
['语音', pageCapabilities.voice]
|
|
||||||
] as const
|
|
||||||
).map(([label, available]) => (
|
|
||||||
<div key={label} className="rounded-lg border border-border-subtle px-3 py-2">
|
|
||||||
<span className="block text-xs text-muted-foreground">{label}</span>
|
|
||||||
<strong className="mt-1 block text-sm">
|
|
||||||
{available ? '可发送' : '未就绪'}
|
|
||||||
</strong>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="mt-5 grid grid-cols-3 gap-2" aria-label="微信发送能力明细">
|
||||||
</div>
|
{(
|
||||||
{error ? (
|
[
|
||||||
<p className="mt-3 text-sm text-destructive" role="alert">
|
['文字', pageCapabilities.text],
|
||||||
{error}
|
['图片', pageCapabilities.image],
|
||||||
</p>
|
['语音', pageCapabilities.voice]
|
||||||
) : null}
|
] as const
|
||||||
</section>
|
).map(([label, available]) => (
|
||||||
|
<div
|
||||||
|
key={label}
|
||||||
|
className="rounded-lg border border-border-subtle px-3 py-2"
|
||||||
|
>
|
||||||
|
<span className="block text-xs text-muted-foreground">{label}</span>
|
||||||
|
<strong className="mt-1 block text-sm">
|
||||||
|
{available ? '可发送' : '未就绪'}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<p className="mt-3 text-sm text-destructive" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{isWindows ? (
|
{isWindows ? (
|
||||||
<>
|
<>
|
||||||
@@ -515,156 +403,16 @@ export function PersonalWechatSendPage({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<h2 className="settings-section-heading">发送能力授权</h2>
|
<h2 className="settings-section-heading">发送能力授权</h2>
|
||||||
<section className="settings-card grid gap-2">
|
<SendCapabilityAuthorizationCard />
|
||||||
<p>发送能力属授权制,需要联系群主。请先加入交流群,然后在群内添加群主申请授权。</p>
|
|
||||||
<p className="settings-footnote">
|
|
||||||
进群请点击{' '}
|
|
||||||
<a
|
|
||||||
className="text-primary hover:underline"
|
|
||||||
href={GROUP_README_URL}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
这里
|
|
||||||
</a>{' '}
|
|
||||||
跳转。
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</>
|
</>
|
||||||
) : isMac ? (
|
) : isMac ? (
|
||||||
<>
|
<>
|
||||||
<h2 className="settings-section-heading">OneBot 运行时</h2>
|
|
||||||
<section className="settings-card tts-runtime-card">
|
|
||||||
<div className="tts-runtime-summary">
|
|
||||||
<span className="settings-card-kicker">
|
|
||||||
OneBot {runtimeStatus?.version || 'v0.0.18'}
|
|
||||||
</span>
|
|
||||||
<strong>
|
|
||||||
{runtimeStatus?.state === 'downloading'
|
|
||||||
? `正在下载 ${Math.round(runtimeStatus.progress * 100)}%`
|
|
||||||
: runtimeStatus
|
|
||||||
? RUNTIME_STATUS_LABELS[runtimeStatus.state]
|
|
||||||
: '正在检测'}
|
|
||||||
</strong>
|
|
||||||
<small>
|
|
||||||
{!runtimeStatus
|
|
||||||
? '正在检测当前平台与组件状态'
|
|
||||||
: personalWechatRuntimeSupported
|
|
||||||
? `仅用于连接 macOS 微信 · ${formatBytes(runtimeStatus.totalBytes)}`
|
|
||||||
: '当前 Mac 环境不满足个人微信发送组件要求'}
|
|
||||||
</small>
|
|
||||||
{runtimeStatus?.error ? (
|
|
||||||
<p className="tts-runtime-error">{runtimeStatus.error}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="tts-runtime-actions">
|
|
||||||
{personalWechatRuntimeSupported &&
|
|
||||||
runtimeStatus?.state === 'downloading' ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void cancelRuntimeDownload()}
|
|
||||||
>
|
|
||||||
取消下载
|
|
||||||
</Button>
|
|
||||||
) : personalWechatRuntimeSupported && runtimeStatus?.state === 'ready' ? (
|
|
||||||
<>
|
|
||||||
{runtimeStatus.directory ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void openRuntimeDirectory()}
|
|
||||||
>
|
|
||||||
打开目录
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{runtimeStatus.removable ? (
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
disabled={runtimeBusy}
|
|
||||||
onClick={() => void removeRuntime()}
|
|
||||||
>
|
|
||||||
卸载组件
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : personalWechatRuntimeSupported ? (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
disabled={runtimeBusy}
|
|
||||||
onClick={() => void downloadRuntime()}
|
|
||||||
>
|
|
||||||
{runtimeStatus?.state === 'invalid' || runtimeStatus?.state === 'error'
|
|
||||||
? '重新下载'
|
|
||||||
: '下载组件'}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={runtimeBusy}
|
|
||||||
onClick={() => void refreshRuntime()}
|
|
||||||
>
|
|
||||||
重新检测组件
|
|
||||||
</Button>
|
|
||||||
{personalWechatRuntimeSupported ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={(event) => {
|
|
||||||
runtimeVersionsTriggerRef.current = event.currentTarget
|
|
||||||
setShowWechatVersions(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
支持版本
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{personalWechatRuntimeSupported && runtimeStatus?.state === 'downloading' ? (
|
|
||||||
<div className="tts-runtime-progress">
|
|
||||||
<div>
|
|
||||||
<span>{Math.round(runtimeStatus.progress * 100)}%</span>
|
|
||||||
<small>
|
|
||||||
{formatBytes(runtimeStatus.downloadedBytes)} /{' '}
|
|
||||||
{formatBytes(runtimeStatus.totalBytes)}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<progress
|
|
||||||
value={runtimeStatus.progress}
|
|
||||||
max={1}
|
|
||||||
aria-label="微信发送组件下载进度"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="col-span-full flex items-center justify-between gap-4 border-t border-border-subtle pt-4">
|
|
||||||
<div>
|
|
||||||
<strong className="block text-sm">保留 OneBot 进程</strong>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
退出 TraceMemo 后继续保留进程,重新打开时可以复用。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={keepOneBotProcess}
|
|
||||||
disabled={keepProcessBusy}
|
|
||||||
onCheckedChange={(checked) => void handleKeepOneBotProcessChange(checked)}
|
|
||||||
aria-label="保留 OneBot 进程"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<h2 className="settings-section-heading">配置状态</h2>
|
<h2 className="settings-section-heading">配置状态</h2>
|
||||||
<PersonalWechatSetupGuide
|
<PersonalWechatSetupGuide
|
||||||
runtimeStatus={runtimeStatus}
|
|
||||||
senderStatus={senderStatus}
|
senderStatus={senderStatus}
|
||||||
runtimeProgress={runtimeProgress}
|
|
||||||
runtimeBusy={runtimeBusy}
|
|
||||||
binding={binding}
|
binding={binding}
|
||||||
detecting={detecting}
|
detecting={detecting}
|
||||||
sessionBound={boundToCurrentWechat(senderStatus)}
|
sessionBound={boundToCurrentWechat(senderStatus)}
|
||||||
onDownloadRuntime={() => void downloadRuntime()}
|
|
||||||
onBind={() => void bindWechat()}
|
onBind={() => void bindWechat()}
|
||||||
onStartSending={() =>
|
onStartSending={() =>
|
||||||
onNotice('微信消息发送能力已就绪,请在档案中选择会话开始发送。')
|
onNotice('微信消息发送能力已就绪,请在档案中选择会话开始发送。')
|
||||||
@@ -678,24 +426,6 @@ export function PersonalWechatSendPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={showWechatVersions} onOpenChange={setShowWechatVersions}>
|
|
||||||
<DialogContent
|
|
||||||
className="max-h-[calc(100vh-3rem)] max-w-[620px] overflow-y-auto"
|
|
||||||
onCloseAutoFocus={(event) => {
|
|
||||||
const trigger = runtimeVersionsTriggerRef.current
|
|
||||||
if (!trigger) return
|
|
||||||
event.preventDefault()
|
|
||||||
trigger.focus()
|
|
||||||
runtimeVersionsTriggerRef.current = null
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogHeader className="pr-8">
|
|
||||||
<DialogTitle className="text-lg">支持的微信版本</DialogTitle>
|
|
||||||
<DialogDescription>请安装下列完整版本之一。</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<PersonalWechatSupportedVersionsContent />
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import type { Contact } from '../../../shared/types'
|
||||||
|
import type { GroupExitMonitorEvent } from '../../../shared/group-exit-monitor'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取某个群的退群推断事件(档案合并展示用)。
|
||||||
|
*
|
||||||
|
* 两条硬约束:
|
||||||
|
* 1. **只对群聊请求** —— 单聊没有「群成员快照」这个概念;
|
||||||
|
* 2. **任何失败都退化成「没有事件」** —— 退群事件是附加信息,
|
||||||
|
* 拿不到它绝不能让档案本身的消息展示受影响。
|
||||||
|
*/
|
||||||
|
export function useGroupExitEvents(contact: Contact | null): GroupExitMonitorEvent[] {
|
||||||
|
const [events, setEvents] = useState<GroupExitMonitorEvent[]>([])
|
||||||
|
const roomId = contact?.type === 'group' ? contact.m_nsUsrName || '' : ''
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!roomId) {
|
||||||
|
setEvents([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let disposed = false
|
||||||
|
window.api
|
||||||
|
.listGroupExitMonitorEvents({ roomId })
|
||||||
|
.then((list) => {
|
||||||
|
if (!disposed) setEvents(Array.isArray(list) ? list : [])
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!disposed) setEvents([])
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
disposed = true
|
||||||
|
}
|
||||||
|
}, [roomId])
|
||||||
|
|
||||||
|
return events
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
--wxex-danger: hsl(var(--tm-destructive));
|
--wxex-danger: hsl(var(--tm-destructive));
|
||||||
--wxex-font:
|
--wxex-font:
|
||||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
--wxex-nav-width: 76px;
|
--wxex-nav-width: 88px;
|
||||||
--wxex-shell-content-top: 12px;
|
--wxex-shell-content-top: 12px;
|
||||||
--wxex-radius-sm: 4px;
|
--wxex-radius-sm: 4px;
|
||||||
--wxex-radius-md: 8px;
|
--wxex-radius-md: 8px;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -833,6 +833,41 @@
|
|||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.personal-wechat-authorization-warning {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 20px minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
align-items: start;
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: color-mix(in srgb, var(--wxex-danger) 9%, var(--wxex-bg-elevated));
|
||||||
|
padding: 9px 11px;
|
||||||
|
|
||||||
|
>span {
|
||||||
|
display: grid;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--wxex-danger);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 750;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.personal-wechat-step-error,
|
.personal-wechat-step-error,
|
||||||
.personal-wechat-global-error {
|
.personal-wechat-global-error {
|
||||||
color: var(--wxex-danger) !important;
|
color: var(--wxex-danger) !important;
|
||||||
@@ -908,55 +943,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.personal-wechat-diagnostics {
|
|
||||||
border-top: 1px solid var(--wxex-border);
|
|
||||||
padding-top: 12px;
|
|
||||||
|
|
||||||
summary {
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--wxex-text-secondary);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
>p {
|
|
||||||
margin: 8px 0;
|
|
||||||
color: var(--wxex-text-muted);
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
dl {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 1px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin: 0;
|
|
||||||
border: 1px solid var(--wxex-border);
|
|
||||||
border-radius: var(--wxex-radius-md);
|
|
||||||
background: var(--wxex-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
dl>div {
|
|
||||||
min-width: 0;
|
|
||||||
background: var(--wxex-bg-main);
|
|
||||||
padding: 7px 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
dt {
|
|
||||||
color: var(--wxex-text-muted);
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
dd {
|
|
||||||
overflow: hidden;
|
|
||||||
margin: 2px 0 0;
|
|
||||||
color: var(--wxex-text-secondary);
|
|
||||||
font-size: 10px;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.personal-wechat-composer {
|
.personal-wechat-composer {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -1109,6 +1095,13 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.personal-wechat-global-success {
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--wxex-success) 24%, transparent);
|
||||||
|
padding: 10px 20px 0;
|
||||||
|
color: var(--wxex-success);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
.personal-wechat-chat-footer {
|
.personal-wechat-chat-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -2522,4 +2515,4 @@
|
|||||||
|
|
||||||
.database-account-status {
|
.database-account-status {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|||||||
+32
@@ -90,6 +90,38 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
|
/*
|
||||||
|
* 导航项是固定高(58px × 9 项 + gap ≈ 570px),窗口高度不足时外层
|
||||||
|
* `.app-primary-rail` 是 overflow: hidden,溢出的按钮会被直接裁掉、点不到。
|
||||||
|
* `min-height: 0` 是让本行能在垂直方向收缩(flex 子项默认 min-height: auto,
|
||||||
|
* 会拒绝收缩到内容高度以下),否则 overflow-y 不会生效。
|
||||||
|
* `overflow-x: hidden` 确保不产生横向滚动条。
|
||||||
|
*/
|
||||||
|
min-height: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
/*
|
||||||
|
* 刻意**不用** `scrollbar-gutter: stable`(项目其他滚动容器在用):
|
||||||
|
* 预留滚动条槽位会永久吃掉 ~15px,把最宽 48px 的「问问微信」标签裁掉。
|
||||||
|
* 这里改用自定义细滚动条 —— classic 默认滚动条在这个窄列里非常显眼。
|
||||||
|
* 注意**不要**再设置 `scrollbar-width`:标准属性一旦存在会覆盖 `::-webkit-scrollbar`。
|
||||||
|
*/
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.16);
|
||||||
|
border-radius: 2px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-nav-item {
|
.primary-nav-item {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* 档案里的「退群」推断事件。
|
||||||
|
*
|
||||||
|
* ⚠️ 视觉上必须与 `.wechat-system-message`(微信真实系统消息)**明确区分**:
|
||||||
|
* 那是微信的原话,这条是 TraceMemo 通过「群成员快照前后对比」推断出来的 ——
|
||||||
|
* 可信度不同,时间含义也不同(记录的是**检测时刻**,不是真实退群时间)。
|
||||||
|
* 所以用虚线边框 + 「本地推断」标记,而不是套用系统消息的样式。
|
||||||
|
*/
|
||||||
|
|
||||||
|
.group-exit-event-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 6px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-exit-event-bubble {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: 82%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px dashed var(--wxex-warning);
|
||||||
|
border-radius: var(--tm-radius-md, 6px);
|
||||||
|
background: color-mix(in srgb, var(--wxex-warning) 8%, transparent);
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-exit-event-tag {
|
||||||
|
flex: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--wxex-warning);
|
||||||
|
color: #fff;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-exit-event-text {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
@@ -157,69 +157,6 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.exit-monitor-template-popover {
|
|
||||||
width: min(390px, calc(100vw - 32px));
|
|
||||||
}
|
|
||||||
|
|
||||||
.exit-monitor-template-heading {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exit-monitor-template-heading h2 {
|
|
||||||
margin: 2px 0 0;
|
|
||||||
color: var(--wxex-text-primary);
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exit-monitor-template-status {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
color: var(--wxex-text-muted);
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exit-monitor-template-editor {
|
|
||||||
margin: 14px 0 0;
|
|
||||||
min-height: 190px;
|
|
||||||
resize: vertical;
|
|
||||||
background: color-mix(in srgb, var(--wxex-bg-main) 70%, var(--wxex-bg-elevated));
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exit-monitor-template-meta {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 6px;
|
|
||||||
color: var(--wxex-text-muted);
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exit-monitor-template-popover p {
|
|
||||||
margin: 10px 0 0;
|
|
||||||
color: var(--wxex-text-muted);
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exit-monitor-template-error {
|
|
||||||
color: var(--wxex-danger) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exit-monitor-template-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exit-monitor-manage-count strong {
|
.exit-monitor-manage-count strong {
|
||||||
margin: 0 4px;
|
margin: 0 4px;
|
||||||
color: var(--wxex-brand);
|
color: var(--wxex-brand);
|
||||||
@@ -363,40 +300,6 @@
|
|||||||
color: var(--wxex-danger);
|
color: var(--wxex-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.exit-monitor-event-notification {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 6px;
|
|
||||||
margin-top: 7px;
|
|
||||||
color: var(--wxex-text-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exit-monitor-event-notification span {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exit-monitor-event-notification small {
|
|
||||||
color: var(--wxex-text-muted);
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exit-monitor-event-notification.sent {
|
|
||||||
color: var(--wxex-success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.exit-monitor-event-notification.blocked,
|
|
||||||
.exit-monitor-event-notification.failed {
|
|
||||||
color: var(--wxex-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.exit-monitor-event-notification.pending {
|
|
||||||
color: var(--wxex-brand);
|
|
||||||
}
|
|
||||||
|
|
||||||
.exit-monitor-event-details {
|
.exit-monitor-event-details {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -629,25 +532,6 @@
|
|||||||
color: var(--wxex-brand);
|
color: var(--wxex-brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.exit-monitor-group-notification {
|
|
||||||
display: inline-flex;
|
|
||||||
min-width: 82px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 7px;
|
|
||||||
color: var(--wxex-text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 18px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exit-monitor-group-notification:has([data-disabled]) {
|
|
||||||
color: var(--wxex-text-muted);
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.65;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exit-monitor-manage-footer {
|
.exit-monitor-manage-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -749,12 +633,6 @@
|
|||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.exit-monitor-group-notification {
|
|
||||||
grid-column: 4;
|
|
||||||
grid-row: 2;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exit-monitor-manage-footer {
|
.exit-monitor-manage-footer {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
/**
|
||||||
|
* 群发言统计面板。
|
||||||
|
*
|
||||||
|
* ⚠️ 这里的样式**故意不写裸 Tailwind 布局类**:`tailwind.config.cjs` 设了
|
||||||
|
* `preflight: false`(项目要保住既有 SCSS),于是 Tailwind 不提供任何基础重置 ——
|
||||||
|
* 一旦用裸 Tailwind 搭结构,`<ol>` 会自带 `list-style` 与 `padding-left`、
|
||||||
|
* `<h3>/<p>` 会自带 margin、`border` 只有 `border-width` 没有 `border-style`
|
||||||
|
* (边框根本不显示)。所以按项目既有惯例:语义化类 + `--wxex-*` 变量。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 滚动策略:**整个面板只有一层滚动**。
|
||||||
|
*
|
||||||
|
* DialogContent 默认没有任何 max-height / overflow(见 `ui/dialog.tsx`),所以
|
||||||
|
* 滚动条必须由我们自己给且**只给一处**:这里给面板容器,两个名单列表**不再各自滚动**。
|
||||||
|
* 早先的写法是「面板 68vh 滚动 + 每个列表 320px 滚动」,三层滚动条叠在一起互相打架。
|
||||||
|
*/
|
||||||
|
|
||||||
|
.group-stats-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
max-height: 62vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-right: 4px;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 时间范围 ---- */
|
||||||
|
|
||||||
|
.group-stats-ranges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-stats-custom-range {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-stats-custom-range input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--tm-radius-sm, 4px);
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
padding: 4px 6px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 状态提示 ---- */
|
||||||
|
|
||||||
|
.group-stats-warning,
|
||||||
|
.group-stats-error,
|
||||||
|
.group-stats-notice,
|
||||||
|
.group-stats-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-stats-warning {
|
||||||
|
color: var(--wxex-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** stale 提示行:左侧说明 + 右侧「去建立索引」的去处。 */
|
||||||
|
.group-stats-stale {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-stats-stale .group-stats-warning {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-stats-error {
|
||||||
|
color: var(--wxex-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-stats-notice {
|
||||||
|
color: var(--wxex-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-stats-hint {
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 概览 ---- */
|
||||||
|
|
||||||
|
.group-stats-overview {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-stats-metric {
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--tm-radius-md, 6px);
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-stats-metric-label {
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-stats-metric-value {
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-stats-range-text {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 群备注副标题:跟在标题下方,与标题同左对齐。 */
|
||||||
|
.group-stats-subtitle {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 分节 ---- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 两个名单用 tab 分开:一次只渲染一个列表,滚动压力减半。
|
||||||
|
* `ui/tabs.tsx` 的类不依赖 Tailwind preflight(全是属性类,不含基础重置),
|
||||||
|
* 所以这里直接复用,只补一个对齐。
|
||||||
|
*/
|
||||||
|
.group-stats-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-stats-tabs-list {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-stats-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-stats-section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-stats-section-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-stats-section-head .group-stats-section-count {
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-stats-section-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-stats-empty {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 名单 ---- */
|
||||||
|
|
||||||
|
.group-stats-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--tm-radius-md, 6px);
|
||||||
|
background: var(--wxex-bg-app);
|
||||||
|
/* 刻意**不**设 max-height / overflow:列表全量展开,
|
||||||
|
滚动交给唯一的 .group-stats-panel。多一层滚动就多一条打架的滚动条。 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-stats-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-stats-row + .group-stats-row {
|
||||||
|
border-top: 1px solid var(--wxex-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-stats-row-name {
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-stats-row-index {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
margin-right: 4px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-stats-row-meta {
|
||||||
|
flex: none;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-stats-notes {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user