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
jackwener 2026-04-16 01:28:22 +08:00
parent 69a2f44240
commit c907cf53fe
4 changed files with 1306 additions and 0 deletions

9
pyproject.toml 100644
View File

@ -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",
]

128
uv.lock 100644
View File

@ -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" },
]

355
wx.py 100644
View File

@ -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()

814
wx_daemon.py 100644
View File

@ -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)