Compare commits

..
13 Commits
189 changed files with 11106 additions and 27703 deletions
+1 -2
View File
@@ -10,8 +10,7 @@ out
coverage/ coverage/
playwright-report/ playwright-report/
test-results/ test-results/
resources/runtime/darwin-arm64 resources/connectors/wechat-personal/
.native-runtime-source
.omc .omc
.codex/ .codex/
skills-lock.json skills-lock.json
+1
View File
@@ -61,6 +61,7 @@ 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
View File
@@ -0,0 +1,674 @@
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
View File
@@ -0,0 +1,28 @@
# 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`。
+1
View File
@@ -34,6 +34,7 @@ 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
View File
@@ -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-native": "node scripts/prepare-wechat-native-runtime.cjs", "prepare:wechat-personal": "node scripts/prepare-wechat-chatter-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.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.
+436
View File
@@ -0,0 +1,436 @@
/*
* 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] 运行时准备完成')
-201
View File
@@ -1,201 +0,0 @@
#!/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
}
}
+28 -4
View File
@@ -1172,13 +1172,37 @@ const renderExportScript = (name: string): string => `
} }
const renderPaymentContent = (data, kind) => { const renderPaymentContent = (data, kind) => {
const isTransfer = kind === 'transfer' const isTransfer = kind === 'transfer'
const pay = (isTransfer ? data.transfer : data.pay) || {}
const amount = pay.amountText
const memo = pay.payMemo
const statusText = isTransfer ? pay.transferStatusText : pay.redPacketStatusText
const meta = [amount, memo ? '备注:' + memo : '', statusText]
.filter(Boolean)
.join(' · ')
const title = isTransfer
? (data.title || '微信转账')
: (pay.sendTitle || pay.receiveTitle || data.title || '微信红包')
const blurb =
data.description ||
data.des ||
(isTransfer ? '转账消息' : (pay.sceneText || '恭喜发财,大吉大利'))
// 状态/金额/备注始终单独露出,避免被消息原文 des 盖住。
const description = meta
? (blurb && blurb !== meta ? blurb + ' · ' + meta : meta)
: blurb
const footerBits = isTransfer
? [pay.paySubtype === '3' ? '收款' : pay.paySubtype === '1' || pay.paySubtype === '4' ? '转账' : '',
pay.transferId ? '单号 ' + pay.transferId : '']
: [pay.hbType ? '类型 ' + pay.hbType : '', pay.sendId ? 'sendid ' + pay.sendId : '']
return '<div class="structured-content payment-content ' + (isTransfer ? 'transfer' : 'red-packet') + return '<div class="structured-content payment-content ' + (isTransfer ? 'transfer' : 'red-packet') +
'" data-rich-kind="' + (isTransfer ? 'transfer' : 'redPacket') + '">' + '" data-rich-kind="' + (isTransfer ? 'transfer' : 'redPacket') + '">' +
'<div class="structured-kicker">' + (isTransfer ? '微信转账' : '微信红包') + '</div>' + '<div class="structured-kicker">' + (isTransfer ? '微信转账' : '微信红包') + '</div>' +
'<div class="structured-title">' + displayText(data.title || (isTransfer ? '微信转账' : '微信红包')) + '</div>' + '<div class="structured-title">' + displayText(title) + '</div>' +
'<div class="structured-description">' + '<div class="structured-description">' + displayText(description) + '</div>' +
displayText(data.description || data.des || (isTransfer ? '转账消息' : '恭喜发财,大吉大利')) + (footerBits.filter(Boolean).length
'</div></div>' ? '<div class="structured-footer">' + displayText(footerBits.filter(Boolean).join(' · ')) + '</div>'
: '') +
'</div>'
} }
const renderVoipContent = (data) => { const renderVoipContent = (data) => {
const title = Number(data.roomType) === 1 ? '视频通话' : '语音通话' const title = Number(data.roomType) === 1 ? '视频通话' : '语音通话'
+23
View File
@@ -25,6 +25,7 @@ import { mergeCachedSelfInfo, type CachedSelfInfo } from './services/bootstrap-c
import type { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-case' import type { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-case'
import { imageFileQuality } from '../shared/image-quality' import { imageFileQuality } from '../shared/image-quality'
import { resolveMemberName } from '../shared/member-names' import { resolveMemberName } from '../shared/member-names'
import { FavoritesService } from './favorites-service'
import { filesystemSafeName } from '../shared/contact-name' import { filesystemSafeName } from '../shared/contact-name'
const jobs = new Set<string>() const jobs = new Set<string>()
@@ -853,6 +854,28 @@ async function runSingleExport(
percent: Math.max(1, Math.round(((targetOrder + 1) / targets.length) * 10)) percent: Math.max(1, Math.round(((targetOrder + 1) / targets.length) * 10))
}) })
} }
if (request.includeFavorites) {
const wcdb = chat.getChatDb()?.getWcdb4Client()
if (wcdb) {
try {
const favoriteMessages = await new FavoritesService(wcdb).listExportMessages(500)
for (const [messageOrder, message] of favoriteMessages.entries()) {
if (!request.kinds.includes(kindOf(message))) continue
messageEntries.push({
message: {
...message,
exportConversationId: 'favorites',
exportConversationName: '收藏'
},
targetOrder: targets.length,
messageOrder
})
}
} catch (error) {
console.warn('[export] favorites merge skipped:', error)
}
}
}
const messages = messageEntries const messages = messageEntries
.sort((left, right) => { .sort((left, right) => {
const byTime = Number(left.message.createTime || 0) - Number(right.message.createTime || 0) const byTime = Number(left.message.createTime || 0) - Number(right.message.createTime || 0)
+71
View File
@@ -0,0 +1,71 @@
import type { Message, ParsedContent } from '../shared/types'
import { favoriteRowToContent } from '../shared/favorites'
import type { Wcdb4Client } from './wcdb4-client'
/** 收藏只读服务:favorite.db → 可读卡片 / 导出消息。 */
export class FavoritesService {
constructor(private readonly wcdb4Client: Wcdb4Client) {}
async listContents(limit = 200): Promise<ParsedContent[]> {
const rows = await this.wcdb4Client.listFavoriteItems(limit)
return rows.map((row) => favoriteRowToContent(row))
}
/** 收藏转成导出/搜索可用的 Message 列表(合成会话「收藏」)。 */
async listExportMessages(limit = 200): Promise<Message[]> {
const rows = await this.wcdb4Client.listFavoriteItems(limit)
return rows.map((row, index) => {
const contentData = favoriteRowToContent(row)
const createTime = Number(row.update_time) || 0
return {
id: `fav-${row.local_id ?? index}`,
from: 'favorite',
type: '收藏',
datetime: createTime ? new Date(createTime * 1000).toISOString() : '',
content: textOf(contentData),
isSender: false,
createTime,
name: '收藏',
contentData
} as Message
})
}
/** 只读关键词搜索收藏文本(title/desc/正文)。 */
async search(query: string, limit = 50): Promise<ParsedContent[]> {
const needle = String(query || '').trim().toLowerCase()
if (!needle) return []
const items = await this.listContents(500)
return items.filter((item) => textOf(item).toLowerCase().includes(needle)).slice(0, limit)
}
/** Local Query API 适配:只要文本与时间戳。 */
async searchHits(
query: string,
limit = 20
): Promise<Array<{ text: string; timestamp?: number }>> {
const rows = await this.wcdb4Client.listFavoriteItems(500)
const needle = String(query || '').trim().toLowerCase()
if (!needle) return []
return rows
.map((row) => {
const content = favoriteRowToContent(row)
return {
text: textOf(content),
timestamp: Number(row.update_time) || undefined
}
})
.filter((hit) => hit.text.toLowerCase().includes(needle))
.slice(0, limit)
}
}
function textOf(content: ParsedContent): string {
if (content.type === 'text') return content.content
if (content.type === 'system') return content.content
if (content.type === 'share') return [content.title, content.des].filter(Boolean).join(' · ')
if (content.type === 'location') return content.poiname || content.label || '[位置]'
if (content.type === 'miniProgram') return content.title || '[小程序]'
if (content.type === 'forwardBundle') return content.title || '[聊天记录]'
return '[收藏]'
}
+4 -10
View File
@@ -3,7 +3,6 @@ import http, { IncomingMessage, ServerResponse, Server } from 'http'
import { import {
isReady, isReady,
listContacts, listContacts,
listContactsAsync,
listMessages, listMessages,
getGroupSnapshot, getGroupSnapshot,
listRecentChat, listRecentChat,
@@ -231,26 +230,21 @@ const routes: Record<string, RouteHandler> = {
}) })
}, },
'/api/v1/contact': async ({ res, url }) => { '/api/v1/contact': ({ 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
// Use the hydrated data source: on macOS the session cache only carries raw let contacts = listContacts(filter)
// 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': async ({ res, url }) => { '/api/v1/chatroom': ({ 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') || ''
// Same hydration requirement as /api/v1/contact: group display names are let groups = listContacts().filter((c) => c.type === 'group')
// 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(
+121 -413
View File
@@ -14,8 +14,7 @@ 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'
@@ -113,28 +112,22 @@ 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 { toPersonalWechatSendResult, wechatActionGateway } from './services/wechat-action-gateway' import { 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 {
@@ -203,12 +196,6 @@ 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.
@@ -224,7 +211,6 @@ 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
@@ -236,6 +222,7 @@ 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()
@@ -505,29 +492,9 @@ 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: initialWidth, width: 1400,
height: initialHeight, height: 800,
minWidth: 960,
minHeight: 640,
center: true,
show: false, show: false,
autoHideMenuBar: true, autoHideMenuBar: true,
icon: appIconPath, icon: appIconPath,
@@ -787,9 +754,6 @@ 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(() =>
@@ -807,6 +771,13 @@ app.whenReady().then(async () => {
localQueryApiService.setImageOcrEntryProvider((conversationId, messageId) => localQueryApiService.setImageOcrEntryProvider((conversationId, messageId) =>
imageTextIndexService.getConversationOcr(conversationId).get(messageId) imageTextIndexService.getConversationOcr(conversationId).get(messageId)
) )
// 收藏只读检索:search_messages 合并 favorite.db 命中(无库时返回空)。
localQueryApiService.setFavoritesSearchProvider(async (query, limit) => {
const wcdb = chat.getChatDb()?.getWcdb4Client()
if (!wcdb) return []
const { FavoritesService } = await import('./favorites-service')
return new FavoritesService(wcdb).searchHits(query, limit)
})
setLocalQueryApiService(localQueryApiService) setLocalQueryApiService(localQueryApiService)
// Query Agent:生产 Runtime 只在这里实例化一次,桌面问问微信与 Agent Hub 共用同一个实例。 // Query Agent:生产 Runtime 只在这里实例化一次,桌面问问微信与 Agent Hub 共用同一个实例。
queryAgentService = new QueryAgentService( queryAgentService = new QueryAgentService(
@@ -866,6 +837,11 @@ 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 })
@@ -1028,120 +1004,20 @@ 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((event) => { const monitoring = await wcdb4Client.startMonitor((type, json) => {
wcdb4Client.invalidateSessionCache() wcdb4Client.invalidateSessionCache()
// 正式 MessageListener:只做 coalesce + 有界回读 + dedup + 投递,不触发业务。 groupExitMonitorService.notifyDatabaseChanged(json)
// v2 事件带 sessionId ⇒ 回读不再依赖「最近活跃会话」。 recallArchiveMonitor?.handleDatabaseChange(json)
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()) { if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
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) {
@@ -1283,54 +1159,7 @@ app.whenReady().then(async () => {
) )
// 日报等系统动作仍复用现有发送服务;普通聊天不再暴露这个入口。 // 日报等系统动作仍复用现有发送服务;普通聊天不再暴露这个入口。
ipcMain.handle('wechat-personal:send', async (_, request: PersonalWechatSendRequest) => { ipcMain.handle('wechat-personal:send', async (_, request: PersonalWechatSendRequest) => {
// 项目规则:**所有发送都必须经过 WechatActionGateway**(审计 + 幂等 + 同一个 Send Log)。 if (request.type !== 'voice' || String(request.fromId || '').trim()) {
// 手动发送在改造前从这里直连 `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 = ''
@@ -1647,153 +1476,31 @@ 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',
@@ -2013,58 +1720,44 @@ 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) =>
* 定时日报(Automation 的 `scheduled_report` 规则类型)。 scheduledReportService.listExecutions(taskId)
*
* 规则本身的 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('automation:getScheduledReportNotificationSettings', () => ipcMain.handle('scheduled-report:getNotificationSettings', () =>
scheduledReportService.getNotificationSettings() scheduledReportService.getNotificationSettings()
) )
ipcMain.handle('automation:getScheduledReportNotificationCapability', () => ipcMain.handle('scheduled-report:setNotificationEnabled', (_, enabled: boolean) =>
scheduledReportService.checkNotificationCapability()
)
ipcMain.handle('automation:setScheduledReportNotificationEnabled', (_, enabled: boolean) =>
scheduledReportService.setNotificationEnabled(Boolean(enabled)) scheduledReportService.setNotificationEnabled(Boolean(enabled))
) )
ipcMain.handle('automation:testScheduledReportErrorNotification', (_, ruleId: string) => { ipcMain.handle('scheduled-report:create', (_, request: ScheduledReportCreateInput) =>
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(ruleId) return scheduledReportService.testScheduledReportErrorNotification(taskId)
}) })
ipcMain.handle('report:reveal', async (_, filePath: string) => { ipcMain.handle('report:reveal', async (_, filePath: string) => {
@@ -2530,13 +2223,11 @@ 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((event) => { const monitoring = await client.startMonitor((type, json) => {
client.invalidateSessionCache() client.invalidateSessionCache()
groupExitMonitorService.notifyDatabaseChanged(event.raw) groupExitMonitorService.notifyDatabaseChanged(json)
for (const window of BrowserWindow.getAllWindows()) { for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) { if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
window.webContents.send('wcdb-change', { type: event.type, json: event.raw })
}
} }
}) })
void groupExitMonitorService.start(monitoring) void groupExitMonitorService.start(monitoring)
@@ -2661,16 +2352,42 @@ app.whenReady().then(async () => {
agentHubService.clearConversations() agentHubService.clearConversations()
return { success: true } return { success: true }
}) })
ipcMain.handle('wechat-personal:getStatus', () => currentPersonalWechatSenderStatus()) ipcMain.handle('wechat-personal:getStatus', () => personalWechatSendService.getStatus())
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', () => ipcMain.handle('wechat-personal:installPilk', async () => {
personalWechatVoiceEnvironmentService.installPilk() const result = await 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/')
@@ -2687,20 +2404,23 @@ app.whenReady().then(async () => {
return { success: false, error: '无法打开 FFmpeg 安装页面' } return { success: false, error: '无法打开 FFmpeg 安装页面' }
} }
}) })
ipcMain.handle('wechat-personal:rebind', async () => { ipcMain.handle('wechat-personal:getRuntimeStatus', () => personalWechatRuntimeManager.getStatus())
if (process.platform !== 'darwin') return personalWechatSendService.rebind() ipcMain.handle('wechat-personal:downloadRuntime', () => personalWechatRuntimeManager.download())
ipcMain.handle('wechat-personal:cancelRuntimeDownload', () => ({
const result = await macWechatRuntimeManager.bind() success: personalWechatRuntimeManager.cancelDownload()
const status = await macWechatRuntimeManager.buildSenderStatus() }))
if (result.ok) return status ipcMain.handle('wechat-personal:removeRuntime', async () => {
await personalWechatSendService.terminate(true)
return { return personalWechatRuntimeManager.remove()
...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) => {
@@ -2728,7 +2448,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 currentPersonalWechatSenderStatus()) const status = sendResult?.status || (await personalWechatSendService.getStatus())
return { action, status } return { action, status }
} }
) )
@@ -2803,21 +2523,9 @@ 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) => {
* Deliberately do NOT stop the macOS native runtime here. console.warn('[Shutdown] personal WeChat sender cleanup failed:', error)
* })
* 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,7 +4,6 @@ import type {
KnowledgeImageOcrState, KnowledgeImageOcrState,
KnowledgeAttachmentMetadata, KnowledgeAttachmentMetadata,
KnowledgeEvidence, KnowledgeEvidence,
KnowledgeMemberStatsResult,
KnowledgeMessageKind, KnowledgeMessageKind,
KnowledgePassProgress, KnowledgePassProgress,
KnowledgeRuntimeState, KnowledgeRuntimeState,
@@ -499,37 +498,6 @@ 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` 组成。
*/ */
-9
View File
@@ -5,8 +5,6 @@ import type {
KnowledgeIndexProgress, KnowledgeIndexProgress,
KnowledgeIndexRequest, KnowledgeIndexRequest,
KnowledgeIndexResult, KnowledgeIndexResult,
KnowledgeMemberStatsRequest,
KnowledgeMemberStatsResult,
KnowledgeRuntimeStatus, KnowledgeRuntimeStatus,
KnowledgeSearchRequest, KnowledgeSearchRequest,
KnowledgeSearchResult, KnowledgeSearchResult,
@@ -55,13 +53,6 @@ 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()
-88
View File
@@ -13,7 +13,6 @@ import type {
KnowledgeIndexProgress, KnowledgeIndexProgress,
KnowledgeIndexRequest, KnowledgeIndexRequest,
KnowledgeIndexResult, KnowledgeIndexResult,
KnowledgeMemberStatsResult,
KnowledgeRuntimeStatus, KnowledgeRuntimeStatus,
KnowledgeNormalizedMessage, KnowledgeNormalizedMessage,
KnowledgeQuery, KnowledgeQuery,
@@ -318,93 +317,6 @@ 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,8 +6,6 @@ import type {
KnowledgeIndexProgress, KnowledgeIndexProgress,
KnowledgeIndexRequest, KnowledgeIndexRequest,
KnowledgeIndexResult, KnowledgeIndexResult,
KnowledgeMemberStatsRequest,
KnowledgeMemberStatsResult,
KnowledgeRuntimeStatus, KnowledgeRuntimeStatus,
KnowledgeSearchRequest, KnowledgeSearchRequest,
KnowledgeSearchResult, KnowledgeSearchResult,
@@ -21,7 +19,6 @@ 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 = {
@@ -79,11 +76,6 @@ 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
-38
View File
@@ -1,8 +1,6 @@
import type { import type {
KnowledgeCapacityPreflightRequest, KnowledgeCapacityPreflightRequest,
KnowledgeIndexRequest, KnowledgeIndexRequest,
KnowledgeMemberStatsRequest,
KnowledgeMemberStatsResult,
KnowledgeRuntimeStatus, KnowledgeRuntimeStatus,
KnowledgeSearchRequest, KnowledgeSearchRequest,
KnowledgeStatusRequest, KnowledgeStatusRequest,
@@ -189,38 +187,6 @@ 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') {
@@ -260,10 +226,6 @@ 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
+482 -9
View File
@@ -1,3 +1,5 @@
import { describeRedPacketStatus, describeTransferStatus } from '../shared/payment-status'
type TextContent = { type: 'text'; content: string } type TextContent = { type: 'text'; content: string }
type VoiceContent = { type: 'voice'; duration?: number } type VoiceContent = { type: 'voice'; duration?: number }
type LocationContent = { type LocationContent = {
@@ -22,6 +24,44 @@ type ShareContent = {
appname?: string appname?: string
typeVal?: string typeVal?: string
articles?: ShareArticle[] articles?: ShareArticle[]
transfer?: TransferPaymentInfo
}
type TransferPaymentInfo = {
paySubtype?: string
amountText?: string
transcationId?: string
transferId?: string
invalidTime?: string
beginTransferTime?: string
effectiveDate?: string
payMemo?: string
receiverUsername?: string
payerUsername?: string
transferStatus?: string
/** `transfer_status` 展示文案(只读)。 */
transferStatusText?: string
transId?: string
feeType?: string
transferAttach?: string
refundBankType?: string
}
type RedPacketPaymentInfo = {
templateId?: string
receiveTitle?: string
sendTitle?: string
sceneText?: string
senderDes?: string
receiverDes?: string
iconUrl?: string
nativeUrl?: string
sendId?: string
hbType?: string
hbStatus?: string
receiveStatus?: string
/** `hb_status` / `receive_status` 展示文案(只读)。 */
redPacketStatusText?: string
} }
type ForwardedMessageItem = { type ForwardedMessageItem = {
messageType: number messageType: number
@@ -51,6 +91,7 @@ type RedPacketContent = {
title: string title: string
description?: string description?: string
url?: string url?: string
pay?: RedPacketPaymentInfo
} }
type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number } type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number }
type ImageContent = { type ImageContent = {
@@ -122,10 +163,26 @@ export type ParsedContent =
| SystemContent | SystemContent
| UnknownContent | UnknownContent
/**
* `local_type` 可能是打包 u64:低 32 位为类型枚举,高 32 位为标志/子类型。
*/
export function normalizeLocalType(messageType: number | string): number {
try {
const value =
typeof messageType === 'number'
? BigInt(Math.trunc(messageType))
: BigInt(String(messageType).trim())
return Number(value & 0xffffffffn)
} catch {
const fallback = Number(messageType)
return Number.isFinite(fallback) ? Math.trunc(fallback) : 0
}
}
export function parseMessageContent(content: string, messageType: number): ParsedContent { export function parseMessageContent(content: string, messageType: number): ParsedContent {
// Voice rows may keep their binary payload outside msgContent, so an empty const localType = normalizeLocalType(messageType)
// content string is still a valid voice message. // content string is still a valid voice message.
if (messageType === 34) { if (localType === 34) {
const duration = parseVoiceDurationSeconds(content) const duration = parseVoiceDurationSeconds(content)
return duration === undefined ? { type: 'voice' } : { type: 'voice', duration } return duration === undefined ? { type: 'voice' } : { type: 'voice', duration }
} }
@@ -135,11 +192,19 @@ export function parseMessageContent(content: string, messageType: number): Parse
const normalized = content.trim() const normalized = content.trim()
switch (messageType) { switch (localType) {
case 1: case 1:
return { type: 'text', content: normalized } return { type: 'text', content: normalized }
case 2:
return parseLegacyType2(normalized)
case 3: case 3:
return parseImageMessage(normalized) return parseImageMessage(normalized)
case 8:
return parseLegacyType8(normalized)
case 17:
return parseForwardBundle(normalized)
case 37:
return parseFriendVerifyMessage(normalized)
case 42: case 42:
return parseCardMessage(normalized) return parseCardMessage(normalized)
case 43: case 43:
@@ -156,7 +221,7 @@ export function parseMessageContent(content: string, messageType: number): Parse
case 10002: case 10002:
return parseSystemMessage(normalized) return parseSystemMessage(normalized)
default: default:
return { type: 'unknown', raw: normalized, messageType } return { type: 'unknown', raw: normalized, messageType: localType }
} }
} }
@@ -480,6 +545,30 @@ function parseLocationMessage(content: string): ParsedContent {
function parseShareMessage(content: string): ParsedContent { function parseShareMessage(content: string): ParsedContent {
const appMsgType = extractAppMsgType(content) const appMsgType = extractAppMsgType(content)
const isFileMessage = appMsgType === '6' || appMsgType === '74' const isFileMessage = appMsgType === '6' || appMsgType === '74'
if (/<patMsg\b/i.test(content) || /sysmsg[^>]+type=["']pat["']/i.test(content)) {
return parsePatMessage(content)
}
if (/<findernamecard\b/i.test(content)) {
return parseFinderNameCard(content)
}
if (/<productitem\b/i.test(content) && !extractXmlValue(content, 'title')) {
return parseProductItem(content)
}
if (looksLikeMusicShare(content)) {
return parseMusicShareCard(content)
}
if (looksLikeSubscribeCard(content)) {
return parseSubscribeCard(content)
}
if (looksLikeKefuCard(content)) {
return parseKefuCard(content)
}
if (looksLikeStreamVideo(content)) {
return parseStreamVideoCard(content)
}
if (looksLikeGiftCard(content)) {
return parseGiftCard(content)
}
if (appMsgType === '19') { if (appMsgType === '19') {
return parseForwardBundle(content) return parseForwardBundle(content)
} }
@@ -522,12 +611,25 @@ function parseShareMessage(content: string): ParsedContent {
} }
} }
if (appMsgType === '2001') { if (appMsgType === '2001' || /mmpayhb|receivehongbao|wxpay:\/\/c2cbizmessagehandler\/hongbao/i.test(content)) {
const pay = parseWcpayInfo(content)
const url = decodeXmlUrl(extractXmlValue(content, 'url')) || undefined
return { return {
type: 'redPacket', type: 'redPacket',
title: extractXmlValue(content, 'title') || '微信红包', title:
description: extractXmlValue(content, 'des') || '恭喜发财,大吉大利', pay.sendTitle ||
url: decodeXmlUrl(extractXmlValue(content, 'url')) || undefined pay.receiveTitle ||
extractXmlValue(content, 'title') ||
'微信红包',
description:
extractXmlValue(content, 'des') ||
pay.sceneText ||
'恭喜发财,大吉大利',
url,
pay: {
...pay,
sendId: pay.sendId || extractSendIdFromUrl(url)
}
} }
} }
@@ -547,6 +649,12 @@ function parseShareMessage(content: string): ParsedContent {
const typeVal = extractXmlValue(content, 'type') || '' const typeVal = extractXmlValue(content, 'type') || ''
if (!title && !url) { if (!title && !url) {
if (/<productitem\b/i.test(content)) return parseProductItem(content)
if (looksLikeMusicShare(content)) return parseMusicShareCard(content)
if (looksLikeSubscribeCard(content)) return parseSubscribeCard(content)
if (looksLikeKefuCard(content)) return parseKefuCard(content)
if (looksLikeStreamVideo(content)) return parseStreamVideoCard(content)
if (looksLikeGiftCard(content)) return parseGiftCard(content)
return { type: 'unknown', raw: content } return { type: 'unknown', raw: content }
} }
@@ -557,7 +665,372 @@ function parseShareMessage(content: string): ParsedContent {
url, url,
appname, appname,
typeVal, typeVal,
articles: articles.length > 1 ? articles : undefined articles: articles.length > 1 ? articles : undefined,
transfer: typeVal === '2000' ? parseWcpayInfo(content) : undefined
}
}
/** 只读解析 `<wcpayinfo>`(转账 / 红包展示字段;不做支付)。 */
function parseWcpayInfo(content: string): TransferPaymentInfo & RedPacketPaymentInfo {
const block = /<wcpayinfo>([\s\S]*?)<\/wcpayinfo>/i.exec(content)?.[1] || content
const val = (tag: string): string | undefined => {
const raw = extractXmlValue(block, tag)
return raw ? decodeXmlEntities(raw) || undefined : undefined
}
const transferStatus = val('transfer_status')
const hbStatus = val('hb_status')
const receiveStatus = val('receive_status')
return {
paySubtype: val('paysubtype'),
amountText: val('feedesc'),
transcationId: val('transcationid'),
transferId: val('transferid'),
invalidTime: val('invalidtime'),
beginTransferTime: val('begintransfertime'),
effectiveDate: val('effectivedate'),
payMemo: val('pay_memo') || undefined,
receiverUsername: val('receiver_username'),
payerUsername: val('payer_username'),
transferStatus,
transferStatusText: describeTransferStatus(transferStatus),
transId: val('trans_id'),
feeType: val('fee_type'),
transferAttach: val('transfer_attach'),
refundBankType: val('refund_bank_type'),
templateId: val('templateid'),
receiveTitle: val('receivertitle'),
sendTitle: val('sendertitle'),
sceneText: val('scenetext'),
senderDes: val('senderdes'),
receiverDes: val('receiverdes'),
iconUrl: decodeXmlUrl(val('iconurl') || '') || undefined,
nativeUrl: val('nativeurl'),
hbType: val('hb_type'),
hbStatus,
receiveStatus,
redPacketStatusText: describeRedPacketStatus(hbStatus, receiveStatus)
}
}
function extractSendIdFromUrl(url?: string): string | undefined {
if (!url) return undefined
try {
return new URL(url).searchParams.get('sendid') || undefined
} catch {
return /sendid=(\d+)/i.exec(url)?.[1]
}
}
/** 拍一拍(`HandlePatMsg` / `<patMsg>`)→ 系统文案。 */
function parsePatMessage(content: string): ParsedContent {
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
const template =
extractXmlValue(decoded, 'template') ||
extractXmlValue(decoded, 'pattemplate') ||
extractXmlValue(decoded, 'plain') ||
''
const from = extractXmlValue(decoded, 'fromusername') || extractXmlValue(decoded, 'fromuser')
const to = extractXmlValue(decoded, 'tousername') || extractXmlValue(decoded, 'touser')
const pat = extractXmlValue(decoded, 'pat') || extractXmlValue(decoded, 'patted')
const text =
normalizeSystemText(
template.replace(/\$from\$/g, from || '').replace(/\$to\$/g, to || '').replace(/\$pat\$/g, pat || '')
) || normalizeSystemText(decoded.replace(/<[^>]+>/g, ' ')) || '拍了拍'
return { type: 'system', content: text, raw: content }
}
/** 视频号名片(`<findernamecard>`)→ 只读 share。 */
function parseFinderNameCard(content: string): ParsedContent {
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
const username = extractXmlValue(decoded, 'username') || extractXmlValue(decoded, 'finderUsername')
const nickname = extractXmlValue(decoded, 'nickname') || extractXmlValue(decoded, 'finderNickname')
const title = nickname || username || extractXmlValue(decoded, 'title') || '视频号名片'
const url = decodeXmlUrl(extractXmlValue(decoded, 'url') || extractXmlValue(decoded, 'appPageUrl') || '')
return {
type: 'share',
title,
des: username && nickname ? username : undefined,
url: url || '',
appname: '视频号',
typeVal: extractAppMsgType(content) || 'findernamecard'
}
}
/** 商品卡(`<productitem>`)→ 只读 share。 */
function parseProductItem(content: string): ParsedContent {
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
const title =
extractXmlValue(decoded, 'productName') ||
extractXmlValue(decoded, 'product_name') ||
extractXmlValue(decoded, 'title') ||
'商品'
const des =
extractXmlValue(decoded, 'productDesc') ||
extractXmlValue(decoded, 'sellingPrice') ||
extractXmlValue(decoded, 'referdes')
const url = decodeXmlUrl(extractXmlValue(decoded, 'url') || extractXmlValue(decoded, 'productUrl') || '')
return {
type: 'share',
title,
des: des || undefined,
url: url || '',
appname: extractXmlValue(decoded, 'sellername') || '商品',
typeVal: extractAppMsgType(content) || 'productitem'
}
}
/** 好友验证(local_type=37)→ 系统文案,避免 unknown 黑块。 */
function parseFriendVerifyMessage(content: string): ParsedContent {
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
const text = extractSysmsgTemplateText(decoded)
if (text) return { type: 'system', content: text, raw: content }
const nickname = extractXmlValue(decoded, 'nickname') || extractXmlValue(decoded, 'NickName')
const username = extractXmlValue(decoded, 'username') || extractXmlValue(decoded, 'UserName')
const contentText =
extractXmlValue(decoded, 'content') ||
extractXmlValue(decoded, 'bighead') ||
extractXmlValue(decoded, 'source')
const label = normalizeSystemText(
[nickname || username ? `${nickname || username}` : '', contentText || '好友验证消息']
.filter(Boolean)
.join(' · ')
)
return { type: 'system', content: label || '好友验证消息', raw: content }
}
function looksLikeMusicShare(content: string): boolean {
return /songalbumurl|songlyric|musicShareItem|tingListenItem|ListenItem|music_share_item|musicurl|<songlyri|<songalbu/i.test(
content
)
}
function looksLikeSubscribeCard(content: string): boolean {
return /subscribeMessage|subscribe_msg|SubscribeMsg|updatablemsg|wadynamicpageinf|OnSubscriptionCustom/i.test(
content
)
}
function looksLikeKefuCard(content: string): boolean {
return /opencustomerservicemsg|wa_app_kefu_message|kefumenu|kf_order|kf_user_|ChatKfTemplate|AppReaderTemplate|template_header/i.test(
content
)
}
/** 流视频 / 长视频卡(`streamvideotitle` / `finderMegaVideo`)。 */
function looksLikeStreamVideo(content: string): boolean {
return /streamvideotitle|streamvideoword|streamvideoweburl|streamvideothumb|finderMegaVideo|finderLiveInvite/i.test(
content
)
}
/**
* 礼物 / 礼品卡(`csgift` / `giftcarditem`)。
* 只读展示;**不**调用 `acceptgiftcard` / `preacceptgiftcard` / `getcardgiftinfo`。
*/
function looksLikeGiftCard(content: string): boolean {
return /<csgift\b|<ecsgift\b|<giftcarditem\b|<giftcard\b|giftcarditem|acceptgiftcard/i.test(content)
}
/** 流视频卡 → 只读 share。 */
function parseStreamVideoCard(content: string): ParsedContent {
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
const title =
extractXmlValue(decoded, 'streamvideotitle') ||
extractXmlValue(decoded, 'title') ||
extractXmlValue(decoded, 'sourcetitle') ||
'视频'
const des =
extractXmlValue(decoded, 'streamvideoword') ||
extractXmlValue(decoded, 'des') ||
extractXmlValue(decoded, 'contentdescshowtext') ||
undefined
const url =
decodeXmlUrl(
extractXmlValue(decoded, 'streamvideoweburl') ||
extractXmlValue(decoded, 'url') ||
extractXmlValue(decoded, 'weburl')
) || ''
return {
type: 'share',
title,
des,
url,
appname: /finderMegaVideo|finderLiveInvite/i.test(content) ? '视频号' : '视频',
typeVal: extractAppMsgType(content) || 'streamvideo'
}
}
/** 礼物 / 礼品卡 → 只读 share(无 title 时 system 文案)。 */
function parseGiftCard(content: string): ParsedContent {
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
const title =
extractXmlValue(decoded, 'title') ||
extractXmlValue(decoded, 'gifttitle') ||
extractXmlValue(decoded, 'cardtitle') ||
extractXmlValue(decoded, 'brandname')
const des =
extractXmlValue(decoded, 'des') ||
extractXmlValue(decoded, 'giftwording') ||
extractXmlValue(decoded, 'cardwording') ||
extractXmlValue(decoded, 'description')
const url = decodeXmlUrl(extractXmlValue(decoded, 'url') || extractXmlValue(decoded, 'cardurl') || '')
if (!title && !des) {
return {
type: 'system',
content: normalizeSystemText(decoded.replace(/<[^>]+>/g, ' ')) || '礼物卡',
raw: content
}
}
return {
type: 'share',
title: title || '礼物卡',
des: des || undefined,
url: url || '',
appname: extractXmlValue(decoded, 'brandname') || '礼物',
typeVal: extractAppMsgType(content) || 'giftcard'
}
}
/**
* local_type=2:真机 histogram 未采到;按内容形态尽量落到 system/text,
* 否则保留 unknown(见 wechat-message-type-coverage.md)。
*/
function parseLegacyType2(content: string): ParsedContent {
if (/<sysmsg\b|<patMsg\b|revoke/i.test(content)) {
return parseSystemMessage(content)
}
if (!/<[a-zA-Z!]/.test(content)) {
return { type: 'text', content }
}
return parseSystemMessage(content)
}
/**
* local_type=8:社区多见于 GIF/大表情变体;无真机样本时按 sticker → image → system
* 逐级尝试,避免直接黑块。
*/
function parseLegacyType8(content: string): ParsedContent {
const sticker = parseStickerMessage(content)
if (sticker.type === 'sticker' && sticker.md5) return sticker
const image = parseImageMessage(content)
if (image.type === 'image' && (image.md5 || image.datName)) return image
if (/<sysmsg\b|<emoji\b|<msg\b/i.test(content)) {
const system = parseSystemMessage(content)
if (system.type === 'system' && system.content && system.content !== content) return system
}
return { type: 'unknown', raw: content, messageType: 8 }
}
/** 音乐 / 听歌分享(`musicShareItem` / `ListenItem` / `songalbumurl`)→ 只读 share。 */
function parseMusicShareCard(content: string): ParsedContent {
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
const title =
extractXmlValue(decoded, 'title') ||
extractXmlValue(decoded, 'songname') ||
extractXmlValue(decoded, 'musicTitle') ||
'音乐分享'
const des =
extractXmlValue(decoded, 'des') ||
extractXmlValue(decoded, 'singername') ||
extractXmlValue(decoded, 'albumname') ||
undefined
const url =
decodeXmlUrl(
extractXmlValue(decoded, 'url') ||
extractXmlValue(decoded, 'musicurl') ||
extractXmlValue(decoded, 'streamweburl') ||
extractXmlValue(decoded, 'weburl')
) || ''
const appname =
extractXmlValue(decoded, 'appname') ||
extractXmlValue(decoded, 'publisher') ||
(looksLikeListenItem(content) ? '听一听' : '音乐')
return {
type: 'share',
title,
des,
url,
appname,
typeVal: extractAppMsgType(content) || 'music'
}
}
function looksLikeListenItem(content: string): boolean {
return /tingListenItem|ListenItem|MMLISTEN_ITEM_TYPE/i.test(content)
}
/**
* 订阅号 / 可更新消息模板(`subscribeMessage` / `updatablemsg`)→ 只读 share。
* 模板头 `template_header` / `template_detail` 作标题与摘要。
*/
function parseSubscribeCard(content: string): ParsedContent {
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
const title =
extractXmlValue(decoded, 'template_header') ||
extractXmlValue(decoded, 'title') ||
extractXmlValue(decoded, 'templatetitle') ||
'订阅消息'
const des =
extractXmlValue(decoded, 'template_detail') ||
extractXmlValue(decoded, 'des') ||
extractXmlValue(decoded, 'digest') ||
undefined
const url =
decodeXmlUrl(
extractXmlValue(decoded, 'url') ||
extractXmlValue(decoded, 'jumpUrl') ||
extractXmlValue(decoded, 'weburl')
) || ''
return {
type: 'share',
title,
des,
url,
appname: extractXmlValue(decoded, 'appname') || '订阅消息',
typeVal: extractAppMsgType(content) || 'subscribe'
}
}
/**
* 客服 / 门店模板卡(`opencustomerservicemsg` / `wa_app_kefu_message` / `kefumenu`)
* → 只读 share;无 title 时退化为 system 文案,避免 unknown 黑块。
*/
function parseKefuCard(content: string): ParsedContent {
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
const title =
extractXmlValue(decoded, 'template_header') ||
extractXmlValue(decoded, 'title') ||
extractXmlValue(decoded, 'kf_title') ||
extractXmlValue(decoded, 'templatetitle')
const des =
extractXmlValue(decoded, 'template_detail') ||
extractXmlValue(decoded, 'kf_order_text') ||
extractXmlValue(decoded, 'des') ||
extractXmlValue(decoded, 'content')
const url =
decodeXmlUrl(
extractXmlValue(decoded, 'url') ||
extractXmlValue(decoded, 'jumpUrl') ||
extractXmlValue(decoded, 'weburl')
) || ''
const appname =
extractXmlValue(decoded, 'kf_user_name') ||
extractXmlValue(decoded, 'appname') ||
'客服消息'
if (!title && !des) {
return {
type: 'system',
content: normalizeSystemText(decoded.replace(/<[^>]+>/g, ' ')) || '客服消息',
raw: content
}
}
return {
type: 'share',
title: title || '客服消息',
des: des || undefined,
url: url || '',
appname,
typeVal: extractAppMsgType(content) || 'kefu'
} }
} }
@@ -1,731 +0,0 @@
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()
@@ -1,153 +0,0 @@
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()
-652
View File
@@ -1,652 +0,0 @@
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
@@ -1,266 +0,0 @@
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 }
+17 -16
View File
@@ -180,6 +180,8 @@ export interface GroupSnapshot {
roomId: string roomId: string
memberCount: number memberCount: number
groupName?: string groupName?: string
/** 只读 roominfo(contact.db / chatroom);字段可能随版本缺失。 */
roomInfo?: GroupRoomInfo
members: { members: {
wxid: string wxid: string
nickname: string nickname: string
@@ -190,6 +192,18 @@ export interface GroupSnapshot {
}[] }[]
} }
/** 群 roominfo 只读字段(列名兼容多版本;缺失则为 undefined)。 */
export interface GroupRoomInfo {
roomId: string
owner?: string
announcement?: string
announcementEditor?: string
maxMemberCount?: number
chatName?: string
openImAccountType?: string
isOpenIm?: boolean
}
export interface GroupMembershipSnapshot { export interface GroupMembershipSnapshot {
roomId: string roomId: string
memberIds: string[] memberIds: string[]
@@ -1007,7 +1021,7 @@ export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
avatar: member.m_nsHeadImgUrl || '' avatar: member.m_nsHeadImgUrl || ''
})) }))
return { roomId, memberCount: members.length, members } return { roomId, memberCount: members.length, members, roomInfo: wcdb4Client.getRoomInfo(roomId) || undefined }
} }
export async function getGroupSnapshotAsync(userMd5: string): Promise<GroupSnapshot | null> { export async function getGroupSnapshotAsync(userMd5: string): Promise<GroupSnapshot | null> {
@@ -1032,24 +1046,11 @@ export async function getGroupSnapshotAsync(userMd5: string): Promise<GroupSnaps
roomId, roomId,
groupName: session?.nickname || undefined, groupName: session?.nickname || undefined,
memberCount: members.length, memberCount: members.length,
members members,
roomInfo: (await wcdb4Client.getRoomInfoAsync(roomId)) || undefined
} }
} }
/**
* 把群会话 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
+166 -208
View File
@@ -2,17 +2,19 @@ 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
@@ -21,14 +23,9 @@ 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 = {
@@ -60,30 +57,19 @@ 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 {
onGroupExit?: GroupExitEventHandler actionGateway?: GroupExitActionGateway
} }
type GroupExitActionGateway = Pick<WechatActionGateway, 'execute'> &
Partial<
Pick<WechatActionGateway, 'registerMemberEvent' | 'registerMemberEvents' | 'clearMemberEvents'>
>
class GroupExitMonitorService { class GroupExitMonitorService {
private onGroupExit: GroupExitEventHandler | undefined private readonly actionGateway: GroupExitActionGateway
private active = false private active = false
private enabled = true private enabled = true
private nativeMonitorActive = false private nativeMonitorActive = false
@@ -101,6 +87,7 @@ 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>()
@@ -108,34 +95,24 @@ 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.onGroupExit = deps.onGroupExit this.actionGateway = deps.actionGateway || wechatActionGateway
}
/**
* 注入退群事件处理器(主进程在 AutomationService 初始化后调用)。
*
* 用 setter 而不是构造参数:AutomationService 依赖 `Wcdb4Client`,
* 要等数据库解锁后才存在,而本服务是模块级单例。
*/
setGroupExitHandler(handler: GroupExitEventHandler | undefined): void {
this.onGroupExit = handler
} }
getState(): GroupExitMonitorState { getState(): GroupExitMonitorState {
this.ensureLoaded() this.ensureLoaded()
return { return {
// this.events 恒为「新在前」,所以这里取到的就是**最新**的 MAX_EVENTS 条。 events: [...this.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
@@ -155,11 +132,12 @@ 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()
} }
@@ -230,13 +208,10 @@ 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
@@ -250,6 +225,10 @@ 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()
@@ -282,41 +261,22 @@ 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 roomId = String(query.roomId || '').trim() const result = validateGroupExitNotificationTemplate(value)
const since = Number(query.sinceMs) if (!result.valid || !result.template) {
const until = Number(query.untilMs) throw new Error(result.error || '退群监测模板无效')
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)
} }
return result this.notificationTemplate = result.template
this.save()
this.broadcast()
return this.getState()
} }
clearEvents(): GroupExitMonitorState { clearEvents(): GroupExitMonitorState {
this.ensureLoaded() this.ensureLoaded()
this.events = [] this.events = []
// 磁盘上的 append-only 历史也要清掉,否则下次启动又读回来了。 this.actionGateway.clearMemberEvents?.()
this.rewriteEventsToDisk([])
this.lastReadAt = Date.now() this.lastReadAt = Date.now()
this.save() this.save()
this.broadcast() this.broadcast()
@@ -439,7 +399,7 @@ class GroupExitMonitorService {
groups: GroupMembershipRecord[], groups: GroupMembershipRecord[],
scopeGeneration: number scopeGeneration: number
): Promise<number> { ): Promise<number> {
const exits: GroupExitMonitorEvent[] = [] const notifications: Array<{ group: GroupSnapshotRecord; event: 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) {
@@ -470,9 +430,10 @@ 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) exits.push(event) if (event && this.notificationRoomIds.has(next.roomId)) {
notifications.push({ group: next, event })
}
} }
} }
@@ -487,11 +448,12 @@ class GroupExitMonitorService {
return changedGroups return changedGroups
} }
this.lastCheckedAt = Date.now() this.lastCheckedAt = Date.now()
// 先把事件和新基线作为同一检查点落盘,再交给自动化。 // 先把事件和新基线作为同一检查点落盘,再执行可失败的通知动作。
// 「退群事实已记录」与「通知发送成功」是两件独立的事。
this.save() this.save()
this.broadcast() this.broadcast()
for (const event of exits) this.emitGroupExit(event) if (notifications.length) {
await Promise.all(notifications.map(({ group, event }) => this.notifyGroup(group, event)))
}
return changedGroups return changedGroups
} }
@@ -627,145 +589,129 @@ class GroupExitMonitorService {
currentCount, currentCount,
delta: currentCount - previousCount, delta: currentCount - previousCount,
message, message,
detectedAt detectedAt,
notificationStatus: 'not_requested'
} }
// 内存按时间倒序(新事件在前);磁盘**只追加这一条**,不重写历史。 this.actionGateway.registerMemberEvent?.(event)
// 这里不再有 `.slice(0, MAX_EVENTS)` —— 事件是永久保留的。 this.events = [event, ...this.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}`
* 1. **绝不 await**:快照扫描与成员 diff 不能被微信发送耗时拖住; ): Promise<void> {
* 2. **异常必须被捕获**:`void promise` 漏掉 `.catch` 会变成 unhandled rejection; event.notificationStatus = 'pending'
* 3. **不影响退群事实**:事件与快照在同一检查点已经先落盘,通知失败不回滚记录。 event.notification = { status: 'pending' }
*/ this.save()
private emitGroupExit(event: GroupExitMonitorEvent): void { this.broadcast()
const handler = this.onGroupExit
if (!handler) return
try { try {
void Promise.resolve( const result = await this.actionGateway.execute({
handler(toGroupMemberExitedEvent(event)) idempotencyKey,
).catch((error) => { origin: 'member_monitor',
console.warn( purpose: 'member_left_notification',
`[GroupMonitor] 退群通知处理失败 eventId=${event.id}: ${ triggerType: 'automation',
error instanceof Error ? error.message : String(error) 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(
`[GroupMonitor] 群聊通知未发送 roomId=${group.roomId} status=${result.status} code=${result.errorCode || ''}`
)
}
} catch (error) { } catch (error) {
// handler 同步抛出的情况(`handleGroupExit` 本身不抛,这里是防御性兜底)。 event.notificationStatus = 'failed'
event.notification = {
status: 'failed',
errorCode: 'UNKNOWN',
reason: error instanceof Error ? error.message : String(error)
}
console.warn( console.warn(
`[GroupMonitor] 退群通知处理异常 eventId=${event.id}: ${ `[GroupMonitor] 群聊通知异常 roomId=${group.roomId}:`,
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
// 事件从 append-only 文件读;状态文件不再承载它们。 this.events = normalizeEvents(stored.events)
const fromDisk = this.readEventsFromDisk() this.actionGateway.registerMemberEvents?.(this.events)
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()
} }
} }
@@ -779,11 +725,12 @@ class GroupExitMonitorService {
{ {
accountRoot: this.accountRoot, accountRoot: this.accountRoot,
enabled: this.enabled, enabled: this.enabled,
// 事件**不在这里**:它们走 append-only 的 JSONL(见 `eventsPath()`)。 events: this.events,
// 放进状态文件会让每新增一条事件都把整部历史重写一遍。
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 }
@@ -980,28 +927,39 @@ 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) }
: {})
}) })
// 不再按 MAX_EVENTS 截断:事件是永久保留的,截在这里等于每次启动都丢掉历史。 if (normalized.length >= MAX_EVENTS) break
// 历史上的 `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'
* 这个不变量有三个依赖方:`recordExit` 的 `[event, ...this.events]` 写入方向、 ? status
* `listEvents()` 的 `.reverse()`(它假定内存是倒序,反转后得到升序)、 : 'not_requested'
* 以及 `getState()` 的 `slice(0, MAX_EVENTS)`(要求取到的是**最新**的一批)。 }
*
* 必须显式重建它:磁盘是 append-only,行序由「迁移写入的历史 + 之后追加的新事件」 function normalizeNotification(value: object): GroupExitNotificationState {
* 决定,两段方向相反,整体不保证时间有序。直接信任文件行序会让列表顶部恒为最旧的 const input = value as Partial<GroupExitNotificationState>
* 一批,并让 `slice(0, MAX_EVENTS)` 恰好把最新的事件截掉。 return {
*/ status: normalizeNotificationStatus(input.status),
function sortEventsNewestFirst(events: GroupExitMonitorEvent[]): GroupExitMonitorEvent[] { ...(input.actionId ? { actionId: String(input.actionId) } : {}),
return [...events].sort((left, right) => right.detectedAt - left.detectedAt) ...(input.decision === 'allow' || input.decision === 'block'
? { 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 {
-208
View File
@@ -1,208 +0,0 @@
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'
}
@@ -1,116 +0,0 @@
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
}
}
@@ -1,91 +0,0 @@
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 }
+30 -2
View File
@@ -320,11 +320,23 @@ export class LocalQueryApiService {
| ((conversationId: string, messageId: string) => { state: string; text: string } | undefined) | ((conversationId: string, messageId: string) => { state: string; text: string } | undefined)
| undefined | undefined
/** 收藏关键词检索(favorite.db,只读)。 */
private favoritesSearch:
| ((query: string, limit: number) => Promise<Array<{ text: string; timestamp?: number }>>)
| undefined
constructor( constructor(
private readonly knowledge?: KnowledgeSearchService, private readonly knowledge?: KnowledgeSearchService,
private readonly nowProvider: () => Date = () => new Date() private readonly nowProvider: () => Date = () => new Date()
) {} ) {}
/** 注入收藏检索(`FavoritesService.searchHits`),供 `search_messages` 合并只读命中。 */
setFavoritesSearchProvider(
provider: (query: string, limit: number) => Promise<Array<{ text: string; timestamp?: number }>>
): void {
this.favoritesSearch = provider
}
/** /**
* 注入图片文字索引覆盖度提供者。 * 注入图片文字索引覆盖度提供者。
* *
@@ -507,14 +519,30 @@ export class LocalQueryApiService {
enrichmentMs, enrichmentMs,
...(knowledgeTiming ? { knowledge: knowledgeTiming } : {}) ...(knowledgeTiming ? { knowledge: knowledgeTiming } : {})
} }
let favoriteEvidence: QueryEvidenceItem[] = []
if (this.favoritesSearch && !targetView) {
try {
const hits = await this.favoritesSearch(request.query, Math.min(10, limit))
favoriteEvidence = hits.map((hit, index) => ({
messageRef: `fav-${index}`,
conversationName: '收藏',
timestamp: hit.timestamp ?? 0,
sender: '收藏',
sourceKind: 'other' as QueryMessageType,
text: hit.text
}))
} catch {
favoriteEvidence = []
}
}
return { return {
status: 'completed' as const, status: 'completed' as const,
...(targetView ? { target: targetView } : {}), ...(targetView ? { target: targetView } : {}),
resolvedTimeRange: range, resolvedTimeRange: range,
coverage: { state: this.searchCoverage(found, requestedEnd) }, coverage: { state: this.searchCoverage(found, requestedEnd) },
probeCount: probes.length, probeCount: probes.length,
evidenceCount: found.evidence.size, evidenceCount: found.evidence.size + favoriteEvidence.length,
evidence: Array.from(found.evidence.values()).slice(0, limit), evidence: [...Array.from(found.evidence.values()).slice(0, limit), ...favoriteEvidence],
scope: corpus.scope, scope: corpus.scope,
indexLatestAt: found.indexLatestAt, indexLatestAt: found.indexLatestAt,
sourceLatestAt: found.sourceLatestAt, sourceLatestAt: found.sourceLatestAt,
@@ -1,101 +0,0 @@
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
)
}
@@ -1,724 +0,0 @@
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()
@@ -1,441 +0,0 @@
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,28 +7,15 @@ 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 the macOS native runtime, the * features can consume without knowing about OneBot, Hook or platform details.
* 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( constructor(private readonly sender: Pick<PersonalWechatSendService, 'getStatus'>) {}
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)
} }
@@ -62,16 +49,6 @@ 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'
@@ -83,7 +60,6 @@ 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 &&
@@ -0,0 +1,603 @@
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,8 +7,11 @@ 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 { buildPersonalWechatRuntimeEnvironment } from './personal-wechat-send-service' import {
import { macWechatRuntimeManager } from './mac-wechat-runtime-manager' buildPersonalWechatRuntimeEnvironment,
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)
@@ -34,8 +37,8 @@ type CommandRunner = (
interface PersonalWechatVoiceEnvironmentServiceOptions { interface PersonalWechatVoiceEnvironmentServiceOptions {
platform?: NodeJS.Platform platform?: NodeJS.Platform
architecture?: string architecture?: string
isRuntimePresent?: () => boolean findRuntime?: () => RuntimeLayout | null
buildEnvironment?: () => NodeJS.ProcessEnv buildEnvironment?: (runtimeRoot?: string) => NodeJS.ProcessEnv
runCommand?: CommandRunner runCommand?: CommandRunner
now?: () => Date now?: () => Date
} }
@@ -97,26 +100,18 @@ 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 isRuntimePresent: () => boolean private readonly findRuntime: () => RuntimeLayout | null
private readonly buildEnvironment: () => NodeJS.ProcessEnv private readonly buildEnvironment: (runtimeRoot?: string) => 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.isRuntimePresent = options.isRuntimePresent || defaultIsRuntimePresent this.findRuntime = options.findRuntime || findPersonalWechatRuntime
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())
@@ -128,8 +123,8 @@ export class PersonalWechatVoiceEnvironmentService {
} }
logEnvironmentLine('Checking voice encoding environment') logEnvironmentLine('Checking voice encoding environment')
const runtimeReady = this.isRuntimePresent() const runtime = this.findRuntime()
const environment = this.buildEnvironment() const environment = this.buildEnvironment(runtime?.root)
const python = blankComponent() const python = blankComponent()
const pilk = blankComponent() const pilk = blankComponent()
const ffmpeg = blankComponent() const ffmpeg = blankComponent()
@@ -202,25 +197,26 @@ export class PersonalWechatVoiceEnvironmentService {
logEnvironmentWarning('ffmpeg: unavailable', { error: ffmpeg.error }) logEnvironmentWarning('ffmpeg: unavailable', { error: ffmpeg.error })
} }
const ready = Boolean(runtimeReady && python.ready && pilk.ready && ffmpeg.ready) const ready = Boolean(runtime && 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, runtimeReady: Boolean(runtime),
...(runtime ? { runtimeRoot: runtime.root } : {}),
python, python,
pilk, pilk,
ffmpeg, ffmpeg,
encoder: pilk.ready encoder: pilk.ready
? 'pilk' ? 'pilk'
: runtimeReady && python.ready && ffmpeg.ready : runtime && python.ready && ffmpeg.ready
? 'silk' ? 'go-silk'
: 'unavailable', : 'unavailable',
message: ready message: ready
? '语音编码环境正常,可以使用 pilk 编码' ? '语音编码环境正常,可以使用 pilk 编码'
: runtimeReady : runtime
? '语音编码环境不完整,语音将回退到内置 SILK 编码' ? '语音编码环境不完整,OneBot 可能回退到 go-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',
@@ -249,7 +245,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(), env: this.buildEnvironment(before.runtimeRoot || undefined),
timeout: INSTALL_TIMEOUT_MS timeout: INSTALL_TIMEOUT_MS
} }
) )
@@ -27,7 +27,8 @@ 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
@@ -249,20 +250,16 @@ export class ScheduledReportApiService {
return result.data return result.data
} }
/** async retrySend(executionId: string): Promise<ScheduledReportExecution> {
* 「重新发送」不再支持。 const retry = this.deps.service.retryScheduledReportSend
* if (!retry) {
* 日报图片归日报历史所有,发送目标由规则配置决定,「把某次执行记录里那张 PNG throw new ScheduledReportApiError(501, 'not_supported', '当前运行时不支持重新发送日报')
* 就地再发一遍」这个语义已经不存在 —— 与其在此伪造一个只对部分规则有效的重发 }
* 语义,不如**明确返回 501**。 const result = await retry.call(this.deps.service, executionId)
* 用户的替代路径:在日报历史里手动转发,或对该规则点「立即执行」重跑一次。 if (!result.data) {
*/ throw new ScheduledReportApiError(404, 'not_found', result.error || '未找到定时日报执行记录')
async retrySend(_executionId: string): Promise<ScheduledReportExecution> { }
throw new ScheduledReportApiError( return result.data
501,
'not_supported',
'当前运行时不支持重新发送日报;请在 TraceMemo 的自动化页面使用「立即执行」,或从日报历史手动转发。'
)
} }
async executions(taskId: string): Promise<ScheduledReportApiExecution[]> { async executions(taskId: string): Promise<ScheduledReportApiExecution[]> {
@@ -1,207 +0,0 @@
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
@@ -1,81 +0,0 @@
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 }
+6 -12
View File
@@ -39,8 +39,9 @@ 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 时的回退开关,
@@ -129,8 +130,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
} }
@@ -149,10 +150,7 @@ 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 stored = fs.readJsonSync(SETTINGS_FILE) as Partial<AppSettings> & { const raw = 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(
@@ -178,7 +176,7 @@ export function loadSettings(): AppSettings {
} }
// 防撤回已下线(设置入口已隐藏):历史版本可能把它持久化为 true。 // 防撤回已下线(设置入口已隐藏):历史版本可能把它持久化为 true。
// 这里强制收敛为 false 并回写磁盘,确保旧的撤回监听与撤回日志不会继续运行。 // 这里强制收敛为 false 并回写磁盘,确保旧的撤回监听与撤回日志不会继续运行。
if (cache.recallProtectionEnabled || retiredKeepProcess !== undefined) { if (cache.recallProtectionEnabled) {
cache.recallProtectionEnabled = false cache.recallProtectionEnabled = false
saveSettings(cache) saveSettings(cache)
} }
@@ -194,11 +192,7 @@ export function loadSettings(): AppSettings {
export function saveSettings(next: AppSettings): AppSettings { export function saveSettings(next: AppSettings): AppSettings {
// 防撤回已下线:所有写入路径(含 settings:set 补丁)统一收敛为 false, // 防撤回已下线:所有写入路径(含 settings:set 补丁)统一收敛为 false,
// 避免遗留入口或旧版本把它重新打开。 // 避免遗留入口或旧版本把它重新打开。
const sanitized = { ...next } as AppSettings & { cache = { ...next, recallProtectionEnabled: false }
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 })
+80 -85
View File
@@ -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,40 +22,17 @@ 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([ const AUTOMATION_PURPOSE_ALLOWLIST = new Set(['scheduled_report', 'member_left_notification'])
'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>
@@ -66,6 +43,10 @@ interface LoadedAuditState {
records: WechatActionAuditRecord[] records: WechatActionAuditRecord[]
} }
export interface WechatActionPolicyContext {
memberEvent?: WechatActionMemberEventReference
}
const defaultDependencies = (): Required< const defaultDependencies = (): Required<
Pick< Pick<
WechatActionGatewayDependencies, WechatActionGatewayDependencies,
@@ -95,6 +76,7 @@ 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()
@@ -104,33 +86,20 @@ export class WechatActionGateway {
this.deps = { ...defaultDependencies(), ...deps } this.deps = { ...defaultDependencies(), ...deps }
} }
/** /** 记录退群事件,发送通知前可确认事件所属群聊。 */
* 用户确认后的日报发送序列。两步都进入 automation 发送队列,从而复用既有 3 秒间隔; registerMemberEvent(event: WechatActionMemberEventReference): void {
* 后置词 action 只在图片明确 sent 后创建,图片失败/blocked 时严格短路。 const id = String(event?.id || '').trim()
*/ const roomId = String(event?.roomId || '').trim()
async executeReportImageSequence( if (!id || !roomId) return
request: ReportImageSequenceRequest this.memberEvents.set(id, { id, roomId })
): 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 }
const postfixText = String(request.postfixText || '').trim() registerMemberEvents(events: WechatActionMemberEventReference[]): void {
if (!postfixText) return { image } for (const event of events) this.registerMemberEvent(event)
}
const postfix = await this.execute({ clearMemberEvents(): void {
origin: 'user_manual', this.memberEvents.clear()
purpose: 'manual_report_postfix',
triggerType: 'automation',
recipient: request.recipient,
content: { type: 'text', text: postfixText }
})
return { image, postfix }
} }
listAuditRecords(): WechatActionAuditRecord[] { listAuditRecords(): WechatActionAuditRecord[] {
@@ -176,7 +145,8 @@ export class WechatActionGateway {
) )
} }
const policy = evaluateWechatActionPolicy(request) const eventContext = await this.resolveEventContext(request)
const policy = evaluateWechatActionPolicy(request, eventContext)
if (policy.decision !== 'allow') { if (policy.decision !== 'allow') {
return this.finishBlocked( return this.finishBlocked(
actionId, actionId,
@@ -270,6 +240,28 @@ 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)
@@ -288,6 +280,9 @@ 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}`
} }
@@ -434,7 +429,10 @@ export class WechatActionGateway {
} }
} }
export function evaluateWechatActionPolicy(request: WechatActionRequest): PolicyDecision { export function evaluateWechatActionPolicy(
request: WechatActionRequest,
context: WechatActionPolicyContext = {}
): PolicyDecision {
if (!request || typeof request !== 'object') { if (!request || typeof request !== 'object') {
return { return {
decision: 'block', decision: 'block',
@@ -465,6 +463,29 @@ export function evaluateWechatActionPolicy(request: WechatActionRequest): Policy
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' }
} }
@@ -579,32 +600,6 @@ 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'> {
+50 -1
View File
@@ -126,5 +126,54 @@ export class AudioDecoderRegistry {
} }
export function createDefaultAudioDecoderRegistry(): AudioDecoderRegistry { export function createDefaultAudioDecoderRegistry(): AudioDecoderRegistry {
return new AudioDecoderRegistry().register(new SilkAudioDecoder()) return new AudioDecoderRegistry()
.register(new SilkAudioDecoder())
.register(new AmrAudioDecoder())
}
/**
* V3 AMR:外部 `ffmpeg` 解码为 PCM(MultiMediaDyn `CAMRDecoder` 未导出)。
*/
export class AmrAudioDecoder implements VoiceAudioDecoder {
readonly codec = 'amr'
async decode(source: EncodedVoiceSource): Promise<DecodedVoiceAudio> {
const { spawn } = await import('child_process')
const { tmpdir } = await import('os')
const { promises: fs } = await import('fs')
const { randomBytes } = await import('crypto')
const stamp = randomBytes(8).toString('hex')
const inPath = join(tmpdir(), `tracememo-amr-in-${stamp}.amr`)
const outPath = join(tmpdir(), `tracememo-amr-out-${stamp}.wav`)
await fs.writeFile(inPath, source.data)
try {
await new Promise<void>((resolve, reject) => {
const child = spawn(
'ffmpeg',
['-y', '-i', inPath, '-f', 'wav', '-acodec', 'pcm_s16le', '-ar', '8000', '-ac', '1', outPath],
{ stdio: ['ignore', 'ignore', 'pipe'] }
)
let err = ''
child.stderr.on('data', (chunk) => {
err += String(chunk)
})
child.on('error', reject)
child.on('close', (code) => {
if (code === 0) resolve()
else reject(new Error(`ffmpeg amr decode failed: ${err.slice(-200)}`))
})
})
const wav = await fs.readFile(outPath)
const pcm = wav.length > 44 && wav.toString('ascii', 0, 4) === 'RIFF' ? wav.subarray(44) : wav
return {
pcm,
sampleRate: 8000,
channels: 1,
sourceHash: source.sourceHash
}
} finally {
await fs.rm(inPath, { force: true })
await fs.rm(outPath, { force: true })
}
}
} }
+3 -3
View File
@@ -19,9 +19,9 @@ export interface VoiceSilkMetadata {
} }
/** /**
* Validate the exact PCM contract consumed by the bundled SILK encoder. * Validate the exact PCM contract consumed by the bundled OneBot encoder.
* The encoder emits a header-only payload for sub-frame input, which would * The Go Silk encoder emits a header-only payload for sub-frame input, which
* produce an unplayable WeChat voice. * is accepted by the old path but produces an unplayable WeChat voice.
*/ */
export function validateVoicePcm( export function validateVoicePcm(
pcm: Uint8Array, pcm: Uint8Array,
+93
View File
@@ -1,4 +1,6 @@
import { createHash } from 'crypto' import { createHash } from 'crypto'
import { promises as fs } from 'fs'
import { join } from 'path'
import { Wcdb4Client } from './wcdb4-client' import { Wcdb4Client } from './wcdb4-client'
import { import {
createDefaultAudioDecoderRegistry, createDefaultAudioDecoderRegistry,
@@ -211,6 +213,17 @@ export class VoiceService {
svrId || 0 svrId || 0
) )
if (!voiceResult.success || !voiceResult.hex) { if (!voiceResult.success || !voiceResult.hex) {
const disk = await this.resolveFromDisk(sessionId, localId, createTime, svrId)
if (disk?.length) {
return {
success: true,
source: {
data: disk,
codec: 'silk',
sourceHash: createHash('sha256').update(disk).digest('hex')
}
}
}
return { success: false, error: voiceResult.error || '获取语音数据失败' } return { success: false, error: voiceResult.error || '获取语音数据失败' }
} }
@@ -239,6 +252,38 @@ export class VoiceService {
return candidates return candidates
} }
/**
* V2:旁路 `wcdb_get_voice_data`,在账号目录下找语音文件
* (`GetMsgAudioPath` 同域:Message / MsgAndFiles / VoiceTemp)。
*/
private async resolveFromDisk(
sessionId: string,
localId: number,
createTime: number,
svrId?: string | number
): Promise<Buffer | null> {
const accountRoot = this.wcdb4Client.getAccountRoot?.()
if (!accountRoot) return null
const needles = [
String(localId),
String(createTime),
svrId !== undefined && svrId !== null && String(svrId) !== '0' ? String(svrId) : '',
sessionId.replace(/@chatroom$/, '')
].filter(Boolean)
const files = await listVoiceDiskCandidates(accountRoot)
for (const file of files) {
const name = file.toLowerCase()
if (!needles.some((needle) => needle && name.includes(needle.toLowerCase()))) continue
try {
const data = await fs.readFile(file)
if (data.length) return data
} catch {
// try next candidate
}
}
return null
}
private decodeVoiceBlob(hex: string): Buffer | null { private decodeVoiceBlob(hex: string): Buffer | null {
try { try {
const hexClean = hex.replace(/\s+/g, '') const hexClean = hex.replace(/\s+/g, '')
@@ -274,3 +319,51 @@ export class VoiceService {
return Buffer.concat([header, pcmData]) return Buffer.concat([header, pcmData])
} }
} }
const VOICE_DISK_EXTS = new Set(['.aud', '.silk', '.amr'])
const VOICE_DISK_DIRS = [
'msg/Message',
'msg/MsgAndFiles',
'msg/VoiceTemp',
'msg/History',
'Message',
'MsgAndFiles',
'VoiceTemp',
// 真机布局(2026-09-24):cache/YYYY-MM/Message/<md5>/VoiceTemp/
'cache'
]
/** 语音磁盘候选路径(扩展名/目录与 GetMsgAudioPath / VoiceTemp 对齐)。 */
export async function listVoiceDiskCandidates(accountRoot: string): Promise<string[]> {
const out: string[] = []
const walk = async (dir: string, depth: number): Promise<void> => {
if (out.length > 200 || depth > 6) return
let names: string[]
try {
names = await fs.readdir(dir)
} catch {
return
}
for (const name of names) {
const full = join(dir, name)
let stat: Awaited<ReturnType<typeof fs.stat>>
try {
stat = await fs.stat(full)
} catch {
continue
}
if (stat.isDirectory()) {
await walk(full, depth + 1)
continue
}
const ext = name.slice(name.lastIndexOf('.')).toLowerCase()
if (!VOICE_DISK_EXTS.has(ext)) continue
out.push(full)
}
}
for (const rel of VOICE_DISK_DIRS) {
await walk(join(accountRoot, rel), 0)
if (out.length > 200) break
}
return out
}
+111 -76
View File
@@ -5,6 +5,51 @@ import crypto from 'crypto'
import { createRequire } from 'module' import { createRequire } from 'module'
import { createConnection, Socket } from 'net' import { createConnection, Socket } from 'net'
import { getResourceRoots } from './resource-paths' import { getResourceRoots } from './resource-paths'
export type RoomInfoRow = {
roomId: string
owner?: string
announcement?: string
announcementEditor?: string
maxMemberCount?: number
chatName?: string
openImAccountType?: string
isOpenIm?: boolean
}
function pickString(row: Record<string, unknown>, keys: string[]): string | undefined {
for (const key of keys) {
const value = row[key]
if (typeof value === 'string' && value.trim()) return value.trim()
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
}
return undefined
}
export function normalizeRoomInfoRow(chatroomId: string, row: Record<string, unknown>): RoomInfoRow {
const openImAccountType =
pickString(row, ['openim_acct_type', 'openimAcctType', 'openim_type']) || undefined
const maxRaw = pickString(row, ['max_member_count', 'maxMemberCount', 'member_max'])
return {
roomId: chatroomId,
owner: pickString(row, ['roomowner', 'room_owner', 'owner', 'owner_id']),
announcement: pickString(row, [
'textannouncement',
'announcement',
'announcement_',
'chatroomannouncement'
]),
announcementEditor: pickString(row, [
'announcement_editor',
'announcement_editor_',
'announcementeditor'
]),
maxMemberCount: maxRaw && Number.isFinite(Number(maxRaw)) ? Number(maxRaw) : undefined,
chatName: pickString(row, ['chatroomnick', 'chat_name', 'chatroom_name', 'displayname']),
openImAccountType,
isOpenIm: openImAccountType ? openImAccountType !== '0' && openImAccountType !== '' : undefined
}
}
import { wcdbDebugLog } from './wcdb-debug' import { wcdbDebugLog } from './wcdb-debug'
import type { ImageMessageCountProbe } from '../shared/image-text-index' import type { ImageMessageCountProbe } from '../shared/image-text-index'
import { imageTextWindowToSeconds } from '../shared/image-text-index' import { imageTextWindowToSeconds } from '../shared/image-text-index'
@@ -384,79 +429,6 @@ 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()
@@ -568,7 +540,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: ((event: Wcdb4MonitorEvent) => void) | null = null private monitorCallback: ((type: string, json: string) => 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 = ''
@@ -846,7 +818,7 @@ export class Wcdb4Client {
}) })
} }
async startMonitor(callback: (event: Wcdb4MonitorEvent) => void): Promise<boolean> { async startMonitor(callback: (type: string, json: string) => 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
} }
@@ -984,7 +956,13 @@ 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 {
@@ -2745,6 +2723,63 @@ export class Wcdb4Client {
return this.accountRoot return this.accountRoot
} }
/**
* 只读 roominfo(contact.db / chatroom 或兼容列名)。
*/
getRoomInfo(chatroomId: string): RoomInfoRow | null {
if (!this.wcdbExecQuery || !chatroomId.endsWith('@chatroom')) return null
const escaped = chatroomId.replace(/'/g, "''")
const sql = `SELECT * FROM chatroom WHERE chatroomname = '${escaped}' LIMIT 1`
try {
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
this.wcdbExecQuery!(handle, 'contact', '', sql, outJson)
)
return rows?.[0] ? normalizeRoomInfoRow(chatroomId, rows[0]) : null
} catch {
return null
}
}
async getRoomInfoAsync(chatroomId: string): Promise<RoomInfoRow | null> {
if (!this.wcdbExecQuery || !chatroomId.endsWith('@chatroom')) return null
const escaped = chatroomId.replace(/'/g, "''")
const sql = `SELECT * FROM chatroom WHERE chatroomname = '${escaped}' LIMIT 1`
try {
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
this.wcdbExecQuery as unknown as KoffiAsyncFunction,
'contact',
'',
sql
)
return rows?.[0] ? normalizeRoomInfoRow(chatroomId, rows[0]) : null
} catch (error) {
console.warn('[WCDB4] getRoomInfoAsync failed:', error)
return null
}
}
/** 只读列收藏(favorite.db / fav_db_item),供导出与搜索。 */
async listFavoriteItems(limit = 200): Promise<Record<string, unknown>[]> {
if (!this.wcdbExecQuery) return []
const sql = `SELECT local_id, server_id, type, update_time, content, fromusr, realchatname FROM fav_db_item ORDER BY update_time DESC LIMIT ${Math.max(
1,
Math.min(1000, limit)
)}`
try {
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
this.wcdbExecQuery as unknown as KoffiAsyncFunction,
'favorite',
path.join(this.accountRoot, 'db_storage/favorite/favorite.db'),
sql
)
return Array.isArray(rows) ? rows : []
} catch (error) {
console.warn('[WCDB4] listFavoriteItems failed:', error)
return []
}
}
getKey(): string { getKey(): string {
return this.key return this.key
} }
+89 -53
View File
@@ -82,28 +82,26 @@ 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 { GroupExitMonitorEvent, GroupExitMonitorState } from '../shared/group-exit-monitor' import type { 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,
@@ -157,6 +155,37 @@ import type {
TextToSpeechSettingsResult TextToSpeechSettingsResult
} from '../shared/text-to-speech' } from '../shared/text-to-speech'
export type TransferPaymentInfo = {
paySubtype?: string
amountText?: string
transcationId?: string
transferId?: string
invalidTime?: string
beginTransferTime?: string
effectiveDate?: string
payMemo?: string
receiverUsername?: string
payerUsername?: string
transferStatus?: string
transferStatusText?: string
}
export type RedPacketPaymentInfo = {
templateId?: string
receiveTitle?: string
sendTitle?: string
sceneText?: string
senderDes?: string
receiverDes?: string
iconUrl?: string
nativeUrl?: string
sendId?: string
hbType?: string
hbStatus?: string
receiveStatus?: string
redPacketStatusText?: string
}
export type ParsedContent = export type ParsedContent =
| { type: 'text'; content: string } | { type: 'text'; content: string }
| { type: 'voice'; duration?: number } | { type: 'voice'; duration?: number }
@@ -169,6 +198,7 @@ export type ParsedContent =
url: string url: string
appname?: string appname?: string
typeVal?: string typeVal?: string
transfer?: TransferPaymentInfo
} }
| { | {
type: 'miniProgram' type: 'miniProgram'
@@ -180,7 +210,13 @@ export type ParsedContent =
thumbDatName?: string thumbDatName?: string
thumbDataUrl?: string thumbDataUrl?: string
} }
| { type: 'redPacket'; title: string; description?: string; url?: string } | {
type: 'redPacket'
title: string
description?: string
url?: string
pay?: RedPacketPaymentInfo
}
| { type: 'voip'; duration?: number; status: string; roomType?: number } | { type: 'voip'; duration?: number; status: string; roomType?: number }
| { type: 'image'; md5?: string; datName?: string; aeskey?: string; encrypVer?: number } | { type: 'image'; md5?: string; datName?: string; aeskey?: string; encrypVer?: number }
| { | {
@@ -295,37 +331,18 @@ 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: (
setGroupExitMonitorGroups: (roomIds: string[]) => Promise<GroupExitMonitorState> roomIds: string[],
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>
@@ -542,7 +559,6 @@ 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
} }
@@ -583,7 +599,6 @@ 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
} }
@@ -607,7 +622,6 @@ 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
}> }>
@@ -721,11 +735,21 @@ 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
@@ -733,23 +757,35 @@ declare global {
sendPersonalWechatMessage: ( sendPersonalWechatMessage: (
request: PersonalWechatSendRequest request: PersonalWechatSendRequest
) => Promise<PersonalWechatSendResult> ) => Promise<PersonalWechatSendResult>
/** 定时日报:「立即执行」——与 scheduler 走同一条执行链路(manual trigger)。 */ listScheduledReports: () => Promise<ScheduledReportTask[]>
runScheduledReportRule: ( listScheduledReportExecutions: (taskId?: string) => Promise<ScheduledReportExecution[]>
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: (
ruleId: string taskId: string
) => Promise<ScheduledReportResult<ScheduledReportNotification>> ) => Promise<ScheduledReportResult<ScheduledReportExecution>>
getPersonalWechatVoiceDiagnostic: () => Promise<PersonalWechatVoiceDiagnostic | null> getPersonalWechatVoiceDiagnostic: () => Promise<PersonalWechatVoiceDiagnostic | null>
getAgentHubStatus: () => Promise<AgentHubStatus> getAgentHubStatus: () => Promise<AgentHubStatus>
getAgentHubLogs: () => Promise<AgentHubLogEntry[]> getAgentHubLogs: () => Promise<AgentHubLogEntry[]>
+78 -72
View File
@@ -52,28 +52,26 @@ 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 { GroupExitMonitorEvent, GroupExitMonitorState } from '../shared/group-exit-monitor' import type { 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'
@@ -169,59 +167,29 @@ 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: (
setGroupExitMonitorGroups: (roomIds: string[]): Promise<GroupExitMonitorState> => roomIds: string[],
ipcRenderer.invoke('group-exit-monitor:setGroups', roomIds), notificationRoomIds?: string[]
): 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)
@@ -551,6 +519,10 @@ 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:
@@ -562,6 +534,26 @@ 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: (
@@ -572,30 +564,44 @@ 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[]> =>
* 定时日报(Automation 的 `scheduled_report` 规则类型)。 ipcRenderer.invoke('scheduled-report:list'),
* listScheduledReportExecutions: (taskId?: string): Promise<ScheduledReportExecution[]> =>
* 规则 CRUD 复用上面的 `automation:*` 通道;这里只暴露三块专属能力: ipcRenderer.invoke('scheduled-report:listExecutions', taskId),
* 立即执行、微信异常通知、旧执行记录只读存档。
*/
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('automation:getScheduledReportNotificationSettings'), ipcRenderer.invoke('scheduled-report:getNotificationSettings'),
getScheduledReportNotificationCapability: (): Promise<ScheduledReportNotificationCapability> =>
ipcRenderer.invoke('automation:getScheduledReportNotificationCapability'),
setScheduledReportNotificationEnabled: ( setScheduledReportNotificationEnabled: (
enabled: boolean enabled: boolean
): Promise<ScheduledReportNotificationSettingsResult> => ): Promise<ScheduledReportNotificationSettingsResult> =>
ipcRenderer.invoke('automation:setScheduledReportNotificationEnabled', enabled), ipcRenderer.invoke('scheduled-report:setNotificationEnabled', 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: (
ruleId: string taskId: string
): Promise<ScheduledReportResult<ScheduledReportNotification>> => ): Promise<ScheduledReportResult<ScheduledReportExecution>> =>
ipcRenderer.invoke('automation:testScheduledReportErrorNotification', ruleId), ipcRenderer.invoke('scheduled-report:testErrorNotification', taskId),
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'),
+32 -84
View File
@@ -18,6 +18,7 @@ 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'
@@ -41,11 +42,10 @@ 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 } from './utils/runtime-environment' import { runtimePlatform, supportsPersonalWechatSend } from './utils/runtime-environment'
import { Button, useToast } from './components/ui' import { useToast } from './components/ui'
import { AppUpdatePrompt } from './features/app-update/AppUpdatePrompt' import { AppUpdatePrompt } from './features/app-update/AppUpdatePrompt'
import { GroupExitMonitorWorkspace, type GroupExitMonitorOpenViewRequest } from './features/group-exit-monitor/GroupExitMonitorWorkspace' import { GroupExitMonitorWorkspace } 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,17 +257,6 @@ 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)。
@@ -279,7 +268,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' | 'market'>('today') const [reportSection, setReportSection] = useState<'today' | 'scheduled' | '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)
@@ -1650,49 +1639,10 @@ 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')
@@ -1957,7 +1907,6 @@ 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}
@@ -1977,6 +1926,22 @@ 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"
@@ -1987,26 +1952,23 @@ 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
@@ -2122,20 +2084,6 @@ 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':
+2 -20
View File
@@ -7,8 +7,6 @@ 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
@@ -24,8 +22,6 @@ 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 取或:任一存在就说明"这是一次跳转"。 */
@@ -46,7 +42,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
onCreateGroupReport, onCreateGroupReport,
onOpenTextToSpeechSettings, onOpenTextToSpeechSettings,
onOpenPersonalWechatSettings, onOpenPersonalWechatSettings,
onOpenLocalIndexSettings,
isAiLoading = false, isAiLoading = false,
jumpToTime, jumpToTime,
jumpToMessageId jumpToMessageId
@@ -127,20 +122,8 @@ 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 allMessages.filter((msg) => { return messages.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())
@@ -149,7 +132,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
}) })
}, [allMessages, contentFilter]) }, [messages, contentFilter])
const handleOpenPersonalWechatSend = useCallback(async (): Promise<void> => { const handleOpenPersonalWechatSend = useCallback(async (): Promise<void> => {
try { try {
@@ -185,7 +168,6 @@ 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
@@ -158,7 +158,22 @@ function CardBubble({ data }: { data: Extract<ParsedContent, { type: 'card' }> }
} }
function ShareBubble({ data }: { data: Extract<ParsedContent, { type: 'share' }> }): JSX.Element { function ShareBubble({ data }: { data: Extract<ParsedContent, { type: 'share' }> }): JSX.Element {
const { title, des, url, appname, articles } = data const { title, des, url, appname, articles, transfer, typeVal } = data
if (String(typeVal) === '2000' || transfer) {
const pay = transfer || {}
const meta = [pay.amountText, pay.payMemo ? `备注:${pay.payMemo}` : '', pay.transferStatusText]
.filter(Boolean)
.join(' · ')
return (
<div className="transfer-message">
<div className="transfer-kicker">微信转账</div>
<div className="transfer-title">{title || '微信转账'}</div>
<div className="transfer-meta">{meta || des || '转账消息'}</div>
{pay.transferId ? <div className="transfer-footer">单号 {pay.transferId}</div> : null}
</div>
)
}
if (articles?.length) { if (articles?.length) {
return ( return (
@@ -254,6 +269,8 @@ function RedPacketBubble({
const handleClick = (): void => { const handleClick = (): void => {
if (data.url) window.open(data.url, '_blank') if (data.url) window.open(data.url, '_blank')
} }
const statusText = data.pay?.redPacketStatusText
const blurb = data.description || data.pay?.sceneText || '恭喜发财,大吉大利'
return ( return (
<div <div
@@ -266,7 +283,7 @@ function RedPacketBubble({
</span> </span>
<span className="red-packet-copy"> <span className="red-packet-copy">
<strong>{data.title}</strong> <strong>{data.title}</strong>
<small>{data.description || '恭喜发财,大吉大利'}</small> <small>{statusText ? `${blurb} · ${statusText}` : blurb}</small>
</span> </span>
</div> </div>
<div className="red-packet-footer">微信红包</div> <div className="red-packet-footer">微信红包</div>
@@ -14,7 +14,6 @@ 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
@@ -28,8 +27,6 @@ interface ChatHeaderProps {
onRefreshData?: () => void onRefreshData?: () => void
onTestSend: () => void onTestSend: () => void
onOpenAiSettings: () => void onOpenAiSettings: () => void
/** 跳到「设置 · 本地索引」(群统计发现索引没追平时用)。 */
onOpenLocalIndexSettings?: () => void
} }
export function ChatHeader({ export function ChatHeader({
@@ -43,11 +40,9 @@ 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
@@ -98,23 +93,12 @@ export function ChatHeader({
</IconButton> </IconButton>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <IconButton label="更多" tooltip="" variant="ghost" className="h-8 w-8">
variant="outline"
size="sm"
className="chat-header-text-action"
aria-label="更多功能"
title="更多功能"
>
<MoreIcon /> <MoreIcon />
<span>更多功能</span> </IconButton>
</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 ? (
@@ -164,14 +148,6 @@ 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>
) )
} }
@@ -1,27 +0,0 @@
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,7 +1,5 @@
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'
@@ -28,17 +26,11 @@ 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) => (
// 退群推断事件与真实微信系统消息都落在 isSystem 分支, <div key={message.id} className="wechat-system-message-row">
// 但必须分开渲染:前者是本地推断,后者是微信原话。 <div className="wechat-system-message">{message.content}</div>
isGroupExitEventMessage(message) ? ( </div>
<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,14 +5,16 @@ 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 }
@@ -34,7 +36,7 @@ function fallbackStatus(error: unknown): PersonalWechatSenderStatus {
sipDisabled: false, sipDisabled: false,
wechatRunning: false, wechatRunning: false,
runtimeReady: false, runtimeReady: false,
endpoint: '', endpoint: '127.0.0.1:58080',
endpointReady: false, endpointReady: false,
attachReady: false, attachReady: false,
baseAddressReady: false, baseAddressReady: false,
@@ -65,14 +67,18 @@ 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)
// 发送入口只信任当前 native runtime 上报的能力状态。 // 状态可能来自之前的 OneBot 进程或日志;发送入口只信任当前语音能力状态。
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)
@@ -80,19 +86,23 @@ 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 || sendBusy const isBusy = binding || runtimeBusy || 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 nextSender = await window.api.getPersonalWechatSenderStatus() const [nextRuntime, nextSender] = await Promise.all([
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' ||
@@ -112,6 +122,13 @@ 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(() => {
@@ -129,6 +146,22 @@ 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)
@@ -199,27 +232,18 @@ 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,
{ {
@@ -263,7 +287,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>{initialImage ? '发送日报图片' : '文字转语音'}</DialogTitle> <DialogTitle>文字转语音</DialogTitle>
<DialogDescription> <DialogDescription>
发送给 {displayName} · {setupReady ? '微信已连接' : '配置微信发送能力'} 发送给 {displayName} · {setupReady ? '微信已连接' : '配置微信发送能力'}
</DialogDescription> </DialogDescription>
@@ -295,10 +319,14 @@ 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}
@@ -308,16 +336,6 @@ 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>
@@ -340,13 +358,8 @@ function PersonalWechatMacSendDialog({
{sendError} {sendError}
</div> </div>
)} )}
{sendSuccess && (
<div className="personal-wechat-global-success" role="status">
{sendSuccess}
</div>
)}
</div> </div>
{setupReady && isMac && !initialImage && ( {setupReady && isMac && (
<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,13 +1,22 @@
import { useState } from 'react'
import type { PersonalWechatSenderStatus } from '../../../../shared/personal-wechat' import type { PersonalWechatSenderStatus } from '../../../../shared/personal-wechat'
import { Button } from '../ui' import type {
PersonalWechatRuntimeProgressEvent,
const GROUP_README_URL = 'https://github.com/Wxw-Gu/TraceMemo#-交流与反馈' PersonalWechatRuntimeStatus
} 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
@@ -18,18 +27,21 @@ 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') { if (status.state === 'wechat_not_running') return '请先启动并登录微信。'
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 '请先完成发送运行时准备。' return isMac ? '请先完成 OneBot 运行时准备。' : '请先完成发送运行时准备。'
} }
if (status.state === 'error') return '连接微信时遇到问题,请稍后重试。' if (status.state === 'error') return '连接微信时遇到问题,请稍后重试。'
return '请保持微信未登录窗口状态,点击“绑定微信”后,再点击微信窗口登录。' return '请启动并登录当前微信,TraceMemo 会自动绑定正在使用的账号。'
} }
function isWechatBound(status: PersonalWechatSenderStatus): boolean { function isWechatBound(status: PersonalWechatSenderStatus): boolean {
@@ -42,21 +54,28 @@ 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 runtimeUnavailable = senderStatus?.state === 'runtime_missing' const [showSupportedVersions, setShowSupportedVersions] = useState(false)
const runtimeLabel = '发送运行时' const runtimeLabel = isMac ? 'OneBot 运行时' : '发送运行时'
const runtimeReady = senderStatus?.runtimeReady === true const runtimeReady = runtimeStatus?.state === 'ready' || 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="微信消息功能配置">
@@ -70,21 +89,38 @@ export function PersonalWechatSetupGuide({
</div> </div>
<ol className="personal-wechat-steps"> <ol className="personal-wechat-steps">
<li className={runtimeReady ? 'is-complete' : 'is-current'}> <li
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> <p>个人微信发送需要 {runtimeLabel},首次使用时下载一次即可。</p>
{runtimeUnavailable {runtimeDownloading ? (
? `个人微信发送需要${runtimeLabel},授权后即可使用。` <div className="personal-wechat-download-progress" aria-live="polite">
: `${runtimeLabel}随 TraceMemo 一起提供,无需额外安装。`} <div>
</p> <span>正在准备 {runtimeLabel}</span>
{runtimeReady ? ( <span>{progressPercent}%</span>
<span className="personal-wechat-step-status">✓ {runtimeLabel}已就绪</span> </div>
) : runtimeUnavailable ? ( <div className="personal-wechat-progress-track">
<span className="personal-wechat-step-error">当前版本暂未提供微信消息发送功能</span> <span style={{ width: `${progressPercent}%` }} />
</div>
</div>
) : runtimeReady ? (
<span className="personal-wechat-step-status">✓ {runtimeLabel}已准备</span>
) : ( ) : (
<span className="personal-wechat-step-status">正在检查 {runtimeLabel}…</span> <Button
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>
@@ -103,7 +139,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>请保持微信未登录窗口状态,点击“绑定微信”后,再点击微信窗口登录。</p> <p>请启动并登录当前微信,TraceMemo 会自动绑定正在使用的账号。</p>
{connected ? ( {connected ? (
<span className="personal-wechat-step-status">✓ 微信已绑定</span> <span className="personal-wechat-step-status">✓ 微信已绑定</span>
) : ( ) : (
@@ -111,28 +147,15 @@ export function PersonalWechatSetupGuide({
size="sm" size="sm"
variant="outline" variant="outline"
onClick={onBind} onClick={onBind}
disabled={runtimeUnavailable || !runtimeReady || binding} disabled={!runtimeReady || binding || runtimeDownloading}
> >
{binding ? '正在绑定…' : '绑定微信'} {binding ? '正在绑定…' : '绑定微信'}
</Button> </Button>
)} )}
{!connected && senderStatus && !runtimeUnavailable && ( {!connected && senderStatus && (
<p className="personal-wechat-step-hint">{bindingHint(senderStatus)}</p> <p className="personal-wechat-step-hint">{bindingHint(senderStatus)}</p>
)} )}
{!connected && runtimeUnavailable && ( {!connected && (
<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 />
@@ -140,6 +163,14 @@ export function PersonalWechatSetupGuide({
通用”,取消勾选“有更新时自动升级微信”,否则版本变化后可能无法绑定。 通用”,取消勾选“有更新时自动升级微信”,否则版本变化后可能无法绑定。
</p> </p>
)} )}
<Button
className="w-fit"
variant="link"
size="sm"
onClick={() => setShowSupportedVersions(true)}
>
查看支持的微信版本
</Button>
</div> </div>
</li> </li>
@@ -194,7 +225,7 @@ export function PersonalWechatSetupGuide({
</li> </li>
</ol> </ol>
{!runtimeReady && runtimeUnavailable && onOpenTextToSpeechSettings && ( {!runtimeReady && runtimeStatus?.state === 'unsupported' && onOpenTextToSpeechSettings && (
<Button <Button
variant="link" variant="link"
size="sm" size="sm"
@@ -204,6 +235,54 @@ 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>
) )
} }
@@ -0,0 +1,46 @@
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,8 +6,6 @@ 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 {
@@ -47,7 +45,7 @@ function statusLabel(status: PersonalWechatSenderStatus | null): string {
} }
function statusText(value: string): string { function statusText(value: string): string {
return value.replace(/Hook/gi, '微信发送能力').replace(/个人微信发送组件/g, '微信发送能力') return value.replace(/OneBot|Hook/gi, '微信发送能力').replace(/个人微信发送组件/g, '微信发送能力')
} }
function statusDescription(status: PersonalWechatSenderStatus | null): string { function statusDescription(status: PersonalWechatSenderStatus | null): string {
@@ -66,17 +64,12 @@ 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
@@ -110,7 +103,9 @@ export function PersonalWechatWindowsSendDialog({
onOpenPersonalWechatSettings() onOpenPersonalWechatSettings()
} }
const handleSend = async (filePath: string): Promise<{ success: boolean; error?: string }> => { const handleSend = async (
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({
@@ -142,28 +137,15 @@ 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) { if (!response.success) return
setSendError(response.error || '日报图片发送失败')
return
}
if (response.postfixError) {
setSendError(`日报图片已发送,但${response.postfixError}`)
setSendSuccess('日报图片发送成功')
} else {
setSendSuccess(postfixText.trim() ? '日报图片和后置词发送成功' : '日报图片发送成功')
}
setMessages((current) => [ setMessages((current) => [
...current, ...current,
{ {
@@ -174,8 +156,6 @@ export function PersonalWechatWindowsSendDialog({
outgoing: true outgoing: true
} }
]) ])
} catch (error) {
setSendError(error instanceof Error ? error.message : String(error))
} finally { } finally {
setSendBusy(false) setSendBusy(false)
} }
@@ -203,7 +183,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>
@@ -221,7 +201,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>
@@ -266,7 +246,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>
))} ))}
@@ -275,16 +255,6 @@ 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>
@@ -301,16 +271,6 @@ 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>
@@ -1,32 +0,0 @@
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>
)
}
@@ -1,36 +0,0 @@
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 }
}
@@ -1,365 +0,0 @@
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,13 +54,6 @@ 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,7 +3,6 @@ export type AppPage =
| 'search' | 'search'
| 'report' | 'report'
| 'exit-monitor' | 'exit-monitor'
| 'automation'
| 'agent-hub' | 'agent-hub'
| 'export' | 'export'
| 'api' | 'api'
@@ -19,7 +18,6 @@ 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,13 +4,7 @@ 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
} }
@@ -55,7 +49,7 @@ export function MessageTypeSelector({
<b>{option.label}</b> <b>{option.label}</b>
<small>{option.description}</small> <small>{option.description}</small>
</span> </span>
{counts ? <em>{counts[option.value]}</em> : null} <em>{counts[option.value]}</em>
</label> </label>
))} ))}
</div> </div>
File diff suppressed because it is too large Load Diff
@@ -1,342 +0,0 @@
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>
)
}
@@ -1,142 +0,0 @@
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>
)
}
@@ -1,57 +0,0 @@
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>
)
}
@@ -1,798 +0,0 @@
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>
)
}
@@ -1,139 +0,0 @@
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>
)
}
@@ -1,144 +0,0 @@
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>
)
}
@@ -1,124 +0,0 @@
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>
)
}
@@ -1,436 +0,0 @@
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>
)
}
@@ -1,114 +0,0 @@
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>
)
}
@@ -1,75 +0,0 @@
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>
)
}
@@ -1,100 +0,0 @@
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>
)
}
@@ -1,395 +0,0 @@
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>
)
}
@@ -1,284 +0,0 @@
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>
)
}
@@ -1,482 +0,0 @@
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>
)
}
@@ -1,150 +0,0 @@
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>
)
}
@@ -1,199 +0,0 @@
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>
)
}
@@ -1,199 +0,0 @@
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
)
}
@@ -1,69 +0,0 @@
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 '未执行'
}
@@ -1,304 +0,0 @@
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,12 +4,16 @@ 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
@@ -17,37 +21,20 @@ 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 = {
@@ -58,6 +45,7 @@ const EMPTY_STATE: GroupExitMonitorState = {
monitoredGroupCount: 0, monitoredGroupCount: 0,
monitorSelectionConfigured: true, monitorSelectionConfigured: true,
monitoredRoomIds: [], monitoredRoomIds: [],
notificationRoomIds: [],
lastReadAt: 0, lastReadAt: 0,
unreadCount: 0 unreadCount: 0
} }
@@ -97,6 +85,56 @@ 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 = ''
@@ -122,12 +160,88 @@ function SendCapabilityStatus({
) )
} }
/* function GroupExitNotificationTemplate({
* 「退群监测模板」编辑入口**已从这里移除**。 template,
* saving,
* 模板的唯一编辑处现在是「自动化 → 规则 → 退群通知 → 3 · 通知内容」。 error,
* 退群监控只产生退群事实,不再持有通知模板 —— 两处可编辑必然漂移。 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)
@@ -142,6 +256,7 @@ 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
@@ -150,14 +265,20 @@ 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,
@@ -166,9 +287,14 @@ 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(() => {
@@ -200,6 +326,12 @@ 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> 个群聊
@@ -262,6 +394,7 @@ 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
@@ -279,6 +412,15 @@ 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>
) )
})} })}
@@ -293,9 +435,7 @@ 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}>
取消 取消
@@ -312,10 +452,7 @@ 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)
@@ -325,23 +462,21 @@ 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>()
@@ -391,44 +526,6 @@ 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)
@@ -475,15 +572,21 @@ 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')
} }
@@ -512,15 +615,26 @@ 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, copyNoticeTemplate) const text = renderGroupExitMonitorNotification(event, notificationTemplate)
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
@@ -542,11 +656,13 @@ 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 nextState = await api.setGroupExitMonitorGroups(selectedIds) const notificationIds = selectedIds.filter((roomId) =>
selectedManageNotificationRoomIds.has(roomId)
)
const nextState = await api.setGroupExitMonitorGroups(selectedIds, notificationIds)
setState(nextState) setState(nextState)
setView('events') setView('events')
} catch (reason) { } catch (reason) {
@@ -556,6 +672,26 @@ 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
@@ -571,12 +707,20 @@ 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 => {
@@ -590,6 +734,17 @@ 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') {
@@ -597,6 +752,7 @@ 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}
@@ -605,9 +761,14 @@ 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}
/> />
) )
} }
@@ -645,19 +806,6 @@ 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>
@@ -773,10 +921,12 @@ 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>
@@ -1,30 +0,0 @@
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,21 +13,7 @@ import {
} from '../../components/ui' } from '../../components/ui'
type StatusFilter = 'all' | WechatActionStatus type StatusFilter = 'all' | WechatActionStatus
type PurposeFilter = 'all' | 'leave_notification' | 'scheduled_report' | 'other' type PurposeFilter = 'all' | 'member_left_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 '退群监控'
@@ -41,7 +27,7 @@ const triggerLabel = (triggerType: ActionLogEntry['triggerType']): string =>
triggerType === 'user' ? '用户触发' : '自动化' triggerType === 'user' ? '用户触发' : '自动化'
const purposeLabel = (purpose: WechatActionPurpose): string => { const purposeLabel = (purpose: WechatActionPurpose): string => {
if (LEAVE_NOTIFICATION_PURPOSES.includes(purpose)) return '退群通知' if (purpose === 'member_left_notification') 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 || '其它动作'
@@ -65,12 +51,7 @@ 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 ( return entry.purpose !== 'member_left_notification' && entry.purpose !== 'scheduled_report'
!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
} }
@@ -152,7 +133,7 @@ export function LogsWorkspace(): React.ReactElement {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">全部类型</SelectItem> <SelectItem value="all">全部类型</SelectItem>
<SelectItem value="leave_notification">退群通知</SelectItem> <SelectItem value="member_left_notification">退群通知</SelectItem>
<SelectItem value="scheduled_report">定时日报</SelectItem> <SelectItem value="scheduled_report">定时日报</SelectItem>
<SelectItem value="other">其它动作</SelectItem> <SelectItem value="other">其它动作</SelectItem>
</SelectContent> </SelectContent>
@@ -9,7 +9,6 @@ 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'
@@ -121,8 +120,6 @@ 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
@@ -1,26 +0,0 @@
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,8 +77,12 @@ 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,10 +29,6 @@ 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,7 +12,6 @@ export type SettingsCategoryId =
| 'local-api' | 'local-api'
| 'storage-export' | 'storage-export'
| 'cache-cleanup' | 'cache-cleanup'
| 'local-index'
| 'appearance' | 'appearance'
| 'advanced' | 'advanced'
| 'about' | 'about'
@@ -1,36 +0,0 @@
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,12 +1,41 @@
import { useCallback, useEffect, useState, type ReactElement } from 'react' import { useCallback, useEffect, useRef, useState, type ReactElement } from 'react'
import type { import type {
PersonalWechatSendCapability, PersonalWechatSendCapability,
PersonalWechatSenderStatus PersonalWechatSenderStatus
} from '../../../../../shared/personal-wechat' } from '../../../../../shared/personal-wechat'
import { Button, Input, Skeleton } from '../../../components/ui' import type {
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}#-交流与反馈`
@@ -40,26 +69,6 @@ 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
@@ -69,7 +78,12 @@ 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('')
@@ -79,6 +93,12 @@ 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)
@@ -86,12 +106,15 @@ export function PersonalWechatSendPage({
} }
setError('') setError('')
try { try {
const [nextCapability, nextSender] = await Promise.all([ const [nextCapability, nextSender, nextRuntime] = 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 {
@@ -102,8 +125,31 @@ 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
@@ -141,6 +187,80 @@ 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)
@@ -266,7 +386,6 @@ 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 (
<> <>
@@ -279,7 +398,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 ? '检测中' : macUnavailable ? '暂不可用' : capabilityLabel[pageStatus]} {loading ? '检测中' : capabilityLabel[pageStatus]}
</span> </span>
</header> </header>
<div className="settings-page-scroll"> <div className="settings-page-scroll">
@@ -288,55 +407,48 @@ export function PersonalWechatSendPage({
<Skeleton className="h-28 w-full" /> <Skeleton className="h-28 w-full" />
) : ( ) : (
<> <>
{/* macOS 走下方「配置状态」SetupGuide 统一展示,这里只在 Windows/其它 <h2 className="settings-section-heading">发送能力</h2>
* 平台显示旧「发送能力」卡(Mac 上两者重复,已按用户要求移除)。 */} <section className="settings-card">
{!isMac ? ( <div className="flex items-start justify-between gap-4">
<> <div>
<h2 className="settings-section-heading">发送能力</h2> <span className="settings-card-kicker">个人微信</span>
<section className="settings-card"> <strong className="mt-1 block text-base">{pageMessage}</strong>
<div className="flex items-start justify-between gap-4"> <p className="mt-2 text-sm text-muted-foreground">
<div> {pageStatus === 'unsupported' && isMac
<span className="settings-card-kicker">个人微信</span> ? 'Intel Mac 不支持个人微信发送。'
<strong className="mt-1 block text-base">{pageMessage}</strong> : pageStatus === 'unsupported' && !isWindows
<p className="mt-2 text-sm text-muted-foreground"> ? '微信消息发送目前仅支持 macOS 和 Windows。'
{pageStatus === 'unsupported' : isWindows
? '微信消息发送目前仅支持 macOS 和 Windows。' ? 'Windows 通过本机微信发送接口工作,请先配置并检测接口端口。'
: isWindows : '档案中的文字、图片和语音发送,以及定时日报发送,都会使用这项能力。'}
? 'Windows 通过本机微信发送接口工作,请先配置并检测接口端口。' </p>
: '档案中的文字、图片和语音发送,以及定时日报发送,都会使用这项能力。'} </div>
</p> <Button variant="outline" size="sm" onClick={() => void detectCapability()}>
</div> {detecting ? '刷新中…' : isWindows ? '重新检测' : '刷新状态'}
<Button variant="outline" size="sm" onClick={() => void detectCapability()}> </Button>
{detecting ? '刷新中…' : isWindows ? '重新检测' : '刷新状态'} </div>
</Button> <div className="mt-5 grid grid-cols-3 gap-2" aria-label="微信发送能力明细">
{(
[
['文字', 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 ? (
['文字', pageCapabilities.text], <p className="mt-3 text-sm text-destructive" role="alert">
['图片', pageCapabilities.image], {error}
['语音', pageCapabilities.voice] </p>
] as const ) : null}
).map(([label, available]) => ( </section>
<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 ? (
<> <>
@@ -403,16 +515,156 @@ export function PersonalWechatSendPage({
</section> </section>
<h2 className="settings-section-heading">发送能力授权</h2> <h2 className="settings-section-heading">发送能力授权</h2>
<SendCapabilityAuthorizationCard /> <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>
</> </>
) : 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('微信消息发送能力已就绪,请在档案中选择会话开始发送。')
@@ -426,6 +678,24 @@ 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>
</> </>
) )
} }
@@ -1,37 +0,0 @@
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
}
+1 -1
View File
@@ -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: 88px; --wxex-nav-width: 76px;
--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

Some files were not shown because too many files have changed in this diff Show More