mirror of https://github.com/jackwener/wx-cli.git
feat: daemon + CLI 架构 (wx_daemon.py + wx.py)
- wx_daemon.py: Unix socket server,mtime 感知 DB 缓存,WAL 监听,实时推送 - wx.py: Click CLI,自动拉起 daemon,sessions/history/search/contacts/watch - pyproject.toml + uv.lock: uv 依赖管理feat/daemon-cli
parent
69a2f44240
commit
c907cf53fe
|
|
@ -0,0 +1,9 @@
|
||||||
|
[project]
|
||||||
|
name = "wechat-decrypt"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"pycryptodome>=3.19,<4",
|
||||||
|
"zstandard>=0.22,<1",
|
||||||
|
"click>=8.1,<9",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "click"
|
||||||
|
version = "8.3.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pycryptodome"
|
||||||
|
version = "3.23.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wechat-decrypt"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "pycryptodome" },
|
||||||
|
{ name = "zstandard" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "click", specifier = ">=8.1,<9" },
|
||||||
|
{ name = "pycryptodome", specifier = ">=3.19,<4" },
|
||||||
|
{ name = "zstandard", specifier = ">=0.22,<1" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstandard"
|
||||||
|
version = "0.25.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" },
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,355 @@
|
||||||
|
"""
|
||||||
|
wx - 微信本地数据 CLI
|
||||||
|
|
||||||
|
自动管理 daemon 生命周期,无需用户手动启动。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
wx sessions 最近会话
|
||||||
|
wx history "张三" 聊天记录
|
||||||
|
wx search "关键词" 搜索消息
|
||||||
|
wx contacts 联系人列表
|
||||||
|
wx watch 实时监听新消息
|
||||||
|
wx daemon status/stop/logs daemon 管理
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
CLI_DIR = os.path.join(os.path.expanduser("~"), ".wechat-cli")
|
||||||
|
SOCK_PATH = os.path.join(CLI_DIR, "daemon.sock")
|
||||||
|
PID_PATH = os.path.join(CLI_DIR, "daemon.pid")
|
||||||
|
LOG_PATH = os.path.join(CLI_DIR, "daemon.log")
|
||||||
|
DAEMON_SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "wx_daemon.py")
|
||||||
|
STARTUP_TIMEOUT = 15 # 等待 daemon 启动的最长秒数
|
||||||
|
|
||||||
|
# ─── daemon 管理 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _is_alive() -> bool:
|
||||||
|
if not os.path.exists(SOCK_PATH):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
s.settimeout(2)
|
||||||
|
s.connect(SOCK_PATH)
|
||||||
|
s.sendall(b'{"cmd":"ping"}\n')
|
||||||
|
resp = json.loads(s.makefile().readline())
|
||||||
|
s.close()
|
||||||
|
return resp.get("pong") is True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _start_daemon() -> None:
|
||||||
|
subprocess.Popen(
|
||||||
|
[sys.executable, DAEMON_SCRIPT],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
deadline = time.time() + STARTUP_TIMEOUT
|
||||||
|
while time.time() < deadline:
|
||||||
|
time.sleep(0.3)
|
||||||
|
if _is_alive():
|
||||||
|
return
|
||||||
|
raise click.ClickException(
|
||||||
|
f"wx-daemon 启动超时(>{STARTUP_TIMEOUT}s)\n"
|
||||||
|
f"请查看日志: {LOG_PATH}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_daemon() -> None:
|
||||||
|
if not _is_alive():
|
||||||
|
click.echo("⏳ 启动 wx-daemon...", err=True)
|
||||||
|
_start_daemon()
|
||||||
|
|
||||||
|
# ─── 通信 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _send(req: dict, timeout: int = 30) -> dict:
|
||||||
|
_ensure_daemon()
|
||||||
|
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
s.settimeout(timeout)
|
||||||
|
s.connect(SOCK_PATH)
|
||||||
|
s.sendall((json.dumps(req, ensure_ascii=False) + '\n').encode())
|
||||||
|
resp = json.loads(s.makefile().readline())
|
||||||
|
s.close()
|
||||||
|
if not resp.get("ok"):
|
||||||
|
raise click.ClickException(resp.get("error", "未知错误"))
|
||||||
|
return resp
|
||||||
|
|
||||||
|
# ─── 时间解析 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _parse_time(value: str, is_end: bool = False) -> int:
|
||||||
|
from datetime import datetime
|
||||||
|
for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M', '%Y-%m-%d'):
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(value, fmt)
|
||||||
|
if fmt == '%Y-%m-%d' and is_end:
|
||||||
|
dt = dt.replace(hour=23, minute=59, second=59)
|
||||||
|
return int(dt.timestamp())
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
raise click.BadParameter(
|
||||||
|
f"无法解析时间 '{value}',支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── CLI ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
||||||
|
@click.version_option("0.1.0", prog_name="wx")
|
||||||
|
def cli():
|
||||||
|
"""wx — 微信本地数据 CLI"""
|
||||||
|
|
||||||
|
|
||||||
|
# ─── sessions ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.option('-n', '--limit', default=20, show_default=True, help='会话数量')
|
||||||
|
@click.option('--json', 'as_json', is_flag=True, help='输出原始 JSON')
|
||||||
|
def sessions(limit, as_json):
|
||||||
|
"""列出最近会话"""
|
||||||
|
resp = _send({"cmd": "sessions", "limit": limit})
|
||||||
|
data = resp.get("sessions", [])
|
||||||
|
|
||||||
|
if as_json:
|
||||||
|
click.echo(json.dumps(data, ensure_ascii=False, indent=2))
|
||||||
|
return
|
||||||
|
|
||||||
|
for s in data:
|
||||||
|
unread = f" \033[31m({s['unread']}未读)\033[0m" if s.get('unread', 0) > 0 else ''
|
||||||
|
group = ' [群]' if s['is_group'] else ''
|
||||||
|
sender = f"{s['last_sender']}: " if s.get('last_sender') else ''
|
||||||
|
click.echo(f"\033[90m[{s['time']}]\033[0m \033[1m{s['chat']}\033[0m{group}{unread}")
|
||||||
|
click.echo(f" {s['last_msg_type']}: {sender}{s['summary']}")
|
||||||
|
click.echo()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── history ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.argument('chat')
|
||||||
|
@click.option('-n', '--limit', default=50, show_default=True, help='消息数量')
|
||||||
|
@click.option('--offset', default=0, help='分页偏移')
|
||||||
|
@click.option('--since', default=None, metavar='DATE', help='起始时间 YYYY-MM-DD')
|
||||||
|
@click.option('--until', default=None, metavar='DATE', help='结束时间 YYYY-MM-DD')
|
||||||
|
@click.option('--json', 'as_json', is_flag=True, help='输出原始 JSON')
|
||||||
|
def history(chat, limit, offset, since, until, as_json):
|
||||||
|
"""查看聊天记录
|
||||||
|
|
||||||
|
\b
|
||||||
|
示例:
|
||||||
|
wx history "张三"
|
||||||
|
wx history "AI群" --since 2026-04-01 --until 2026-04-15
|
||||||
|
wx history "张三" -n 100 --offset 50
|
||||||
|
"""
|
||||||
|
req = {"cmd": "history", "chat": chat, "limit": limit, "offset": offset}
|
||||||
|
if since:
|
||||||
|
req["since"] = _parse_time(since)
|
||||||
|
if until:
|
||||||
|
req["until"] = _parse_time(until, is_end=True)
|
||||||
|
|
||||||
|
resp = _send(req)
|
||||||
|
|
||||||
|
if as_json:
|
||||||
|
click.echo(json.dumps(resp.get("messages", []), ensure_ascii=False, indent=2))
|
||||||
|
return
|
||||||
|
|
||||||
|
group = ' [群]' if resp.get('is_group') else ''
|
||||||
|
click.echo(f"=== {resp['chat']}{group} ({resp['count']} 条) ===\n")
|
||||||
|
for m in resp.get("messages", []):
|
||||||
|
sender = f"\033[33m{m['sender']}\033[0m: " if m.get('sender') else ''
|
||||||
|
click.echo(f"\033[90m[{m['time']}]\033[0m {sender}{m['content']}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── search ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.argument('keyword')
|
||||||
|
@click.option('--in', 'chats', multiple=True, metavar='CHAT', help='限定聊天(可多次指定)')
|
||||||
|
@click.option('-n', '--limit', default=20, show_default=True)
|
||||||
|
@click.option('--since', default=None, metavar='DATE')
|
||||||
|
@click.option('--until', default=None, metavar='DATE')
|
||||||
|
@click.option('--json', 'as_json', is_flag=True)
|
||||||
|
def search(keyword, chats, limit, since, until, as_json):
|
||||||
|
"""搜索消息
|
||||||
|
|
||||||
|
\b
|
||||||
|
示例:
|
||||||
|
wx search "Claude"
|
||||||
|
wx search "deadline" --in "TeamA" --in "TeamB"
|
||||||
|
wx search "会议" --since 2026-04-01
|
||||||
|
"""
|
||||||
|
req = {"cmd": "search", "keyword": keyword, "limit": limit}
|
||||||
|
if chats:
|
||||||
|
req["chats"] = list(chats)
|
||||||
|
if since:
|
||||||
|
req["since"] = _parse_time(since)
|
||||||
|
if until:
|
||||||
|
req["until"] = _parse_time(until, is_end=True)
|
||||||
|
|
||||||
|
resp = _send(req)
|
||||||
|
results = resp.get("results", [])
|
||||||
|
|
||||||
|
if as_json:
|
||||||
|
click.echo(json.dumps(results, ensure_ascii=False, indent=2))
|
||||||
|
return
|
||||||
|
|
||||||
|
click.echo(f'搜索 "{keyword}",找到 {resp["count"]} 条:\n')
|
||||||
|
for r in results:
|
||||||
|
sender = f"\033[33m{r['sender']}\033[0m: " if r.get('sender') else ''
|
||||||
|
chat = f"\033[36m[{r['chat']}]\033[0m " if r.get('chat') else ''
|
||||||
|
click.echo(f"\033[90m[{r['time']}]\033[0m {chat}{sender}{r['content']}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── contacts ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.option('-q', '--query', default=None, help='按名字过滤')
|
||||||
|
@click.option('-n', '--limit', default=50, show_default=True)
|
||||||
|
@click.option('--json', 'as_json', is_flag=True)
|
||||||
|
def contacts(query, limit, as_json):
|
||||||
|
"""查看联系人
|
||||||
|
|
||||||
|
\b
|
||||||
|
示例:
|
||||||
|
wx contacts
|
||||||
|
wx contacts -q "李"
|
||||||
|
"""
|
||||||
|
resp = _send({"cmd": "contacts", "query": query, "limit": limit})
|
||||||
|
data = resp.get("contacts", [])
|
||||||
|
|
||||||
|
if as_json:
|
||||||
|
click.echo(json.dumps(data, ensure_ascii=False, indent=2))
|
||||||
|
return
|
||||||
|
|
||||||
|
click.echo(f"共 {resp.get('total', len(data))} 个联系人(显示 {len(data)} 个):\n")
|
||||||
|
for c in data:
|
||||||
|
click.echo(f" {c['display']:<20} {c['username']}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── watch ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.option('--chat', default=None, help='只显示指定聊天的消息')
|
||||||
|
@click.option('--json', 'as_json', is_flag=True, help='输出 JSON lines(方便 jq 处理)')
|
||||||
|
def watch(chat, as_json):
|
||||||
|
"""实时监听新消息(Ctrl+C 退出)
|
||||||
|
|
||||||
|
\b
|
||||||
|
示例:
|
||||||
|
wx watch
|
||||||
|
wx watch --chat "AI交流群"
|
||||||
|
wx watch --json | jq .content
|
||||||
|
"""
|
||||||
|
_ensure_daemon()
|
||||||
|
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
s.connect(SOCK_PATH)
|
||||||
|
s.sendall((json.dumps({"cmd": "watch"}) + '\n').encode())
|
||||||
|
|
||||||
|
if not as_json:
|
||||||
|
click.echo("监听中(Ctrl+C 退出)...\n", err=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
for line in s.makefile():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
event = json.loads(line)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
evt = event.get("event", "")
|
||||||
|
if evt in ("connected", "heartbeat"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 过滤指定聊天
|
||||||
|
if chat and event.get("chat") != chat and event.get("username") != chat:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if as_json:
|
||||||
|
click.echo(line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
time_s = event.get('time', '')
|
||||||
|
chat_s = event.get('chat', '')
|
||||||
|
is_group = event.get('is_group', False)
|
||||||
|
sender = event.get('sender', '')
|
||||||
|
content = event.get('content', '')
|
||||||
|
|
||||||
|
chat_part = f"\033[36m[{chat_s}]\033[0m " if is_group else f"\033[1m{chat_s}\033[0m "
|
||||||
|
sender_part = f"\033[33m{sender}\033[0m: " if sender else ''
|
||||||
|
click.echo(f"\033[90m[{time_s}]\033[0m {chat_part}{sender_part}{content}")
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
s.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ─── daemon 子命令组 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@cli.group()
|
||||||
|
def daemon():
|
||||||
|
"""管理 wx-daemon"""
|
||||||
|
|
||||||
|
|
||||||
|
@daemon.command()
|
||||||
|
def status():
|
||||||
|
"""查看 daemon 运行状态"""
|
||||||
|
if _is_alive():
|
||||||
|
pid = open(PID_PATH).read().strip() if os.path.exists(PID_PATH) else '?'
|
||||||
|
click.echo(f"✓ wx-daemon 运行中 (PID {pid})")
|
||||||
|
else:
|
||||||
|
click.echo("✗ wx-daemon 未运行")
|
||||||
|
|
||||||
|
|
||||||
|
@daemon.command()
|
||||||
|
def stop():
|
||||||
|
"""停止 daemon"""
|
||||||
|
if not os.path.exists(PID_PATH):
|
||||||
|
click.echo("daemon 未运行")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
pid = int(open(PID_PATH).read().strip())
|
||||||
|
import signal
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
click.echo(f"✓ 已停止 wx-daemon (PID {pid})")
|
||||||
|
except (ValueError, ProcessLookupError):
|
||||||
|
click.echo("daemon 进程不存在,清理残留文件")
|
||||||
|
for p in (SOCK_PATH, PID_PATH):
|
||||||
|
try:
|
||||||
|
os.unlink(p)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@daemon.command()
|
||||||
|
@click.option('-f', '--follow', is_flag=True, help='持续输出(tail -f)')
|
||||||
|
@click.option('-n', '--lines', default=50, show_default=True, help='显示最近 N 行')
|
||||||
|
def logs(follow, lines):
|
||||||
|
"""查看 daemon 日志"""
|
||||||
|
if not os.path.exists(LOG_PATH):
|
||||||
|
click.echo("暂无日志")
|
||||||
|
return
|
||||||
|
if follow:
|
||||||
|
import subprocess as sp
|
||||||
|
sp.run(['tail', f'-{lines}', '-f', LOG_PATH])
|
||||||
|
else:
|
||||||
|
with open(LOG_PATH) as f:
|
||||||
|
all_lines = f.readlines()
|
||||||
|
click.echo(''.join(all_lines[-lines:]), nl=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 入口 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
cli()
|
||||||
|
|
@ -0,0 +1,814 @@
|
||||||
|
"""
|
||||||
|
wx-daemon: 微信数据访问守护进程
|
||||||
|
|
||||||
|
启动后常驻后台,通过 Unix socket 响应 CLI 查询,持续监听 WAL 变化推送实时消息。
|
||||||
|
|
||||||
|
Socket : ~/.wechat-cli/daemon.sock
|
||||||
|
PID : ~/.wechat-cli/daemon.pid
|
||||||
|
Log : ~/.wechat-cli/daemon.log
|
||||||
|
Cache : ~/.wechat-cli/cache/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac as hmac_mod
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import sqlite3
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from contextlib import closing
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from Crypto.Cipher import AES
|
||||||
|
import zstandard as zstd
|
||||||
|
|
||||||
|
# ─── 路径常量 ─────────────────────────────────────────────────────────────────
|
||||||
|
CLI_DIR = os.path.join(os.path.expanduser("~"), ".wechat-cli")
|
||||||
|
SOCK_PATH = os.path.join(CLI_DIR, "daemon.sock")
|
||||||
|
PID_PATH = os.path.join(CLI_DIR, "daemon.pid")
|
||||||
|
LOG_PATH = os.path.join(CLI_DIR, "daemon.log")
|
||||||
|
CACHE_DIR = os.path.join(CLI_DIR, "cache")
|
||||||
|
MTIME_FILE = os.path.join(CACHE_DIR, "_mtimes.json")
|
||||||
|
|
||||||
|
os.makedirs(CLI_DIR, exist_ok=True)
|
||||||
|
os.makedirs(CACHE_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
# ─── 加密常量 ─────────────────────────────────────────────────────────────────
|
||||||
|
PAGE_SZ = 4096
|
||||||
|
SALT_SZ = 16
|
||||||
|
RESERVE_SZ = 80
|
||||||
|
SQLITE_HDR = b'SQLite format 3\x00'
|
||||||
|
WAL_HDR_SZ = 32
|
||||||
|
WAL_FRAME_HDR = 24
|
||||||
|
|
||||||
|
# ─── 配置加载 ─────────────────────────────────────────────────────────────────
|
||||||
|
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, _SCRIPT_DIR)
|
||||||
|
|
||||||
|
from config import load_config
|
||||||
|
from key_utils import get_key_info, strip_key_metadata
|
||||||
|
|
||||||
|
_cfg = load_config()
|
||||||
|
DB_DIR = _cfg["db_dir"]
|
||||||
|
KEYS_FILE = _cfg["keys_file"]
|
||||||
|
|
||||||
|
with open(KEYS_FILE, encoding="utf-8") as _f:
|
||||||
|
ALL_KEYS = strip_key_metadata(json.load(_f))
|
||||||
|
|
||||||
|
_zstd = zstd.ZstdDecompressor()
|
||||||
|
|
||||||
|
# ─── 日志 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _log(msg: str) -> None:
|
||||||
|
ts = datetime.now().strftime('%H:%M:%S')
|
||||||
|
print(f"[{ts}] {msg}", flush=True)
|
||||||
|
|
||||||
|
# ─── 解密 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _decrypt_page(enc_key: bytes, page_data: bytes, pgno: int) -> bytes:
|
||||||
|
iv = page_data[PAGE_SZ - RESERVE_SZ: PAGE_SZ - RESERVE_SZ + 16]
|
||||||
|
if pgno == 1:
|
||||||
|
enc = page_data[SALT_SZ: PAGE_SZ - RESERVE_SZ]
|
||||||
|
dec = AES.new(enc_key, AES.MODE_CBC, iv).decrypt(enc)
|
||||||
|
return bytes(SQLITE_HDR + dec + b'\x00' * RESERVE_SZ)
|
||||||
|
enc = page_data[:PAGE_SZ - RESERVE_SZ]
|
||||||
|
dec = AES.new(enc_key, AES.MODE_CBC, iv).decrypt(enc)
|
||||||
|
return dec + b'\x00' * RESERVE_SZ
|
||||||
|
|
||||||
|
|
||||||
|
def _full_decrypt(db_path: str, out_path: str, enc_key: bytes) -> None:
|
||||||
|
size = os.path.getsize(db_path)
|
||||||
|
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||||
|
with open(db_path, 'rb') as fin, open(out_path, 'wb') as fout:
|
||||||
|
for pgno in range(1, size // PAGE_SZ + 1):
|
||||||
|
page = fin.read(PAGE_SZ)
|
||||||
|
if not page:
|
||||||
|
break
|
||||||
|
if len(page) < PAGE_SZ:
|
||||||
|
page = page + b'\x00' * (PAGE_SZ - len(page))
|
||||||
|
fout.write(_decrypt_page(enc_key, page, pgno))
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_wal(wal_path: str, out_path: str, enc_key: bytes) -> None:
|
||||||
|
if not os.path.exists(wal_path):
|
||||||
|
return
|
||||||
|
wal_size = os.path.getsize(wal_path)
|
||||||
|
if wal_size <= WAL_HDR_SZ:
|
||||||
|
return
|
||||||
|
frame_size = WAL_FRAME_HDR + PAGE_SZ
|
||||||
|
with open(wal_path, 'rb') as wf, open(out_path, 'r+b') as df:
|
||||||
|
hdr = wf.read(WAL_HDR_SZ)
|
||||||
|
s1 = struct.unpack('>I', hdr[16:20])[0]
|
||||||
|
s2 = struct.unpack('>I', hdr[20:24])[0]
|
||||||
|
while wf.tell() + frame_size <= wal_size:
|
||||||
|
fh = wf.read(WAL_FRAME_HDR)
|
||||||
|
if len(fh) < WAL_FRAME_HDR:
|
||||||
|
break
|
||||||
|
pgno = struct.unpack('>I', fh[0:4])[0]
|
||||||
|
fs1 = struct.unpack('>I', fh[8:12])[0]
|
||||||
|
fs2 = struct.unpack('>I', fh[12:16])[0]
|
||||||
|
ep = wf.read(PAGE_SZ)
|
||||||
|
if len(ep) < PAGE_SZ:
|
||||||
|
break
|
||||||
|
if pgno == 0 or pgno > 1_000_000:
|
||||||
|
continue
|
||||||
|
if fs1 != s1 or fs2 != s2:
|
||||||
|
continue
|
||||||
|
dec = _decrypt_page(enc_key, ep, pgno)
|
||||||
|
df.seek((pgno - 1) * PAGE_SZ)
|
||||||
|
df.write(dec)
|
||||||
|
|
||||||
|
# ─── DB 缓存(mtime 感知,跨进程重启可复用)────────────────────────────────────
|
||||||
|
|
||||||
|
class DBCache:
|
||||||
|
def __init__(self):
|
||||||
|
self._cache: dict[str, tuple[float, float, str]] = {} # rel -> (db_mt, wal_mt, path)
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._load_persistent()
|
||||||
|
|
||||||
|
def _cache_path(self, rel_key: str) -> str:
|
||||||
|
h = hashlib.md5(rel_key.encode()).hexdigest()[:12]
|
||||||
|
return os.path.join(CACHE_DIR, f"{h}.db")
|
||||||
|
|
||||||
|
def _load_persistent(self) -> None:
|
||||||
|
if not os.path.exists(MTIME_FILE):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
saved = json.loads(open(MTIME_FILE, encoding='utf-8').read())
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
reused = 0
|
||||||
|
for rel_key, info in saved.items():
|
||||||
|
path = info.get("path", "")
|
||||||
|
if not os.path.exists(path):
|
||||||
|
continue
|
||||||
|
db_path = os.path.join(DB_DIR, rel_key.replace('\\', os.sep).replace('/', os.sep))
|
||||||
|
wal_path = db_path + "-wal"
|
||||||
|
try:
|
||||||
|
db_mt = os.path.getmtime(db_path)
|
||||||
|
wal_mt = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0.0
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if db_mt == info.get("db_mt") and wal_mt == info.get("wal_mt"):
|
||||||
|
self._cache[rel_key] = (db_mt, wal_mt, path)
|
||||||
|
reused += 1
|
||||||
|
if reused:
|
||||||
|
_log(f"DBCache: 复用 {reused} 个已解密 DB")
|
||||||
|
|
||||||
|
def _save_persistent(self) -> None:
|
||||||
|
data = {k: {"db_mt": v[0], "wal_mt": v[1], "path": v[2]} for k, v in self._cache.items()}
|
||||||
|
try:
|
||||||
|
with open(MTIME_FILE, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get(self, rel_key: str) -> str | None:
|
||||||
|
key_info = get_key_info(ALL_KEYS, rel_key)
|
||||||
|
if not key_info:
|
||||||
|
return None
|
||||||
|
db_path = os.path.join(DB_DIR, rel_key.replace('\\', os.sep).replace('/', os.sep))
|
||||||
|
wal_path = db_path + "-wal"
|
||||||
|
if not os.path.exists(db_path):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
db_mt = os.path.getmtime(db_path)
|
||||||
|
wal_mt = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0.0
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
cached = self._cache.get(rel_key)
|
||||||
|
if cached and cached[0] == db_mt and cached[1] == wal_mt and os.path.exists(cached[2]):
|
||||||
|
return cached[2]
|
||||||
|
out = self._cache_path(rel_key)
|
||||||
|
enc_key = bytes.fromhex(key_info["enc_key"])
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
_full_decrypt(db_path, out, enc_key)
|
||||||
|
_apply_wal(wal_path, out, enc_key)
|
||||||
|
ms = (time.perf_counter() - t0) * 1000
|
||||||
|
_log(f"解密 {rel_key} ({ms:.0f}ms)")
|
||||||
|
self._cache[rel_key] = (db_mt, wal_mt, out)
|
||||||
|
self._save_persistent()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
_db = DBCache()
|
||||||
|
|
||||||
|
# ─── 消息 DB 列表 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
MSG_DB_KEYS = sorted([
|
||||||
|
k for k in ALL_KEYS
|
||||||
|
if re.search(r'message[/\\]message_\d+\.db$', k)
|
||||||
|
])
|
||||||
|
|
||||||
|
# ─── 联系人缓存 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_names: dict[str, str] | None = None
|
||||||
|
_names_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_names() -> dict[str, str]:
|
||||||
|
global _names
|
||||||
|
with _names_lock:
|
||||||
|
if _names is not None:
|
||||||
|
return _names
|
||||||
|
path = _db.get(os.path.join("contact", "contact.db"))
|
||||||
|
if not path:
|
||||||
|
_names = {}
|
||||||
|
return _names
|
||||||
|
try:
|
||||||
|
with closing(sqlite3.connect(path)) as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT username, nick_name, remark FROM contact"
|
||||||
|
).fetchall()
|
||||||
|
_names = {u: (r if r else (n if n else u)) for u, n, r in rows}
|
||||||
|
except Exception:
|
||||||
|
_names = {}
|
||||||
|
return _names
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_names() -> None:
|
||||||
|
"""强制刷新联系人缓存(新联系人/新群加入时调用)"""
|
||||||
|
global _names
|
||||||
|
with _names_lock:
|
||||||
|
_names = None
|
||||||
|
_load_names()
|
||||||
|
|
||||||
|
# ─── 辅助 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_XML_BAD = re.compile(r'<!DOCTYPE|<!ENTITY', re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_type(t) -> str:
|
||||||
|
try:
|
||||||
|
base = int(t) & 0xFFFFFFFF if int(t) > 0xFFFFFFFF else int(t)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return f'type={t}'
|
||||||
|
return {
|
||||||
|
1: '文本', 3: '图片', 34: '语音', 42: '名片', 43: '视频',
|
||||||
|
47: '表情', 48: '位置', 49: '链接/文件', 50: '通话',
|
||||||
|
10000: '系统', 10002: '撤回',
|
||||||
|
}.get(base, f'type={base}')
|
||||||
|
|
||||||
|
|
||||||
|
def _decompress(content, ct) -> str | None:
|
||||||
|
if ct == 4 and isinstance(content, bytes):
|
||||||
|
try:
|
||||||
|
return _zstd.decompress(content).decode('utf-8', errors='replace')
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if isinstance(content, bytes):
|
||||||
|
return content.decode('utf-8', errors='replace')
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_content(local_id: int, local_type, content: str | None, is_group: bool) -> str:
|
||||||
|
try:
|
||||||
|
base = int(local_type) & 0xFFFFFFFF if int(local_type) > 0xFFFFFFFF else int(local_type)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
base = 0
|
||||||
|
if base == 3:
|
||||||
|
return f"[图片] local_id={local_id}"
|
||||||
|
if base == 47:
|
||||||
|
return "[表情]"
|
||||||
|
if base == 50:
|
||||||
|
return "[通话]"
|
||||||
|
# 群聊消息内容带 "sender:\n" 前缀,解析 XML 前先剥离
|
||||||
|
text = content or ''
|
||||||
|
if is_group and ':\n' in text:
|
||||||
|
text = text.split(':\n', 1)[1]
|
||||||
|
if base == 49 and text and '<appmsg' in text and not _XML_BAD.search(text):
|
||||||
|
try:
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
root = ET.fromstring(text)
|
||||||
|
appmsg = root.find('.//appmsg')
|
||||||
|
if appmsg is not None:
|
||||||
|
title = (appmsg.findtext('title') or '').strip()
|
||||||
|
atype = (appmsg.findtext('type') or '').strip()
|
||||||
|
if atype == '6':
|
||||||
|
return f"[文件] {title}" if title else "[文件]"
|
||||||
|
if atype == '57':
|
||||||
|
ref = appmsg.find('.//refermsg')
|
||||||
|
ref_content = ''
|
||||||
|
if ref is not None:
|
||||||
|
ref_content = re.sub(r'\s+', ' ', (ref.findtext('content') or '')).strip()
|
||||||
|
if len(ref_content) > 80:
|
||||||
|
ref_content = ref_content[:80] + '...'
|
||||||
|
quote = f"[引用] {title}" if title else "[引用]"
|
||||||
|
return f"{quote}\n ↳ {ref_content}" if ref_content else quote
|
||||||
|
if atype in ('33', '36', '44'):
|
||||||
|
return f"[小程序] {title}" if title else "[小程序]"
|
||||||
|
return f"[链接] {title}" if title else "[链接/文件]"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_username(chat_name: str) -> str | None:
|
||||||
|
names = _load_names()
|
||||||
|
if chat_name in names or '@chatroom' in chat_name or chat_name.startswith('wxid_'):
|
||||||
|
return chat_name
|
||||||
|
low = chat_name.lower()
|
||||||
|
for uname, display in names.items():
|
||||||
|
if low == display.lower():
|
||||||
|
return uname
|
||||||
|
for uname, display in names.items():
|
||||||
|
if low in display.lower():
|
||||||
|
return uname
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_id2u(conn: sqlite3.Connection) -> dict[int, str]:
|
||||||
|
try:
|
||||||
|
return {r: u for r, u in conn.execute("SELECT rowid, user_name FROM Name2Id").fetchall() if u}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _sender_label(real_sender_id, content, is_group, chat_username, id2u, names) -> str:
|
||||||
|
sender_uname = id2u.get(real_sender_id, '')
|
||||||
|
if is_group:
|
||||||
|
if sender_uname and sender_uname != chat_username:
|
||||||
|
return names.get(sender_uname, sender_uname)
|
||||||
|
if content and ':\n' in content:
|
||||||
|
raw = content.split(':\n', 1)[0]
|
||||||
|
return names.get(raw, raw)
|
||||||
|
return ''
|
||||||
|
return names.get(sender_uname, '') if sender_uname and sender_uname != chat_username else ''
|
||||||
|
|
||||||
|
|
||||||
|
def _find_msg_tables(username: str) -> list[dict]:
|
||||||
|
table_name = f"Msg_{hashlib.md5(username.encode()).hexdigest()}"
|
||||||
|
if not re.fullmatch(r'Msg_[0-9a-f]{32}', table_name):
|
||||||
|
return []
|
||||||
|
results = []
|
||||||
|
for rel_key in MSG_DB_KEYS:
|
||||||
|
path = _db.get(rel_key)
|
||||||
|
if not path:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with closing(sqlite3.connect(path)) as conn:
|
||||||
|
exists = conn.execute(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table_name,)
|
||||||
|
).fetchone()
|
||||||
|
if not exists:
|
||||||
|
continue
|
||||||
|
max_ts = conn.execute(f"SELECT MAX(create_time) FROM [{table_name}]").fetchone()[0] or 0
|
||||||
|
results.append({'path': path, 'table': table_name, 'max_ts': max_ts})
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
results.sort(key=lambda x: x['max_ts'], reverse=True)
|
||||||
|
return results
|
||||||
|
|
||||||
|
# ─── 查询函数 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def q_sessions(limit: int = 20) -> dict:
|
||||||
|
path = _db.get(os.path.join("session", "session.db"))
|
||||||
|
if not path:
|
||||||
|
return {"error": "无法解密 session.db"}
|
||||||
|
names = _load_names()
|
||||||
|
with closing(sqlite3.connect(path)) as conn:
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT username, unread_count, summary, last_timestamp,
|
||||||
|
last_msg_type, last_msg_sender, last_sender_display_name
|
||||||
|
FROM SessionTable
|
||||||
|
WHERE last_timestamp > 0
|
||||||
|
ORDER BY last_timestamp DESC LIMIT ?
|
||||||
|
""", (limit,)).fetchall()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for username, unread, summary, ts, msg_type, sender, sender_name in rows:
|
||||||
|
display = names.get(username, username)
|
||||||
|
is_group = '@chatroom' in username
|
||||||
|
if isinstance(summary, bytes):
|
||||||
|
try:
|
||||||
|
summary = _zstd.decompress(summary).decode('utf-8', errors='replace')
|
||||||
|
except Exception:
|
||||||
|
summary = '(压缩内容)'
|
||||||
|
if isinstance(summary, str) and ':\n' in summary:
|
||||||
|
summary = summary.split(':\n', 1)[1]
|
||||||
|
sender_display = ''
|
||||||
|
if is_group and sender:
|
||||||
|
sender_display = names.get(sender, sender_name or sender)
|
||||||
|
results.append({
|
||||||
|
"chat": display,
|
||||||
|
"username": username,
|
||||||
|
"is_group": is_group,
|
||||||
|
"unread": unread or 0,
|
||||||
|
"last_msg_type": _fmt_type(msg_type),
|
||||||
|
"last_sender": sender_display,
|
||||||
|
"summary": str(summary or ''),
|
||||||
|
"timestamp": ts,
|
||||||
|
"time": datetime.fromtimestamp(ts).strftime('%m-%d %H:%M'),
|
||||||
|
})
|
||||||
|
return {"sessions": results}
|
||||||
|
|
||||||
|
|
||||||
|
def q_history(chat_name: str, limit: int = 50, offset: int = 0,
|
||||||
|
since: int | None = None, until: int | None = None) -> dict:
|
||||||
|
username = _resolve_username(chat_name)
|
||||||
|
if not username:
|
||||||
|
return {"error": f"找不到联系人: {chat_name}"}
|
||||||
|
names = _load_names()
|
||||||
|
display = names.get(username, username)
|
||||||
|
is_group = '@chatroom' in username
|
||||||
|
tables = _find_msg_tables(username)
|
||||||
|
if not tables:
|
||||||
|
return {"error": f"找不到 {display} 的消息记录"}
|
||||||
|
|
||||||
|
all_msgs: list[dict] = []
|
||||||
|
for tbl in tables:
|
||||||
|
try:
|
||||||
|
with closing(sqlite3.connect(tbl['path'])) as conn:
|
||||||
|
id2u = _load_id2u(conn)
|
||||||
|
clauses, params = [], []
|
||||||
|
if since:
|
||||||
|
clauses.append('create_time >= ?'); params.append(since)
|
||||||
|
if until:
|
||||||
|
clauses.append('create_time <= ?'); params.append(until)
|
||||||
|
where = f"WHERE {' AND '.join(clauses)}" if clauses else ''
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT local_id, local_type, create_time, real_sender_id,"
|
||||||
|
f" message_content, WCDB_CT_message_content"
|
||||||
|
f" FROM [{tbl['table']}] {where}"
|
||||||
|
f" ORDER BY create_time DESC LIMIT ? OFFSET ?",
|
||||||
|
(*params, limit + offset, 0)
|
||||||
|
).fetchall()
|
||||||
|
for local_id, local_type, ts, real_sender_id, content, ct in rows:
|
||||||
|
content = _decompress(content, ct)
|
||||||
|
if content is None:
|
||||||
|
content = '(无法解压)'
|
||||||
|
sender = _sender_label(real_sender_id, content, is_group, username, id2u, names)
|
||||||
|
text = _fmt_content(local_id, local_type, content, is_group)
|
||||||
|
all_msgs.append({
|
||||||
|
"timestamp": ts,
|
||||||
|
"time": datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M'),
|
||||||
|
"sender": sender,
|
||||||
|
"content": text,
|
||||||
|
"type": _fmt_type(local_type),
|
||||||
|
"local_id": local_id,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
all_msgs.sort(key=lambda m: m['timestamp'], reverse=True)
|
||||||
|
paged = all_msgs[offset: offset + limit]
|
||||||
|
paged.sort(key=lambda m: m['timestamp'])
|
||||||
|
return {
|
||||||
|
"chat": display,
|
||||||
|
"username": username,
|
||||||
|
"is_group": is_group,
|
||||||
|
"count": len(paged),
|
||||||
|
"messages": paged,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def q_search(keyword: str, chats: list[str] | None = None,
|
||||||
|
limit: int = 20, since: int | None = None, until: int | None = None) -> dict:
|
||||||
|
names = _load_names()
|
||||||
|
results: list[dict] = []
|
||||||
|
|
||||||
|
# 构建搜索目标 (db_path, table_name, chat_display, username)
|
||||||
|
targets: list[tuple[str, str, str, str]] = []
|
||||||
|
|
||||||
|
if chats:
|
||||||
|
for chat_name in chats:
|
||||||
|
uname = _resolve_username(chat_name)
|
||||||
|
if not uname:
|
||||||
|
continue
|
||||||
|
for tbl in _find_msg_tables(uname):
|
||||||
|
targets.append((tbl['path'], tbl['table'], names.get(uname, uname), uname))
|
||||||
|
else:
|
||||||
|
for rel_key in MSG_DB_KEYS:
|
||||||
|
path = _db.get(rel_key)
|
||||||
|
if not path:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with closing(sqlite3.connect(path)) as conn:
|
||||||
|
table_rows = conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'"
|
||||||
|
).fetchall()
|
||||||
|
for (tname,) in table_rows:
|
||||||
|
if not re.fullmatch(r'Msg_[0-9a-f]{32}', tname):
|
||||||
|
continue
|
||||||
|
targets.append((path, tname, '', ''))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 按 db_path 分组,减少重复打开
|
||||||
|
by_path: dict[str, list[tuple[str, str, str]]] = {}
|
||||||
|
for db_path, table, display, uname in targets:
|
||||||
|
by_path.setdefault(db_path, []).append((table, display, uname))
|
||||||
|
|
||||||
|
for db_path, table_list in by_path.items():
|
||||||
|
try:
|
||||||
|
with closing(sqlite3.connect(db_path)) as conn:
|
||||||
|
id2u = _load_id2u(conn)
|
||||||
|
for table, display, uname in table_list:
|
||||||
|
clauses = ['message_content LIKE ?']
|
||||||
|
params = [f'%{keyword}%']
|
||||||
|
if since:
|
||||||
|
clauses.append('create_time >= ?'); params.append(since)
|
||||||
|
if until:
|
||||||
|
clauses.append('create_time <= ?'); params.append(until)
|
||||||
|
where = f"WHERE {' AND '.join(clauses)}"
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT local_id, local_type, create_time, real_sender_id,"
|
||||||
|
f" message_content, WCDB_CT_message_content"
|
||||||
|
f" FROM [{table}] {where}"
|
||||||
|
f" ORDER BY create_time DESC LIMIT ?",
|
||||||
|
(*params, limit * 3)
|
||||||
|
).fetchall()
|
||||||
|
is_group = uname and '@chatroom' in uname
|
||||||
|
for local_id, local_type, ts, real_sender_id, content, ct in rows:
|
||||||
|
content = _decompress(content, ct)
|
||||||
|
if content is None:
|
||||||
|
continue
|
||||||
|
sender = _sender_label(real_sender_id, content, is_group or False,
|
||||||
|
uname or '', id2u, names)
|
||||||
|
text = _fmt_content(local_id, local_type, content, is_group or False)
|
||||||
|
# 全局搜索时从 table_name 反推 display(联系人缓存中查)
|
||||||
|
chat_display = display or '未知'
|
||||||
|
results.append({
|
||||||
|
"timestamp": ts,
|
||||||
|
"time": datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M'),
|
||||||
|
"chat": chat_display,
|
||||||
|
"sender": sender,
|
||||||
|
"content": text,
|
||||||
|
"type": _fmt_type(local_type),
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
results.sort(key=lambda r: r['timestamp'], reverse=True)
|
||||||
|
paged = results[:limit]
|
||||||
|
return {"keyword": keyword, "count": len(paged), "results": paged}
|
||||||
|
|
||||||
|
|
||||||
|
def q_contacts(query: str | None = None, limit: int = 50) -> dict:
|
||||||
|
names = _load_names()
|
||||||
|
contacts = [
|
||||||
|
{"username": u, "display": d}
|
||||||
|
for u, d in names.items()
|
||||||
|
if not u.startswith('gh_') # 排除公众号
|
||||||
|
and not u.startswith('biz_') # 排除服务号
|
||||||
|
]
|
||||||
|
if query:
|
||||||
|
low = query.lower()
|
||||||
|
contacts = [c for c in contacts
|
||||||
|
if low in c['display'].lower() or low in c['username'].lower()]
|
||||||
|
contacts.sort(key=lambda c: c['display'])
|
||||||
|
return {"contacts": contacts[:limit], "total": len(contacts)}
|
||||||
|
|
||||||
|
# ─── 实时推送(watch)────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_watch_clients: list[queue.Queue] = []
|
||||||
|
_watch_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _broadcast(event: dict) -> None:
|
||||||
|
line = json.dumps(event, ensure_ascii=False)
|
||||||
|
with _watch_lock:
|
||||||
|
dead = []
|
||||||
|
for q in _watch_clients:
|
||||||
|
try:
|
||||||
|
q.put_nowait(line)
|
||||||
|
except queue.Full:
|
||||||
|
dead.append(q)
|
||||||
|
for q in dead:
|
||||||
|
_watch_clients.remove(q)
|
||||||
|
|
||||||
|
|
||||||
|
def _wal_watcher() -> None:
|
||||||
|
"""后台线程:每 500ms 检测 session.db-wal 的 mtime,有变化时推送新消息"""
|
||||||
|
last_mtime: dict[str, float] = {}
|
||||||
|
last_ts: dict[str, int] = {} # username -> last pushed timestamp
|
||||||
|
initialized = False
|
||||||
|
|
||||||
|
while True:
|
||||||
|
time.sleep(0.5)
|
||||||
|
with _watch_lock:
|
||||||
|
if not _watch_clients:
|
||||||
|
continue
|
||||||
|
|
||||||
|
session_wal = os.path.join(DB_DIR, "session", "session.db-wal")
|
||||||
|
try:
|
||||||
|
mtime = os.path.getmtime(session_wal)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
prev = last_mtime.get(session_wal, 0.0)
|
||||||
|
if mtime == prev:
|
||||||
|
continue
|
||||||
|
last_mtime[session_wal] = mtime
|
||||||
|
|
||||||
|
# 解密 session.db(缓存会处理 mtime,只有真的变了才重新解密)
|
||||||
|
path = _db.get(os.path.join("session", "session.db"))
|
||||||
|
if not path:
|
||||||
|
continue
|
||||||
|
names = _load_names()
|
||||||
|
try:
|
||||||
|
with closing(sqlite3.connect(path)) as conn:
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT username, summary, last_timestamp, last_msg_type, last_msg_sender
|
||||||
|
FROM SessionTable WHERE last_timestamp > 0
|
||||||
|
ORDER BY last_timestamp DESC LIMIT 50
|
||||||
|
""").fetchall()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for username, summary, ts, msg_type, sender in rows:
|
||||||
|
if not initialized:
|
||||||
|
# 第一轮只建立基线,不推送
|
||||||
|
last_ts[username] = ts
|
||||||
|
continue
|
||||||
|
prev_ts = last_ts.get(username, 0)
|
||||||
|
if ts <= prev_ts:
|
||||||
|
continue
|
||||||
|
last_ts[username] = ts
|
||||||
|
|
||||||
|
display = names.get(username, username)
|
||||||
|
is_group = '@chatroom' in username
|
||||||
|
if isinstance(summary, bytes):
|
||||||
|
try:
|
||||||
|
summary = _zstd.decompress(summary).decode('utf-8', errors='replace')
|
||||||
|
except Exception:
|
||||||
|
summary = '(压缩内容)'
|
||||||
|
if isinstance(summary, str) and ':\n' in summary:
|
||||||
|
summary = summary.split(':\n', 1)[1]
|
||||||
|
sender_display = names.get(sender, sender) if sender else ''
|
||||||
|
|
||||||
|
_broadcast({
|
||||||
|
"event": "message",
|
||||||
|
"time": datetime.fromtimestamp(ts).strftime('%H:%M'),
|
||||||
|
"chat": display,
|
||||||
|
"username": username,
|
||||||
|
"is_group": is_group,
|
||||||
|
"sender": sender_display,
|
||||||
|
"content": str(summary or ''),
|
||||||
|
"type": _fmt_type(msg_type),
|
||||||
|
"timestamp": ts,
|
||||||
|
})
|
||||||
|
|
||||||
|
if not initialized:
|
||||||
|
initialized = True
|
||||||
|
|
||||||
|
# ─── 命令路由 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _dispatch(req: dict) -> dict:
|
||||||
|
cmd = req.get("cmd", "")
|
||||||
|
try:
|
||||||
|
if cmd == "ping":
|
||||||
|
return {"ok": True, "pong": True}
|
||||||
|
if cmd == "sessions":
|
||||||
|
return {"ok": True, **q_sessions(int(req.get("limit", 20)))}
|
||||||
|
if cmd == "history":
|
||||||
|
return {"ok": True, **q_history(
|
||||||
|
req["chat"],
|
||||||
|
limit=int(req.get("limit", 50)),
|
||||||
|
offset=int(req.get("offset", 0)),
|
||||||
|
since=req.get("since"),
|
||||||
|
until=req.get("until"),
|
||||||
|
)}
|
||||||
|
if cmd == "search":
|
||||||
|
return {"ok": True, **q_search(
|
||||||
|
req["keyword"],
|
||||||
|
chats=req.get("chats"),
|
||||||
|
limit=int(req.get("limit", 20)),
|
||||||
|
since=req.get("since"),
|
||||||
|
until=req.get("until"),
|
||||||
|
)}
|
||||||
|
if cmd == "contacts":
|
||||||
|
return {"ok": True, **q_contacts(req.get("query"), int(req.get("limit", 50)))}
|
||||||
|
return {"ok": False, "error": f"未知命令: {cmd}"}
|
||||||
|
except KeyError as e:
|
||||||
|
return {"ok": False, "error": f"缺少参数: {e}"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
# ─── Unix Socket Server ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _handle_client(conn: socket.socket) -> None:
|
||||||
|
try:
|
||||||
|
f = conn.makefile('rwb', buffering=0)
|
||||||
|
line = f.readline()
|
||||||
|
if not line:
|
||||||
|
return
|
||||||
|
req = json.loads(line.decode('utf-8'))
|
||||||
|
|
||||||
|
if req.get("cmd") == "watch":
|
||||||
|
# 流式模式:daemon 持续推事件,直到客户端断开
|
||||||
|
q: queue.Queue = queue.Queue(maxsize=500)
|
||||||
|
with _watch_lock:
|
||||||
|
_watch_clients.append(q)
|
||||||
|
_write_line(f, {"event": "connected"})
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
event_line = q.get(timeout=30)
|
||||||
|
f.write((event_line + '\n').encode())
|
||||||
|
f.flush()
|
||||||
|
except queue.Empty:
|
||||||
|
_write_line(f, {"event": "heartbeat"})
|
||||||
|
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
with _watch_lock:
|
||||||
|
try:
|
||||||
|
_watch_clients.remove(q)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
resp = _dispatch(req)
|
||||||
|
_write_line(f, resp)
|
||||||
|
except Exception as e:
|
||||||
|
try:
|
||||||
|
_write_line(conn.makefile('rwb', buffering=0), {"ok": False, "error": str(e)})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _write_line(f, obj: dict) -> None:
|
||||||
|
f.write((json.dumps(obj, ensure_ascii=False) + '\n').encode())
|
||||||
|
f.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def _serve() -> None:
|
||||||
|
if os.path.exists(SOCK_PATH):
|
||||||
|
os.unlink(SOCK_PATH)
|
||||||
|
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
server.bind(SOCK_PATH)
|
||||||
|
os.chmod(SOCK_PATH, 0o600)
|
||||||
|
server.listen(64)
|
||||||
|
_log(f"监听 {SOCK_PATH}")
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
conn, _ = server.accept()
|
||||||
|
threading.Thread(target=_handle_client, args=(conn,), daemon=True).start()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ─── 守护进程化 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _daemonize() -> None:
|
||||||
|
if os.fork() > 0:
|
||||||
|
sys.exit(0)
|
||||||
|
os.setsid()
|
||||||
|
if os.fork() > 0:
|
||||||
|
sys.exit(0)
|
||||||
|
sys.stdin = open(os.devnull, 'r')
|
||||||
|
log_file = open(LOG_PATH, 'a', buffering=1)
|
||||||
|
sys.stdout = log_file
|
||||||
|
sys.stderr = log_file
|
||||||
|
|
||||||
|
# ─── 入口 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main(foreground: bool = False) -> None:
|
||||||
|
if not foreground:
|
||||||
|
_daemonize()
|
||||||
|
|
||||||
|
with open(PID_PATH, 'w') as f:
|
||||||
|
f.write(str(os.getpid()))
|
||||||
|
|
||||||
|
def _cleanup(sig=None, frame=None):
|
||||||
|
for p in (SOCK_PATH, PID_PATH):
|
||||||
|
try:
|
||||||
|
os.unlink(p)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, _cleanup)
|
||||||
|
signal.signal(signal.SIGINT, _cleanup)
|
||||||
|
|
||||||
|
_log("wx-daemon 启动")
|
||||||
|
_log(f"DB_DIR: {DB_DIR}")
|
||||||
|
_log(f"密钥数量: {len(ALL_KEYS)}")
|
||||||
|
|
||||||
|
# 预热:加载联系人 + 解密 session.db(最常用的两个)
|
||||||
|
_load_names()
|
||||||
|
_db.get(os.path.join("session", "session.db"))
|
||||||
|
_log(f"预热完成,联系人 {len(_names or {})} 个")
|
||||||
|
|
||||||
|
# WAL 监听线程
|
||||||
|
threading.Thread(target=_wal_watcher, daemon=True, name='wal-watcher').start()
|
||||||
|
|
||||||
|
# Socket server(阻塞)
|
||||||
|
_serve()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main(foreground='--fg' in sys.argv)
|
||||||
Loading…
Reference in New Issue