diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..84f8cd2 --- /dev/null +++ b/pyproject.toml @@ -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", +] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..4648c43 --- /dev/null +++ b/uv.lock @@ -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" }, +] diff --git a/wx.py b/wx.py new file mode 100644 index 0000000..9f412e8 --- /dev/null +++ b/wx.py @@ -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() diff --git a/wx_daemon.py b/wx_daemon.py new file mode 100644 index 0000000..6d0a9eb --- /dev/null +++ b/wx_daemon.py @@ -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' 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 ' 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)