diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..c6a693a --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,19 @@ +{ + "permissions": { + "allow": [ + "Bash(grep -E \"\\\\.py$|\\\\.md$\")", + "Bash(git checkout:*)", + "Bash(python3 -c \"import ast; ast.parse\\(open\\(''wx_daemon.py''\\).read\\(\\)\\); print\\(''wx_daemon.py OK''\\)\")", + "Bash(python3 -c \"import ast; ast.parse\\(open\\(''wx.py''\\).read\\(\\)\\); print\\(''wx.py OK''\\)\")", + "Bash(pip install:*)", + "Bash(pip show:*)", + "Bash(pip3 install:*)", + "Bash(python3 -c \"import click; print\\(''click'', click.__version__\\)\")", + "Bash(python3 wx.py --help)", + "Bash(python3 wx.py sessions --help)", + "Bash(python3 -c \"import sys; print\\(sys.executable\\)\")", + "Bash(uv pip:*)", + "Bash(uv venv:*)" + ] + } +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..61dca8b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,70 @@ +name: Release + +on: + push: + tags: ['v*'] + workflow_dispatch: + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + target: aarch64-apple-darwin + name: wx-macos-arm64 + - os: macos-latest + target: x86_64-apple-darwin + name: wx-macos-x86_64 + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + name: wx-linux-x86_64 + - os: windows-latest + target: x86_64-pc-windows-msvc + name: wx-windows-x86_64.exe + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo- + + - name: Build release + run: cargo build --release --locked --target ${{ matrix.target }} + + - name: Rename binary (Unix) + if: matrix.os != 'windows-latest' + run: | + cp target/${{ matrix.target }}/release/wx ${{ matrix.name }} + + - name: Rename binary (Windows) + if: matrix.os == 'windows-latest' + run: | + copy target\${{ matrix.target }}\release\wx.exe ${{ matrix.name }} + + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.name }} + path: ${{ matrix.name }} + + - name: Upload to GitHub Release + uses: softprops/action-gh-release@v2 + if: startsWith(github.ref, 'refs/tags/') + with: + files: ${{ matrix.name }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index db821c3..056b740 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,5 @@ __pycache__/ # OS .DS_Store Thumbs.db +find_all_keys_macos +.claude/worktrees/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..683b414 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1332 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "doctest-file" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2db04e74f0a9a93103b50e90b96024c9b2bdca8bce6a632ec71b88736d3d359" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "interprocess" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6be5e5c847dbdb44564bd85294740d031f4f8aeb3464e5375ef7141f7538db69" +dependencies = [ + "doctest-file", + "futures-core", + "libc", + "recvmsg", + "tokio", + "widestring", + "windows-sys 0.52.0", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "recvmsg" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "wx" +version = "0.1.0" +dependencies = [ + "aes", + "anyhow", + "cbc", + "chrono", + "clap", + "dirs", + "hmac", + "interprocess", + "libc", + "md5", + "pbkdf2", + "regex", + "rusqlite", + "serde", + "serde_json", + "sha2", + "tokio", + "windows", + "zstd", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..fc17ffb --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,68 @@ +[package] +name = "wx" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "wx" +path = "src/main.rs" + +[dependencies] +# CLI +clap = { version = "4", features = ["derive"] } + +# 异步 +tokio = { version = "1", features = ["full"] } + +# 序列化 +serde = { version = "1", features = ["derive"] } +serde_json = "=1.0.140" + +# SQLite +rusqlite = { version = "0.31", features = ["bundled"] } + +# 加密 +aes = "0.8" +cbc = { version = "0.1", features = ["alloc"] } +hmac = "0.12" +sha2 = "0.10" +pbkdf2 = "0.12" + +# 解压 +zstd = "0.13" + +# IPC (Unix socket + Windows named pipe 统一) +interprocess = { version = "2", features = ["tokio"] } + +# 错误处理 +anyhow = "1" + +# 时间 +chrono = { version = "0.4", features = ["serde"] } + +# 跨平台路径 +dirs = "5" + +# MD5 (联系人表名 Msg_) +md5 = "0.7" + +# 正则表达式 +regex = "1" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[target.'cfg(target_os = "windows")'.dependencies] +windows = { version = "0.58", features = [ + "Win32_System_Diagnostics_Debug", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_Threading", + "Win32_Foundation", + "Win32_System_Memory", +] } + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +strip = true diff --git a/README.md b/README.md index 8279fae..4877f6f 100644 --- a/README.md +++ b/README.md @@ -1,269 +1,178 @@ -# WeChat 4.x Database Decryptor +# wx-cli -微信 4.0 (Windows、MacOS、Linux) 本地数据库解密工具。从运行中的微信进程内存提取加密密钥,解密所有 SQLCipher 4 加密数据库,并提供实时消息监听。 +微信 4.x (macOS) 本地数据 CLI 工具。从运行中的微信进程内存提取加密密钥,后台常驻 daemon 持久缓存解密数据库,CLI 毫秒级响应。 -## 更新日志 +## 架构 -## 防失联tg: https://t.me/wechat_decrypt +``` +wx (CLI) ──Unix socket──▶ wx-daemon (后台进程) + │ + ┌─────────┼─────────┐ + DBCache 联系人缓存 WAL 监听 + (mtime 感知) (500ms polling) +``` -### 2025-03-03 — 富媒体内容 & 组合消息修复 +- **wx-daemon**:后台常驻,持有解密后的 DB 热缓存,首次解密后跨重启复用(mtime 不变则不重解密) +- **wx (CLI)**:发 JSON 请求到 Unix socket,获得响应后格式化输出;首次调用自动启动 daemon -- **表情包内联显示**: 自动从 emoticon.db 构建 MD5→CDN 映射,支持自定义表情(NonStore)和商店表情(Store),CDN 下载后本地缓存 -- **富媒体内容解析**: 链接卡片(type 49)、文件、视频号、小程序、引用回复、位置分享等在 Web UI 中完整渲染 -- **文字+图片组合消息不再丢失**: 修复同时发送文字和图片时只显示最后一条的问题(前端去重 key 增加消息类型) -- **隐藏消息检测**: 新增 `_check_hidden_messages` 机制,session.db 只保存最后一条消息摘要,现在会异步查 message DB 找回同一秒内的其他消息 -- **MonitorDBCache 线程安全**: 引入 per-key 锁,防止多线程并发解密同一数据库导致文件损坏 -- **Web UI 改进**: 消息气泡样式优化、群聊发送者显示、图片缩略图点击放大 - -## 原理 - -微信 4.0 使用 SQLCipher 4 加密本地数据库: -- **加密算法**: AES-256-CBC + HMAC-SHA512 -- **KDF**: PBKDF2-HMAC-SHA512, 256,000 iterations -- **页面大小**: 4096 bytes, reserve = 80 (IV 16 + HMAC 64) -- **每个数据库有独立的 salt 和 enc_key** - -WCDB (微信的 SQLCipher 封装) 会在进程内存中缓存派生后的 raw key,格式为 `x'<64hex_enc_key><32hex_salt>'`。三个平台(Windows / Linux / macOS)均可通过扫描进程内存匹配此模式,再通过 HMAC 校验 page 1 确认密钥正确性。 - -## 使用方法 +## 快速开始 ### 环境要求 -- Python 3.10+ -- 微信 4.x -- `pip install -r requirements.txt` - -Windows: - -- Windows 10/11 -- 微信正在运行 -- 需要管理员权限(读取进程内存) - -Linux: - -- 64-bit Linux -- 需要 root 权限或 `CAP_SYS_PTRACE`(读取 `/proc//mem`) -- `db_dir` 默认类似 `~/Documents/xwechat_files//db_storage` - -### 安装依赖 - -```bash -pip install -r requirements.txt -``` - -Windows 如果遇到权限不足或全局环境不可写,可以改用: - -```bash -py -m pip install --user -r requirements.txt -``` - -如果需要读取受保护的进程或把依赖安装到系统 Python,也可能需要以管理员身份打开终端。 - -### 快速开始 - -Windows: - -```bash -python main.py -python main.py decrypt -``` - -Linux: - -```bash -python3 main.py decrypt -``` - -程序会自动完成:配置检测 → 内存扫描提取密钥 → 解密。首次运行会自动检测微信数据目录并生成 `config.json`。微信只要在运行中即可,无需重启或重新登录。 - -如果自动检测失败(例如微信安装在非默认位置),手动创建 `config.json`: -```json -{ - "db_dir": "D:\\xwechat_files\\你的微信ID\\db_storage", - "keys_file": "all_keys.json", - "decrypted_dir": "decrypted", - "wechat_process": "Weixin.exe" -} -``` - -Linux 版 `config.json` 示例: - -```json -{ - "db_dir": "/home/yourname/Documents/xwechat_files/your_wxid/db_storage", - "keys_file": "all_keys.json", - "decrypted_dir": "decrypted", - "wechat_process": "wechat" -} -``` - -`db_dir` 路径:Windows 可在微信设置 → 文件管理中找到;Linux 默认在 `~/Documents/xwechat_files//db_storage`。 - -### Web UI 说明 - -`python main.py` 启动后打开 http://localhost:5678 查看实时消息流。 - -- 30ms 轮询 WAL 文件变化 (mtime) -- 检测到变化后全量解密 + WAL patch (~70ms) -- SSE 实时推送到浏览器 -- 总延迟约 100ms -- **图片消息内联预览**(支持旧 XOR / V1 / V2 三种 .dat 加密格式) - -#### HTTP API - -| 端点 | 说明 | -|------|------| -| `GET /api/history` | 最近消息列表 (JSON) | -| `GET /api/history?chat=群名` | 按群名/用户名过滤消息 | -| `GET /api/history?since=1712000000` | 增量拉取(返回该时间戳之后的消息) | -| `GET /api/history?chat=群名&since=ts&limit=100` | 参数可组合使用 | -| `GET /api/tags` | 所有联系人标签及成员 (JSON) | -| `GET /api/tags?name=同事` | 按标签名过滤 | -| `GET /stream` | SSE 实时消息推送 | - -将特定群消息存到自己的数据库:监听 `/stream` 或轮询 `/api/history?chat=群名&since=上次时间戳`,写入即可。 - -### MCP Server (Claude AI 集成) - -将微信数据查询能力接入 [Claude Code](https://claude.ai/claude-code),让 AI 直接读取你的微信消息。 - -```bash -pip install -r requirements.txt -``` - -注册到 Claude Code: - -```bash -claude mcp add wechat -- python C:\Users\你的用户名\wechat-decrypt\mcp_server.py -``` - -或手动编辑 `~/.claude.json`: - -```json -{ - "mcpServers": { - "wechat": { - "type": "stdio", - "command": "python", - "args": ["C:\\Users\\你的用户名\\wechat-decrypt\\mcp_server.py"] - } - } -} -``` - -注册后在 Claude Code 中即可使用以下工具: - -| Tool | 功能 | -|------|------| -| `get_recent_sessions(limit)` | 最近会话列表(含消息摘要、未读数) | -| `get_chat_history(chat_name, limit, offset, start_time, end_time)` | 指定聊天的消息记录,支持时间范围和分页 | -| `search_messages(keyword, chat_name, start_time, end_time, limit, offset)` | 统一搜索消息;支持全库、单个聊天对象、多个聊天对象、时间范围和分页 | -| `get_contacts(query, limit)` | 搜索/列出联系人 | -| `get_contact_tags()` | 列出所有联系人标签及成员数量 | -| `get_tag_members(tag_name)` | 获取指定标签下的所有联系人,支持模糊匹配 | -| `get_new_messages()` | 获取自上次调用以来的新消息 | - -前置条件:需要先运行 `python main.py` 或 `python find_all_keys.py` 完成密钥提取。 - -说明:`search_messages` 的 `limit` 最大为 `500`;`get_chat_history` 支持更大的 `limit`,但消息很多时仍建议配合 `offset` 分页读取。 - -**[查看使用案例 →](USAGE.md)** - -### 图片解密 (V2 格式) - -微信 4.0 (2025-08+) 的 .dat 图片文件使用 AES-128-ECB + XOR 混合加密 (V2 格式)。AES 密钥需要从运行中的微信进程内存中提取: - -```bash -# 1. 在微信中打开查看 2-3 张图片(点击看大图) -# 2. 立即运行密钥提取(持续监控版): -python find_image_key_monitor.py - -# 或单次扫描版: -python find_image_key.py -``` - -密钥会自动保存到 `config.json` 的 `image_aes_key` 字段。之后 `monitor_web.py` 启动时会自动加载密钥,图片消息将显示内联预览。 - -> **注意**: AES 密钥仅在微信查看图片时临时加载到内存中。如果扫描未找到密钥,请先在微信中查看几张图片,然后立即重新运行脚本。 - -## 文件说明 - -| 文件 | 说明 | -|------|------| -| `main.py` | **一键启动入口** — 自动配置、提取密钥、启动服务 | -| `config.py` | 配置加载器(自动检测微信数据目录) | -| `find_all_keys.py` | 平台分发入口(Windows / Linux) | -| `find_all_keys_windows.py` | Windows 版内存扫描提 key | -| `find_all_keys_linux.py` | Linux 版内存扫描提 key | -| `decrypt_db.py` | 全量解密所有数据库 | -| `mcp_server.py` | MCP Server,让 Claude AI 查询微信数据 | -| `monitor_web.py` | 实时消息监听 (Web UI + SSE + 图片预览) | -| `monitor.py` | 实时消息监听 (命令行) | -| `decode_image.py` | 图片 .dat 文件解密模块 (XOR / V1 / V2) | -| `find_image_key.py` | 从微信进程内存提取图片 AES 密钥 | -| `find_image_key_monitor.py` | 持续监控版密钥提取(推荐) | -| `latency_test.py` | 延迟测量诊断工具 | -| `find_all_keys_macos.c` | macOS 版内存密钥扫描器 (C, Mach VM API) | - -## 技术细节 - -### WAL 处理 - -微信使用 SQLite WAL 模式,WAL 文件是**预分配固定大小** (4MB)。检测变化时: -- 不能用文件大小 (永远不变) -- 使用 mtime 检测写入 -- 解密 WAL frame 时需校验 salt 值,跳过旧周期遗留的 frame - -### 图片 .dat 加密格式 - -微信本地图片 (.dat) 有三种加密格式: - -| 格式 | 时期 | Magic | 加密方式 | 密钥来源 | -|------|------|-------|---------|---------| -| 旧 XOR | ~2025-07 | 无 | 单字节 XOR | 自动检测 (对比 magic bytes) | -| V1 | 过渡期 | `07 08 V1 08 07` | AES-ECB + XOR | 固定 key: `cfcd208495d565ef` | -| V2 | 2025-08+ | `07 08 V2 08 07` | AES-128-ECB + XOR | 从进程内存提取 | - -V2 文件结构: `[6B signature] [4B aes_size LE] [4B xor_size LE] [1B padding]` + `[AES-ECB encrypted] [raw unencrypted] [XOR encrypted]` - -### 数据库结构 - -解密后包含约 26 个数据库: -- `session/session.db` - 会话列表 (最新消息摘要) -- `message/message_*.db` - 聊天记录 -- `contact/contact.db` - 联系人 -- `media_*/media_*.db` - 媒体文件索引 -- 其他: head_image, favorite, sns, emoticon 等 - -## macOS 数据库密钥扫描 (WeChat 4.x) - -macOS 版微信 4.x 使用 SQLCipher 4 加密本地数据库,密钥格式为 `x'<64hex_key><32hex_salt>'`。C 版扫描器通过 Mach VM API 扫描微信进程内存提取密钥。 - -### 前置条件 - - macOS (Apple Silicon / Intel) -- WeChat 4.x (macOS 版) -- Xcode Command Line Tools: `xcode-select --install` -- 微信需要 ad-hoc 签名(或安装了防撤回补丁): - `sudo codesign --force --deep --sign - /Applications/WeChat.app` +- WeChat 4.x (macOS 版,需 ad-hoc 签名,见下文) +- Python 3.12+ +- [uv](https://docs.astral.sh/uv/)(Python 包管理) +- Xcode Command Line Tools:`xcode-select --install` -### 编译和使用 +### 安装 ```bash -# 编译 -cc -O2 -o find_all_keys_macos find_all_keys_macos.c -framework Foundation - -# 运行(自动查找微信进程、扫描内存、匹配 DB salt) -sudo ./find_all_keys_macos - -# 或指定 PID -sudo ./find_all_keys_macos +git clone git@github.com:jackwener/wx-cli.git +cd wx-cli +uv sync ``` -输出 `all_keys.json`,格式兼容 `decrypt_db.py`,可直接用于解密: +### 初始化(首次使用) + +微信需要 ad-hoc 签名才能被扫描内存: ```bash -python3 decrypt_db.py +sudo codesign --force --deep --sign - /Applications/WeChat.app +``` + +然后打开微信并登录,运行初始化: + +```bash +uv run python wx.py init +``` + +`wx init` 自动完成: +1. 检测微信数据目录(`~/Library/Containers/.../xwechat_files//db_storage`) +2. 编译 C 内存扫描器(如未编译) +3. `sudo` 扫描微信进程内存,提取所有数据库密钥 → `all_keys.json` +4. 更新 `config.json` + +### 使用 + +```bash +# 最近会话 +uv run python wx.py sessions + +# 聊天记录 +uv run python wx.py history "张三" +uv run python wx.py history "AI群" --since 2026-04-01 --until 2026-04-15 + +# 搜索消息 +uv run python wx.py search "Claude" +uv run python wx.py search "会议" --in "工作群" --since 2026-01-01 + +# 联系人 +uv run python wx.py contacts +uv run python wx.py contacts -q "李" + +# 导出聊天记录 +uv run python wx.py export "张三" --format markdown -o chat.md +uv run python wx.py export "AI群" --since 2026-01-01 --format json -o chat.json + +# 实时监听新消息(Ctrl+C 退出) +uv run python wx.py watch +uv run python wx.py watch --chat "AI交流群" +uv run python wx.py watch --json | jq .content + +# daemon 管理 +uv run python wx.py daemon status +uv run python wx.py daemon stop +uv run python wx.py daemon logs +uv run python wx.py daemon logs --follow +``` + +> **注**:daemon 在首次 CLI 调用时自动启动,无需手动运行。 + +### 可选:设置别名 + +```bash +echo 'alias wx="uv run --directory /path/to/wx-cli python wx.py"' >> ~/.zshrc +source ~/.zshrc + +# 之后可以直接用 +wx sessions +wx history "张三" +wx watch +``` + +## 命令参考 + +### `wx init [--force]` +首次初始化:检测数据目录、编译扫描器、提取密钥、写入配置。`--force` 强制重新扫描(微信更新后使用)。 + +### `wx sessions [-n N] [--json]` +列出最近 N 个会话(默认 20),显示未读数、最后消息摘要。 + +### `wx history CHAT [-n N] [--offset N] [--since DATE] [--until DATE] [--json]` +查看指定聊天的消息记录。`DATE` 格式:`YYYY-MM-DD` 或 `YYYY-MM-DD HH:MM`。 + +### `wx search KEYWORD [--in CHAT]... [-n N] [--since DATE] [--until DATE] [--json]` +全库搜索消息,`--in` 可指定多个聊天范围。 + +### `wx contacts [-q QUERY] [-n N] [--json]` +列出或搜索联系人。 + +### `wx export CHAT [-f FORMAT] [-o FILE] [-n N] [--since DATE] [--until DATE]` +导出聊天记录。`-f` 支持 `markdown`(默认)、`txt`、`json`。`-o` 指定输出文件,不指定则输出到 stdout。 + +### `wx watch [--chat CHAT] [--json]` +实时监听新消息(WAL 变化推送,约 500ms 延迟)。`--json` 输出 JSON lines,方便 `jq` 处理。 + +### `wx daemon status / stop / logs [-f] [-n N]` +管理后台 daemon。`logs --follow` 等同 `tail -f`。 + +## 原理 + +### 密钥提取 + +微信 4.x 使用 SQLCipher 4 加密本地数据库: +- **加密**:AES-256-CBC + HMAC-SHA512 +- **KDF**:PBKDF2-HMAC-SHA512,256,000 次迭代 +- **页结构**:4096 bytes/page,reserve = 80(IV 16 + HMAC 64) + +WCDB 在进程内存中缓存派生后的 raw key,格式为 `x'<64hex_enc_key><32hex_salt>'`。C 扫描器(`find_all_keys_macos.c`)通过 macOS Mach VM API 扫描微信进程内存,匹配此模式,再用 HMAC 校验 page 1 确认密钥正确性,输出到 `all_keys.json`。 + +### DBCache(mtime 感知缓存) + +daemon 首次解密后将结果(及 DB/WAL 的 mtime)持久化到 `~/.wechat-cli/cache/_mtimes.json`。重启时若 mtime 未变,直接复用已解密文件,无需重新解密。 + +### WAL 监听 + +微信使用 SQLite WAL 模式(WAL 文件固定预分配 4MB,不能靠文件大小判断变化)。daemon 每 500ms 检测 `session.db-wal` 的 mtime,有变化时重新解密并广播新消息给所有 `watch` 客户端。 + +### 数据文件路径 + +``` +~/.wechat-cli/ +├── daemon.sock # Unix socket +├── daemon.pid # PID 文件 +├── daemon.log # daemon 日志 +└── cache/ + ├── _mtimes.json # mtime 持久化索引 + └── *.db # 解密后的数据库缓存 +``` + +## 数据库结构 + +解密后约 26 个数据库: + +| 路径 | 内容 | +|------|------| +| `session/session.db` | 会话列表(最新消息摘要、未读数) | +| `message/message_*.db` | 聊天记录(按 `Msg_` 分表) | +| `contact/contact.db` | 联系人(username、nick_name、remark) | +| `media_*/media_*.db` | 媒体文件索引 | + +## 测试 + +```bash +uv run python -m pytest tests/ -v ``` ## 免责声明 diff --git a/USAGE.md b/USAGE.md deleted file mode 100644 index 0b356ce..0000000 --- a/USAGE.md +++ /dev/null @@ -1,234 +0,0 @@ -# Usage Examples / 使用案例 - -WeChat MCP Server 注册到 Claude Code 后,可以直接在对话中查询微信数据。以下是真实使用截图(敏感信息已打码)。 - -## 1. 查看最近会话 - -``` -> 帮我看看微信最近的会话 -``` - -Claude 自动调用 `get_recent_sessions`: - -``` -最近 10 个会话: - -[02-28 16:26] A]██交流群 [群] (7条未读) - 文本: 阿██: 打开就是那么大的一个 - -[02-28 16:26] ██俱乐部 [群] (105条未读) - 文本: S██: 死了就换另外一个 - -[02-28 16:26] ██Sol██社区 [群] (3条未读) - 文本: 洋██: 加密币已成中登资产,还没避险属性 - -[02-28 16:25] ██健身群 [群] (18条未读) - 文本: 风██: 不强求,多的就留着。刚需就洗了。 - -[02-28 16:24] ██社保 (24条未读) - 链接/文件: @年后换工作的你,社保怎么处理?看这里! - -[02-28 16:22] 文件传输助手 - 图片: (无内容) -``` - -## 2. 查看群聊记录 - -``` -> 帮我看看██群在聊啥 -``` - -Claude 调用 `get_chat_history`,然后自动分析总结: - -``` -██群今日话题: - -1. AI工具讨论 (上午) - 群友对微信消息监听工具感兴趣,问了用什么模型、token贵不贵。 - -2. A股炒股 (下午) - ██想让朋友帮操作A股做T,群友一致劝阻——"赚了分他,亏了算你的" - -3. 基金持仓 (傍晚) - ██手痒又买了有色基金 - -活跃度排名: -| 排名 | 昵称 | 发言数 | -|------|------|--------| -| 1 | ██ | ~35条 | -| 2 | ██ | ~15条 | -| 3 | ██ | ~15条 | -``` - -## 3. 搜索消息 - -``` -> 搜一下谁提过"claude" -``` - -Claude 调用 `search_messages(keyword="claude")`: - -``` -搜索 "claude" 找到 20 条结果: - -[2026-02-28 10:25] [██AI群] ██: max套餐,就用opus模型 -[2026-02-28 10:31] [██AI群] ██: 我小龙虾也没用api接入的 -[2026-02-24 21:58] [██AI群] ██: Claude max 套餐给我干没了 -[2026-02-24 22:16] [██AI群] ██: 最简单的就是装claude -... -``` - -## 4. 时间范围 + 分页查看聊天记录 - -``` -> 帮我看一下██群 3 月 1 日到 3 月 7 日的聊天,先给我前 20 条 -``` - -Claude 可以调用: - -```python -get_chat_history( - chat_name="██群", - start_time="2026-03-01", - end_time="2026-03-07", - limit=20, - offset=0, -) -``` - -下一页: - -```python -get_chat_history( - chat_name="██群", - start_time="2026-03-01", - end_time="2026-03-07", - limit=20, - offset=20, -) -``` - -## 5. 搜索指定联系人/群聊在某个时间段内的消息 - -``` -> 帮我搜一下██群这周谁提到过 Claude -``` - -Claude 可以调用统一接口: - -```python -search_messages( - keyword="Claude", - chat_name="██群", - start_time="2026-03-01", - end_time="2026-03-07", - limit=20, - offset=0, -) -``` - -## 6. 多个联系人/群聊联合搜索 - -``` -> 帮我看看联系人A、联系人B 和 ██项目群 这周谁提到过“项目” -``` - -Claude 可以调用统一接口: - -```python -search_messages( - keyword="项目", - chat_name=["联系人A", "联系人B", "██项目群"], - start_time="2026-03-01", - end_time="2026-03-07", - limit=20, - offset=0, -) -``` - -如果某些名字没匹配到联系人,或没有对应消息表,结果里会单独说明。 - -## 7. 搜索联系人 - -``` -> 帮我找一下姓张的联系人 -``` - -Claude 调用 `get_contacts(query="张")`: - -``` -找到 12 个联系人(搜索: 张): - -wxid_████ 备注: 张██ 昵称: ██ -wxid_████ 备注: 张██ 昵称: 小██ -████@chatroom 昵称: 张██的工作群 -... -``` - -## 8. 获取新消息 - -``` -> 有没有新消息 -``` - -Claude 调用 `get_new_messages()`: - -``` -当前 5 个未读会话: - -[16:26] ██交流群 [群] (7条未读): 打开就是那么大的一个 -[16:26] ██俱乐部 [群] (105条未读): 死了就换另外一个 -[16:25] ██健身群 [群] (18条未读): 不强求,多的就留着 -[16:24] ██社保 (24条未读): @年后换工作的你... -[16:22] ██群 [群] (19条未读): (图片) -``` - -## 9. 高级用法:群聊分析 - -Claude 可以获取大量消息后自动分析活跃度、话题分布、关键人物: - -``` -> 帮我分析一下██群最近一周的情况 -``` - -Claude 会调用 `get_chat_history(chat_name="██群", limit=500)` 获取消息,然后输出。消息很多时,也可以把 `limit` 设得更大,或配合 `offset` 分页读取: - -``` -## ██群最近一周分析 - -### 话题分布 -| 话题 | 占比 | -|----------|-------| -| AI工具 | ~40% | -| 币圈行情 | ~25% | -| 时事讨论 | ~20% | -| 闲聊 | ~15% | - -### 活跃度排名 -| 排名 | 昵称 | 发言数 | 角色 | -|------|------|--------|------------| -| 1 | ██ | ~90条 | 技术分享者 | -| 2 | ██ | ~55条 | 深度讨论 | -| 3 | ██ | ~25条 | 新闻搬运 | - -### 群氛围 -这个群正在从██交流群转型成AI工具交流群... -``` - ---- - -## Setup / 配置方法 - -```bash -# 1. 安装依赖 -pip install -r requirements.txt - -# 2. 注册到 Claude Code -claude mcp add wechat -- python C:\path\to\mcp_server.py - -# 3. 在 Claude Code 中直接对话 -claude -> 看看微信最近谁找我了 -``` - -前置条件:需要先运行 `find_all_keys.py` 提取密钥,并配置 `config.json`。详见 [README.md](README.md)。 diff --git a/decode_image.py b/decode_image.py deleted file mode 100644 index f9edbc4..0000000 --- a/decode_image.py +++ /dev/null @@ -1,464 +0,0 @@ -r""" -微信图片 .dat 文件解密模块 - -支持两种加密格式: - - 旧格式: 单字节 XOR 加密,key 通过对比文件头与已知图片 magic bytes 自动检测 - - V2 格式 (2025-08+): AES-128-ECB + XOR 混合加密,需要从微信进程内存提取 AES key - -V2 文件结构: - [6B signature: 07 08 V2 08 07] [4B aes_size LE] [4B xor_size LE] [1B padding] - [aligned_aes_size bytes AES-ECB] [raw_data] [xor_size bytes XOR] - -文件路径格式: - D:\xwechat_files\\msg\attach\\\Img\[_t|_h].dat - -映射链: - message_*.db (local_id) → message_resource.db (packed_info 含 MD5) → .dat 文件 → 解密 -""" - -import os -import sys -import glob -import hashlib -import sqlite3 -import struct - -# V2 格式完整 magic (6 bytes) -V2_MAGIC = b'\x07\x08\x56\x32' # 前 4 字节用于快速检测 -V2_MAGIC_FULL = b'\x07\x08V2\x08\x07' # 完整 6 字节签名 -V1_MAGIC_FULL = b'\x07\x08V1\x08\x07' # V1 签名 (固定 key) - -# 常见图片格式的 magic bytes (按长度降序排列,避免短 magic 假阳性) -IMAGE_MAGIC = { - 'png': [0x89, 0x50, 0x4E, 0x47], - 'gif': [0x47, 0x49, 0x46, 0x38], - 'tif': [0x49, 0x49, 0x2A, 0x00], # little-endian TIFF - 'webp': [0x52, 0x49, 0x46, 0x46], # RIFF header - 'jpg': [0xFF, 0xD8, 0xFF], - # BMP 只有 2 字节 magic,容易假阳性,需要额外验证 -} - - -def is_v2_format(dat_path): - """检测是否是微信 V2 加密格式 (2025-08+)""" - try: - with open(dat_path, 'rb') as f: - magic = f.read(4) - return magic == V2_MAGIC - except (OSError, IOError): - return False - - -def detect_xor_key(dat_path): - """通过对比文件头和已知图片 magic bytes 自动检测 XOR key - - 返回 key (int) 或 None。V2 格式文件返回 None。 - """ - with open(dat_path, 'rb') as f: - header = f.read(16) - - if len(header) < 4: - return None - - # V2 新格式无法用 XOR 解密 - if header[:4] == V2_MAGIC: - return None - - # 先尝试 3+ 字节 magic 的格式(可靠匹配) - for fmt, magic in IMAGE_MAGIC.items(): - key = header[0] ^ magic[0] - match = True - for i in range(1, len(magic)): - if i >= len(header): - break - if (header[i] ^ key) != magic[i]: - match = False - break - if match: - return key - - # 最后尝试 BMP (2 字节 magic,需要额外验证) - bmp_magic = [0x42, 0x4D] - key = header[0] ^ bmp_magic[0] - if len(header) >= 2 and (header[1] ^ key) == bmp_magic[1]: - # 额外验证: XOR 解密后检查 BMP file size 和 offset 字段 - if len(header) >= 14: - dec = bytes(b ^ key for b in header[:14]) - bmp_size = struct.unpack_from('= 12 and header_bytes[8:12] == b'WEBP': - return 'webp' - if header_bytes[:4] == bytes([0x49, 0x49, 0x2A, 0x00]): - return 'tif' - return 'bin' - - -def v2_decrypt_file(dat_path, out_path=None, aes_key=None, xor_key=0x88): - """解密 V2 格式 .dat 文件 (AES-ECB + XOR) - - Args: - dat_path: V2 .dat 文件路径 - out_path: 输出路径 (None 则自动命名) - aes_key: 16 字节 AES key (bytes 或 str) - xor_key: XOR key (int, 默认 0x88) - - Returns: - (output_path, format) 或 (None, None) - """ - if aes_key is None: - return None, None - - from Crypto.Cipher import AES - from Crypto.Util import Padding - - # 确保 key 是 16 字节 bytes - if isinstance(aes_key, str): - aes_key = aes_key.encode('ascii')[:16] - if len(aes_key) < 16: - return None, None - - with open(dat_path, 'rb') as f: - data = f.read() - - if len(data) < 15: - return None, None - - # 解析 header - sig = data[:6] - if sig not in (V2_MAGIC_FULL, V1_MAGIC_FULL): - return None, None - - aes_size, xor_size = struct.unpack_from('= aes_size,向上对齐到 16 - # 当 aes_size 是 16 的倍数时,还需要加 16 (完整填充块) - aligned_aes_size = aes_size - aligned_aes_size -= ~(~aligned_aes_size % 16) # 同 wx-dat 的公式 - - offset = 15 - if offset + aligned_aes_size > len(data): - return None, None - - # AES-ECB 解密 - aes_data = data[offset:offset + aligned_aes_size] - try: - cipher = AES.new(aes_key[:16], AES.MODE_ECB) - dec_aes = Padding.unpad(cipher.decrypt(aes_data), AES.block_size) - except (ValueError, KeyError): - return None, None - offset += aligned_aes_size - - # Raw 部分 (不加密) - raw_end = len(data) - xor_size - raw_data = data[offset:raw_end] if offset < raw_end else b'' - offset = raw_end - - # XOR 部分 - xor_data = data[offset:] - dec_xor = bytes(b ^ xor_key for b in xor_data) - - decrypted = dec_aes + raw_data + dec_xor - fmt = detect_image_format(decrypted[:16]) - - # wxgf (HEVC 裸流) 格式 - if decrypted[:4] == b'wxgf': - fmt = 'hevc' - - if out_path is None: - base = os.path.splitext(dat_path)[0] - for suffix in ('_t', '_h'): - if base.endswith(suffix): - base = base[:-len(suffix)] - break - out_path = f"{base}.{fmt}" - - os.makedirs(os.path.dirname(out_path), exist_ok=True) - with open(out_path, 'wb') as f: - f.write(decrypted) - - return out_path, fmt - - -def xor_decrypt_file(dat_path, out_path=None, key=None): - """解密单个 .dat 文件,返回 (output_path, format)""" - if key is None: - key = detect_xor_key(dat_path) - if key is None: - return None, None - - with open(dat_path, 'rb') as f: - data = f.read() - - decrypted = bytes(b ^ key for b in data) - fmt = detect_image_format(decrypted[:16]) - - if out_path is None: - base = os.path.splitext(dat_path)[0] - # 去掉 _t, _h 后缀 - for suffix in ('_t', '_h'): - if base.endswith(suffix): - base = base[:-len(suffix)] - break - out_path = f"{base}.{fmt}" - - os.makedirs(os.path.dirname(out_path), exist_ok=True) - with open(out_path, 'wb') as f: - f.write(decrypted) - - return out_path, fmt - - -def decrypt_dat_file(dat_path, out_path=None, aes_key=None, xor_key=0x88): - """智能解密 .dat 文件 (自动检测格式) - - Args: - dat_path: .dat 文件路径 - out_path: 输出路径 - aes_key: V2 格式的 AES key (str 或 bytes, 16 字节) - xor_key: XOR key (int) - - Returns: - (output_path, format) 或 (None, None) - """ - with open(dat_path, 'rb') as f: - head = f.read(6) - - # V2 新格式 - if head == V2_MAGIC_FULL: - return v2_decrypt_file(dat_path, out_path, aes_key, xor_key) - - # V1 格式 (固定 AES key) - if head == V1_MAGIC_FULL: - return v2_decrypt_file(dat_path, out_path, b'cfcd208495d565ef', xor_key) - - # 旧 XOR 格式 - return xor_decrypt_file(dat_path, out_path) - - -def extract_md5_from_packed_info(blob): - """从 message_resource.db 的 packed_info (protobuf) 中提取文件 MD5 - - 格式: ... \\x12\\x22\\x0a\\x20 + 32 字节 ASCII hex MD5 ... - """ - if not blob or not isinstance(blob, bytes): - return None - - # 查找 protobuf 标记 - marker = b'\x12\x22\x0a\x20' - idx = blob.find(marker) - if idx >= 0 and idx + len(marker) + 32 <= len(blob): - md5_bytes = blob[idx + len(marker): idx + len(marker) + 32] - try: - md5_str = md5_bytes.decode('ascii') - # 验证是合法的 hex 字符串 - int(md5_str, 16) - return md5_str - except (UnicodeDecodeError, ValueError): - pass - - # 备用方案:扫描 32 字节连续 hex 字符 - hex_chars = set(b'0123456789abcdef') - i = 0 - while i <= len(blob) - 32: - if blob[i] in hex_chars: - candidate = blob[i:i+32] - if all(b in hex_chars for b in candidate): - try: - return candidate.decode('ascii') - except UnicodeDecodeError: - pass - i += 32 - else: - i += 1 - - return None - - -class ImageResolver: - """封装从 local_id 到图片文件的完整解析链""" - - def __init__(self, wechat_base_dir, decoded_image_dir, cache): - """ - Args: - wechat_base_dir: 微信数据根目录 (如 D:\\xwechat_files\\) - decoded_image_dir: 解密图片输出目录 - cache: DBCache 实例,用于解密 message_resource.db - """ - self.base_dir = wechat_base_dir - self.attach_dir = os.path.join(wechat_base_dir, "msg", "attach") - self.out_dir = decoded_image_dir - self.cache = cache - - def get_image_md5(self, local_id): - """通过 local_id 查 message_resource.db 获取图片文件 MD5""" - path = self.cache.get("message/message_resource.db") - if not path: - return None - - conn = sqlite3.connect(path) - try: - row = conn.execute( - "SELECT packed_info FROM MessageResourceInfo WHERE local_id = ?", - (local_id,) - ).fetchone() - if row and row[0]: - return extract_md5_from_packed_info(row[0]) - except Exception: - pass - finally: - conn.close() - - return None - - def find_dat_files(self, username, file_md5): - """在 attach 目录下查找对应的 .dat 文件 - - 路径: attach///Img/[_t|_h].dat - """ - username_hash = hashlib.md5(username.encode()).hexdigest() - search_base = os.path.join(self.attach_dir, username_hash) - - if not os.path.isdir(search_base): - return [] - - # 在所有月份目录下搜索 - results = [] - pattern = os.path.join(search_base, "*", "Img", f"{file_md5}*.dat") - for p in glob.glob(pattern): - results.append(p) - - return sorted(results) - - def decode_image(self, username, local_id): - """完整流程:local_id → MD5 → .dat → 解密 - - Returns: - dict with keys: success, path, format, md5, error - """ - # 1. 获取 MD5 - file_md5 = self.get_image_md5(local_id) - if not file_md5: - return {'success': False, 'error': f'无法从 message_resource.db 找到 local_id={local_id} 的图片信息'} - - # 2. 找 .dat 文件 - dat_files = self.find_dat_files(username, file_md5) - if not dat_files: - return {'success': False, 'error': f'找不到 .dat 文件 (MD5={file_md5})', 'md5': file_md5} - - # 优先选标准版(非 _t/_h),然后高清 _h,最后缩略图 _t - selected = dat_files[0] - for f in dat_files: - fname = os.path.basename(f) - if not fname.startswith(file_md5 + '_'): - selected = f - break - for f in dat_files: - if f.endswith('_h.dat'): - selected = f - break - - # 3. 解密 - out_name = f"{file_md5}" - out_path_base = os.path.join(self.out_dir, out_name) - - result_path, fmt = xor_decrypt_file(selected, f"{out_path_base}.tmp") - if not result_path: - return {'success': False, 'error': f'无法检测 XOR key (文件: {selected})', 'md5': file_md5} - - # 重命名为正确扩展名 - final_path = f"{out_path_base}.{fmt}" - if os.path.exists(final_path): - os.unlink(final_path) - os.rename(result_path, final_path) - - return { - 'success': True, - 'path': final_path, - 'format': fmt, - 'md5': file_md5, - 'source': selected, - 'size': os.path.getsize(final_path), - } - - def list_chat_images(self, db_path, table_name, username, limit=20): - """列出某个聊天中的所有图片消息""" - conn = sqlite3.connect(db_path) - try: - rows = conn.execute(f""" - SELECT local_id, create_time - FROM [{table_name}] - WHERE local_type = 3 - ORDER BY create_time DESC - LIMIT ? - """, (limit,)).fetchall() - except Exception as e: - conn.close() - return [] - conn.close() - - results = [] - for local_id, create_time in rows: - file_md5 = self.get_image_md5(local_id) - info = { - 'local_id': local_id, - 'create_time': create_time, - 'md5': file_md5, - } - if file_md5: - dat_files = self.find_dat_files(username, file_md5) - if dat_files: - info['dat_file'] = dat_files[0] - try: - info['size'] = os.path.getsize(dat_files[0]) - except OSError: - pass - results.append(info) - - return results - - -# ============ CLI 测试 ============ - -if __name__ == "__main__": - if len(sys.argv) < 2: - print("用法: python decode_image.py [output_file]") - print(" 解密单个 .dat 文件") - sys.exit(1) - - dat_file = sys.argv[1] - out_file = sys.argv[2] if len(sys.argv) > 2 else None - - if not os.path.exists(dat_file): - print(f"文件不存在: {dat_file}") - sys.exit(1) - - result_path, fmt = decrypt_dat_file(dat_file, out_file) - if result_path: - size = os.path.getsize(result_path) - print(f"解密成功: {result_path}") - print(f"格式: {fmt}, 大小: {size:,} bytes") - else: - print("解密失败") - sys.exit(1) diff --git a/decrypt_db.py b/decrypt_db.py deleted file mode 100644 index ec1aad8..0000000 --- a/decrypt_db.py +++ /dev/null @@ -1,185 +0,0 @@ -""" -WeChat 4.0 数据库解密器 - -使用从进程内存提取的per-DB enc_key解密SQLCipher 4加密的数据库 -参数: SQLCipher 4, AES-256-CBC, HMAC-SHA512, reserve=80, page_size=4096 -密钥来源: all_keys.json (由find_all_keys.py从内存提取) -""" -import hashlib, struct, os, sys, json -import hmac as hmac_mod -from Crypto.Cipher import AES - -import functools -print = functools.partial(print, flush=True) - -PAGE_SZ = 4096 -KEY_SZ = 32 -SALT_SZ = 16 -IV_SZ = 16 -HMAC_SZ = 64 -RESERVE_SZ = 80 # IV(16) + HMAC(64) -SQLITE_HDR = b'SQLite format 3\x00' - -from config import load_config -from key_utils import get_key_info, strip_key_metadata -_cfg = load_config() -DB_DIR = _cfg["db_dir"] -OUT_DIR = _cfg["decrypted_dir"] -KEYS_FILE = _cfg["keys_file"] - - -def derive_mac_key(enc_key, salt): - """从enc_key派生HMAC密钥""" - mac_salt = bytes(b ^ 0x3a for b in salt) - return hashlib.pbkdf2_hmac("sha512", enc_key, mac_salt, 2, dklen=KEY_SZ) - - -def decrypt_page(enc_key, page_data, pgno): - """解密单个页面,输出4096字节的标准SQLite页面""" - iv = page_data[PAGE_SZ - RESERVE_SZ : PAGE_SZ - RESERVE_SZ + IV_SZ] - - if pgno == 1: - encrypted = page_data[SALT_SZ : PAGE_SZ - RESERVE_SZ] - cipher = AES.new(enc_key, AES.MODE_CBC, iv) - decrypted = cipher.decrypt(encrypted) - page = bytearray(SQLITE_HDR + decrypted + b'\x00' * RESERVE_SZ) - # 保留 reserve=80, B-tree 基于 usable_size=4016 构建 - return bytes(page) - else: - encrypted = page_data[:PAGE_SZ - RESERVE_SZ] - cipher = AES.new(enc_key, AES.MODE_CBC, iv) - decrypted = cipher.decrypt(encrypted) - return decrypted + b'\x00' * RESERVE_SZ - - -def decrypt_database(db_path, out_path, enc_key): - """解密整个数据库文件""" - file_size = os.path.getsize(db_path) - total_pages = file_size // PAGE_SZ - - if file_size % PAGE_SZ != 0: - print(f" [WARN] 文件大小 {file_size} 不是 {PAGE_SZ} 的倍数") - total_pages += 1 - - with open(db_path, 'rb') as fin: - page1 = fin.read(PAGE_SZ) - - if len(page1) < PAGE_SZ: - print(f" [ERROR] 文件太小") - return False - - # 提取salt并派生mac_key, 验证page 1 - salt = page1[:SALT_SZ] - mac_key = derive_mac_key(enc_key, salt) - p1_hmac_data = page1[SALT_SZ : PAGE_SZ - RESERVE_SZ + IV_SZ] - p1_stored_hmac = page1[PAGE_SZ - HMAC_SZ : PAGE_SZ] - hm = hmac_mod.new(mac_key, p1_hmac_data, hashlib.sha512) - hm.update(struct.pack(' 0: - page = page + b'\x00' * (PAGE_SZ - len(page)) - else: - break - - decrypted = decrypt_page(enc_key, page, pgno) - fout.write(decrypted) - - if pgno == 1: - if decrypted[:16] != SQLITE_HDR: - print(f" [WARN] 解密后header不匹配!") - - if pgno % 10000 == 0: - print(f" 进度: {pgno}/{total_pages} ({100*pgno/total_pages:.1f}%)") - - return True - - -def main(): - print("=" * 60) - print(" WeChat 4.0 数据库解密器") - print("=" * 60) - - # 加载密钥 - if not os.path.exists(KEYS_FILE): - print(f"[ERROR] 密钥文件不存在: {KEYS_FILE}") - print("请先运行 find_all_keys.py") - sys.exit(1) - - with open(KEYS_FILE, encoding="utf-8") as f: - keys = json.load(f) - - keys = strip_key_metadata(keys) - print(f"\n加载 {len(keys)} 个数据库密钥") - print(f"输出目录: {OUT_DIR}") - os.makedirs(OUT_DIR, exist_ok=True) - - # 收集所有DB文件 - db_files = [] - for root, dirs, files in os.walk(DB_DIR): - for f in files: - if f.endswith('.db') and not f.endswith('-wal') and not f.endswith('-shm'): - path = os.path.join(root, f) - rel = os.path.relpath(path, DB_DIR) - sz = os.path.getsize(path) - db_files.append((rel, path, sz)) - - db_files.sort(key=lambda x: x[2]) # 从小到大 - - print(f"找到 {len(db_files)} 个数据库文件\n") - - success = 0 - failed = 0 - total_bytes = 0 - - for rel, path, sz in db_files: - key_info = get_key_info(keys, rel) - if not key_info: - print(f"SKIP: {rel} (无密钥)") - failed += 1 - continue - - enc_key = bytes.fromhex(key_info["enc_key"]) - out_path = os.path.join(OUT_DIR, rel) - - print(f"解密: {rel} ({sz/1024/1024:.1f}MB) ...", end=" ") - - ok = decrypt_database(path, out_path, enc_key) - if ok: - # SQLite验证 - try: - import sqlite3 - conn = sqlite3.connect(out_path) - tables = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() - conn.close() - table_names = [t[0] for t in tables] - print(f" OK! 表: {', '.join(table_names[:5])}", end="") - if len(table_names) > 5: - print(f" ...共{len(table_names)}个", end="") - print() - success += 1 - total_bytes += sz - except Exception as e: - print(f" [WARN] SQLite验证失败: {e}") - failed += 1 - else: - failed += 1 - - print(f"\n{'='*60}") - print(f"结果: {success} 成功, {failed} 失败, 共 {len(db_files)} 个") - print(f"解密数据量: {total_bytes/1024/1024/1024:.1f}GB") - print(f"解密文件在: {OUT_DIR}") - - -if __name__ == '__main__': - main() diff --git a/decrypt_images.c b/decrypt_images.c deleted file mode 100644 index 3193680..0000000 --- a/decrypt_images.c +++ /dev/null @@ -1,616 +0,0 @@ -/* - * decrypt_images.c — WeChat V2 image batch decryptor (multi-key) - * - * Decrypts all V2 encrypted .dat files in the WeChat image cache. - * Supports multiple keys via image_keys.json (CT block → AES key mapping). - * - * V2 format: - * [15B header] [AES-128-ECB ciphertext] [XOR encrypted tail] - * Header: \x07\x08V2\x08\x07 (6B) + aes_size:u32LE + xor_size:u32LE + 1B pad - * AES region: ceil(aes_size/16)*16 bytes of AES-128-ECB ciphertext - * XOR tail: xor_size bytes, each XOR'd with a single-byte key - * - * Build: - * cc -O3 -o decrypt_images decrypt_images.c -framework Security - * - * Usage: - * ./decrypt_images # auto from config + image_keys.json - * ./decrypt_images # single-key manual - */ - -#include -#include -#include -#include -#include -#include -#include -#include - -#define MAX_PATH 4096 -#define V2_MAGIC "\x07\x08V2\x08\x07" -#define V2_MAGIC_LEN 6 -#define HEADER_SIZE 15 -#define MAX_KEYS 4096 - -/* ---- Key mapping: CT block hex → AES key ---- */ -typedef struct { - unsigned char ct[16]; /* CT block 0 pattern */ - unsigned char key[16]; /* AES key for this pattern */ -} key_map_t; - -static key_map_t key_map[MAX_KEYS]; -static int n_keys = 0; - -/* ---- Utility ---- */ - -static int hex2bytes(const char *hex, unsigned char *out, int maxlen) { - int len = 0; - while (*hex && *(hex + 1) && len < maxlen) { - unsigned int b; - if (sscanf(hex, "%2x", &b) != 1) break; - out[len++] = (unsigned char)b; - hex += 2; - } - return len; -} - -/* Minimal JSON string extractor (for simple unescaped string values only). */ -static int json_get_string(const char *json, const char *key, - char *value, int maxlen) { - char pattern[256]; - snprintf(pattern, sizeof(pattern), "\"%s\"", key); - const char *p = strstr(json, pattern); - if (!p) return 0; - p = strchr(p + strlen(pattern), '"'); - if (!p) return 0; - p++; - const char *end = strchr(p, '"'); - if (!end) return 0; - int len = (int)(end - p); - if (len >= maxlen) len = maxlen - 1; - memcpy(value, p, len); - value[len] = '\0'; - return 1; -} - -/* Load image_keys.json: { "ct_hex": "key_hex", ... } */ -static int load_key_map(const char *path) { - FILE *f = fopen(path, "r"); - if (!f) return 0; - fseek(f, 0, SEEK_END); - long sz = ftell(f); - if (sz <= 0) { fclose(f); return 0; } - fseek(f, 0, SEEK_SET); - char *json = malloc((size_t)sz + 1); - if (!json) { fclose(f); return 0; } - size_t rd = fread(json, 1, (size_t)sz, f); - if (rd != (size_t)sz) { - fclose(f); - free(json); - return 0; - } - json[rd] = '\0'; - fclose(f); - - /* Simple parser: find all "32hex": "32hex" pairs */ - const char *p = json; - int warned_capacity = 0; - while ((p = strchr(p, '"')) != NULL) { - if (n_keys >= MAX_KEYS) { - if (!warned_capacity) { - fprintf(stderr, "Warning: image_keys.json exceeds MAX_KEYS=%d, extra keys ignored\n", - MAX_KEYS); - warned_capacity = 1; - } - break; - } - - p++; - const char *end = strchr(p, '"'); - if (!end) break; - int klen = (int)(end - p); - if (klen != 32) { p = end + 1; continue; } - - char ct_hex[33]; - memcpy(ct_hex, p, 32); - ct_hex[32] = '\0'; - const char *colon = end + 1; - while (*colon == ' ' || *colon == '\t' || *colon == '\r' || *colon == '\n') - colon++; - if (*colon != ':') { p = end + 1; continue; } - p = colon + 1; - - /* Find next quoted string (the value) */ - p = strchr(p, '"'); - if (!p) break; - p++; - end = strchr(p, '"'); - if (!end) break; - int vlen = (int)(end - p); - if (vlen != 32) { p = end + 1; continue; } - - char key_hex[33]; - memcpy(key_hex, p, 32); - key_hex[32] = '\0'; - p = end + 1; - - if (hex2bytes(ct_hex, key_map[n_keys].ct, 16) != 16 || - hex2bytes(key_hex, key_map[n_keys].key, 16) != 16) { - continue; - } - n_keys++; - } - free(json); - return n_keys; -} - -/* Find AES key for a given CT block */ -static const unsigned char *find_key_for_ct(const unsigned char *ct) { - for (int i = 0; i < n_keys; i++) - if (memcmp(key_map[i].ct, ct, 16) == 0) return key_map[i].key; - return NULL; -} - -/* Create directory and parents */ -static void mkdirs(const char *path) { - char tmp[MAX_PATH]; - snprintf(tmp, sizeof(tmp), "%s", path); - for (char *p = tmp + 1; *p; p++) { - if (*p == '/') { - *p = '\0'; - mkdir(tmp, 0755); - *p = '/'; - } - } - mkdir(tmp, 0755); -} - -static int has_parent_segment(const char *path) { - if (!path || !path[0]) return 1; - if (path[0] == '/' || path[0] == '\\') return 1; - - const char *p = path; - while (*p) { - while (*p == '/' || *p == '\\') p++; - if (!*p) break; - const char *seg = p; - while (*p && *p != '/' && *p != '\\') p++; - if ((p - seg) == 2 && seg[0] == '.' && seg[1] == '.') return 1; - } - return 0; -} - -/* Detect image type from magic bytes */ -static const char *detect_ext(const unsigned char *data, size_t len) { - if (len < 4) return ".bin"; - if (data[0] == 0xFF && data[1] == 0xD8) return ".jpg"; - if (data[0] == 0x89 && data[1] == 0x50 && - data[2] == 0x4E && data[3] == 0x47) return ".png"; - if (data[0] == 'G' && data[1] == 'I' && - data[2] == 'F' && data[3] == '8') return ".gif"; - if (data[0] == 'R' && data[1] == 'I' && - data[2] == 'F' && data[3] == 'F') return ".webp"; - if (data[0] == 0x00 && data[1] == 0x00 && - data[2] == 0x00 && (data[3] == 0x18 || data[3] == 0x1C || - data[3] == 0x20 || data[3] == 0x14)) return ".mp4"; - return ".bin"; -} - -/* Auto-detect XOR key */ -static unsigned char detect_xor_key(const unsigned char *xor_data, size_t xor_size) { - if (xor_size == 0) return 0; - unsigned char candidates[] = {0x80, 0xDC, 0x00}; - for (int i = 0; i < (int)(sizeof(candidates)/sizeof(candidates[0])); i++) { - /* We want a candidate that doesn't produce a leading NUL byte after XOR. */ - unsigned char test = xor_data[0] ^ candidates[i]; - if (test != 0x00 || candidates[i] == 0x00) - return candidates[i]; - } - return 0x80; -} - -/* ---- Decrypt one V2 file ---- */ - -static int decrypt_v2_file(const char *input_path, const char *output_dir, - const char *rel_path, const unsigned char *aes_key, - unsigned char xor_key, int auto_xor, - int *out_xor_detected) { - FILE *fin = fopen(input_path, "rb"); - if (!fin) return -1; - - unsigned char header[HEADER_SIZE]; - if (fread(header, 1, HEADER_SIZE, fin) != HEADER_SIZE) { - fclose(fin); return -1; - } - if (memcmp(header, V2_MAGIC, V2_MAGIC_LEN) != 0) { - fclose(fin); return -2; - } - - uint32_t aes_size, xor_size; - memcpy(&aes_size, header + 6, 4); - memcpy(&xor_size, header + 10, 4); - - if ((uint64_t)aes_size > 100u * 1024u * 1024u || - (uint64_t)xor_size > 100u * 1024u * 1024u) { - fclose(fin); - return -6; - } - - /* PKCS7: when aes_size is already 16-byte aligned, an extra 16-byte - * padding block is present in the ciphertext */ - size_t aes_ct_size = (aes_size % 16 == 0) - ? (size_t)aes_size + 16 - : ((size_t)aes_size + 15) / 16 * 16; - - /* Get total file size and validate header claims fit within it */ - long cur_pos = ftell(fin); - fseek(fin, 0, SEEK_END); - long file_size = ftell(fin); - fseek(fin, cur_pos, SEEK_SET); - - if ((long)aes_ct_size + (long)xor_size > file_size - HEADER_SIZE) { - fclose(fin); - return -6; /* header claims more data than file contains */ - } - - unsigned char *aes_ct = malloc(aes_ct_size); - if (!aes_ct) { fclose(fin); return -1; } - size_t rd = fread(aes_ct, 1, aes_ct_size, fin); - if (rd != aes_ct_size) { - free(aes_ct); - fclose(fin); - return -8; - } - - /* V2 may have unencrypted raw_data between AES and XOR sections */ - long raw_data_size = file_size - HEADER_SIZE - (long)aes_ct_size - (long)xor_size; - if (raw_data_size < 0) raw_data_size = 0; - - unsigned char *raw_data = NULL; - if (raw_data_size > 0) { - raw_data = malloc((size_t)raw_data_size); - if (!raw_data) { free(aes_ct); fclose(fin); return -1; } - rd = fread(raw_data, 1, (size_t)raw_data_size, fin); - if (rd != (size_t)raw_data_size) { - free(aes_ct); free(raw_data); fclose(fin); return -8; - } - } - - unsigned char *xor_data = NULL; - if (xor_size > 0) { - xor_data = malloc(xor_size); - if (!xor_data) { free(aes_ct); free(raw_data); fclose(fin); return -1; } - rd = fread(xor_data, 1, xor_size, fin); - if (rd != xor_size) { - free(aes_ct); free(raw_data); free(xor_data); - fclose(fin); return -8; - } - } - fclose(fin); - - /* Try multi-key lookup (image_keys.json) first, then fall back to provided key */ - if (aes_ct_size >= 16) { - const unsigned char *mk = find_key_for_ct(aes_ct); - if (mk) aes_key = mk; - } - if (!aes_key) { free(aes_ct); free(raw_data); free(xor_data); return -5; } - - unsigned char *aes_pt = malloc(aes_ct_size); - if (!aes_pt) { free(aes_ct); free(raw_data); free(xor_data); return -1; } - - size_t moved = 0; - CCCryptorStatus st = CCCrypt( - kCCDecrypt, kCCAlgorithmAES128, kCCOptionECBMode, - aes_key, 16, NULL, - aes_ct, aes_ct_size, aes_pt, aes_ct_size, &moved); - free(aes_ct); - - if (st != kCCSuccess) { - free(aes_pt); free(raw_data); free(xor_data); return -3; - } - - if (auto_xor && xor_data && xor_size > 0) { - xor_key = detect_xor_key(xor_data, xor_size); - if (out_xor_detected) *out_xor_detected = xor_key; - } - - if (xor_data && xor_size > 0) { - for (uint32_t i = 0; i < xor_size; i++) - xor_data[i] ^= xor_key; - } - - const char *ext = detect_ext(aes_pt, aes_size); - - /* Skip unrecognized formats — avoids writing garbage .bin files */ - if (strcmp(ext, ".bin") == 0) { - free(aes_pt); free(raw_data); free(xor_data); - return -9; /* unrecognized image type */ - } - - char out_path[MAX_PATH]; - char rel_noext[MAX_PATH]; - snprintf(rel_noext, sizeof(rel_noext), "%s", rel_path); - char *dot = strrchr(rel_noext, '.'); - if (dot) *dot = '\0'; - if (has_parent_segment(rel_noext)) { - free(aes_pt); free(raw_data); free(xor_data); - return -7; - } - snprintf(out_path, sizeof(out_path), "%s/%s%s", output_dir, rel_noext, ext); - - /* Skip if already decrypted */ - struct stat st_out; - if (stat(out_path, &st_out) == 0 && st_out.st_size > 0) { - free(aes_pt); free(raw_data); free(xor_data); - return 1; /* already exists */ - } - - char parent[MAX_PATH]; - snprintf(parent, sizeof(parent), "%s", out_path); - char *last_slash = strrchr(parent, '/'); - if (last_slash) { *last_slash = '\0'; mkdirs(parent); } - - FILE *fout = fopen(out_path, "wb"); - if (!fout) { free(aes_pt); free(raw_data); free(xor_data); return -4; } - - fwrite(aes_pt, 1, aes_size, fout); - if (raw_data && raw_data_size > 0) fwrite(raw_data, 1, (size_t)raw_data_size, fout); - if (xor_data && xor_size > 0) fwrite(xor_data, 1, xor_size, fout); - - fclose(fout); - free(aes_pt); - free(raw_data); - free(xor_data); - return 0; -} - -/* ---- Directory walking ---- */ - -typedef struct { - const unsigned char *fallback_key; /* single key from config.json (or NULL) */ - int multi_key; /* 1 if using image_keys.json */ - unsigned char xor_key; - int auto_xor; - const char *output_dir; - const char *base_dir; - int success; - int skipped; - int existed; /* already decrypted */ - int no_key; /* V2 files with no matching key */ - int failed; -} walk_ctx; - -static void walk_dir(const char *dir, walk_ctx *ctx) { - DIR *d = opendir(dir); - if (!d) return; - - struct dirent *ent; - while ((ent = readdir(d))) { - if (ent->d_name[0] == '.') continue; - - char path[MAX_PATH]; - snprintf(path, sizeof(path), "%s/%s", dir, ent->d_name); - - struct stat st; - if (lstat(path, &st) != 0) continue; - if (S_ISLNK(st.st_mode)) continue; - - if (S_ISDIR(st.st_mode)) { - walk_dir(path, ctx); - } else if (S_ISREG(st.st_mode)) { - size_t nlen = strlen(ent->d_name); - if (nlen < 5 || strcmp(ent->d_name + nlen - 4, ".dat") != 0) - continue; - - const char *rel = path + strlen(ctx->base_dir); - if (*rel == '/') rel++; - - int xor_detected = -1; - /* In multi-key mode, pass fallback_key — decrypt_v2_file tries - * image_keys.json lookup first, falls back to this key if provided */ - const unsigned char *key = ctx->fallback_key; - int ret = decrypt_v2_file(path, ctx->output_dir, rel, - key, ctx->xor_key, - ctx->auto_xor, &xor_detected); - - if (ret == 0) { - ctx->success++; - if (ctx->auto_xor && xor_detected >= 0) { - ctx->xor_key = (unsigned char)xor_detected; - ctx->auto_xor = 0; - printf(" Auto-detected XOR key: 0x%02X\n", ctx->xor_key); - } - if (ctx->success <= 5 || ctx->success % 1000 == 0) { - printf(" [%d] %s\n", ctx->success, rel); - } - } else if (ret == 1) { - ctx->existed++; - } else if (ret == -2) { - ctx->skipped++; - } else if (ret == -5) { - ctx->no_key++; - } else { - ctx->failed++; - if (ctx->failed <= 5) - printf(" FAIL(%d): %s\n", ret, rel); - } - } - } - closedir(d); -} - -/* ---- Main ---- */ - -int main(int argc, char *argv[]) { - unsigned char aes_key[16]; - char image_dir[MAX_PATH] = ""; - char output_dir[MAX_PATH] = ""; - char key_hex[64] = ""; - int have_single_key = 0; - - printf("=== WeChat V2 Image Decryptor ===\n\n"); - - /* Determine exe directory for config file lookup */ - char exe_dir[MAX_PATH] = "."; - const char *last_slash = strrchr(argv[0], '/'); - if (last_slash) { - int len = (int)(last_slash - argv[0]); - snprintf(exe_dir, sizeof(exe_dir), "%.*s", len, argv[0]); - } - - if (argc >= 4) { - /* Manual single-key mode */ - strncpy(key_hex, argv[1], sizeof(key_hex) - 1); - key_hex[sizeof(key_hex) - 1] = '\0'; - strncpy(image_dir, argv[2], sizeof(image_dir) - 1); - image_dir[sizeof(image_dir) - 1] = '\0'; - strncpy(output_dir, argv[3], sizeof(output_dir) - 1); - output_dir[sizeof(output_dir) - 1] = '\0'; - have_single_key = (key_hex[0] != '\0'); - } else { - /* Load image_keys.json first (multi-key) */ - char keys_path[MAX_PATH]; - snprintf(keys_path, sizeof(keys_path), "%s/image_keys.json", exe_dir); - int loaded = load_key_map(keys_path); - if (loaded > 0) - printf("Loaded %d key mappings from %s\n", loaded, keys_path); - - /* Read config.json for paths (and fallback single key) */ - char cfg_path[MAX_PATH]; - snprintf(cfg_path, sizeof(cfg_path), "%s/config.json", exe_dir); - FILE *cf = fopen(cfg_path, "r"); - if (!cf) { - fprintf(stderr, "ERROR: Cannot open %s\n", cfg_path); - return 1; - } - - fseek(cf, 0, SEEK_END); - long sz = ftell(cf); - if (sz <= 0) { fclose(cf); return 1; } - fseek(cf, 0, SEEK_SET); - char *json = malloc((size_t)sz + 1); - if (!json) { fclose(cf); return 1; } - size_t rd = fread(json, 1, (size_t)sz, cf); - if (rd != (size_t)sz) { - free(json); - fclose(cf); - return 1; - } - json[sz] = '\0'; - fclose(cf); - - if (json_get_string(json, "image_key", key_hex, sizeof(key_hex)) && - key_hex[0] != '\0') - have_single_key = 1; - else - have_single_key = 0; - - char db_dir[MAX_PATH] = ""; - json_get_string(json, "db_dir", db_dir, sizeof(db_dir)); - - char out_rel[MAX_PATH] = "decrypted_images"; - json_get_string(json, "decrypted_images_dir", out_rel, sizeof(out_rel)); - if (out_rel[0] == '/') - strncpy(output_dir, out_rel, sizeof(output_dir) - 1); - else - snprintf(output_dir, sizeof(output_dir), "%s/%s", exe_dir, out_rel); - output_dir[sizeof(output_dir) - 1] = '\0'; - - if (db_dir[0]) { - char *s = strrchr(db_dir, '/'); - if (!s) s = strrchr(db_dir, '\\'); - if (s) { - int plen = (int)(s - db_dir); - snprintf(image_dir, sizeof(image_dir), - "%.*s/msg", plen, db_dir); - } - } - free(json); - } - - /* Parse single key if available (used as fallback or sole key) */ - if (have_single_key && key_hex[0]) { - if (hex2bytes(key_hex, aes_key, 16) == 16) { - /* If no image_keys.json loaded, add single key to key_map - * by discovering its CT block at runtime */ - } else { - have_single_key = 0; - } - } - - if (n_keys == 0 && !have_single_key) { - fprintf(stderr, "ERROR: No keys available.\n"); - fprintf(stderr, "Run find_image_key first, or set image_key in config.json\n"); - return 1; - } - - /* Auto-detect: scan ~/Library/Containers/com.tencent.xinWeChat */ - if (image_dir[0] == '\0') { - const char *home = getenv("HOME"); - if (!home) home = "/Users"; - char base[MAX_PATH]; - snprintf(base, sizeof(base), - "%s/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files", - home); - DIR *d = opendir(base); - if (d) { - struct dirent *ent; - while ((ent = readdir(d))) { - if (ent->d_name[0] == '.') continue; - char candidate[MAX_PATH]; - snprintf(candidate, sizeof(candidate), "%s/%s/msg", base, ent->d_name); - struct stat st2; - if (stat(candidate, &st2) == 0 && S_ISDIR(st2.st_mode)) { - strncpy(image_dir, candidate, sizeof(image_dir) - 1); - printf("Auto-detected image directory:\n %s\n\n", image_dir); - break; - } - } - closedir(d); - } - } - - if (image_dir[0] == '\0') { - fprintf(stderr, "ERROR: Cannot determine image directory.\n"); - fprintf(stderr, "Tried: command line, config.json, auto-detect.\n"); - fprintf(stderr, "Set db_dir in config.json or pass image_dir as argument.\n"); - return 1; - } - - printf("Mode: %s\n", n_keys > 0 ? "multi-key" : "single-key"); - if (n_keys > 0) printf("Keys: %d pattern→key mappings\n", n_keys); - if (have_single_key) printf("Fallback: %s\n", key_hex); - printf("Image dir: %s\n", image_dir); - printf("Output: %s\n\n", output_dir); - - mkdirs(output_dir); - - walk_ctx ctx = { - .fallback_key = have_single_key ? aes_key : NULL, - .multi_key = (n_keys > 0), - .xor_key = 0, - .auto_xor = 1, - .output_dir = output_dir, - .base_dir = image_dir, - .success = 0, - .skipped = 0, - .existed = 0, - .no_key = 0, - .failed = 0, - }; - - walk_dir(image_dir, &ctx); - - printf("\n==================================================\n"); - printf("Results:\n"); - printf(" Decrypted: %d\n", ctx.success); - printf(" Existed: %d (already decrypted, skipped)\n", ctx.existed); - printf(" No key: %d (run find_image_key to discover more keys)\n", ctx.no_key); - printf(" Skipped: %d (non-V2)\n", ctx.skipped); - printf(" Failed: %d\n", ctx.failed); - printf("Output: %s\n", output_dir); - printf("==================================================\n"); - - return (ctx.success > 0) ? 0 : 1; -} diff --git a/find_all_keys.py b/find_all_keys.py deleted file mode 100644 index eba2191..0000000 --- a/find_all_keys.py +++ /dev/null @@ -1,34 +0,0 @@ -import functools -import platform -import sys - - -@functools.lru_cache(maxsize=1) -def _load_impl(): - system = platform.system().lower() - if system == "windows": - import find_all_keys_windows as impl - return impl - if system == "linux": - import find_all_keys_linux as impl - return impl - raise RuntimeError( - f"当前平台暂不支持通过 find_all_keys.py 提取密钥: {platform.system()}\n" - f"macOS 请使用 find_all_keys_macos.c (C 版扫描器)" - ) - - -def get_pids(): - return _load_impl().get_pids() - - -def main(): - return _load_impl().main() - - -if __name__ == "__main__": - try: - main() - except RuntimeError as exc: - print(f"\n[ERROR] {exc}") - sys.exit(1) diff --git a/find_all_keys_linux.py b/find_all_keys_linux.py deleted file mode 100644 index b12feb0..0000000 --- a/find_all_keys_linux.py +++ /dev/null @@ -1,246 +0,0 @@ -""" -Linux 版微信数据库密钥提取 - -原理: 与 Windows/macOS 相同 — 扫描微信进程内存,查找 -WCDB 缓存的 x'<64hex_enc_key><32hex_salt>' 模式, -通过匹配数据库 salt + HMAC 校验确认密钥。 - -读取方式: /proc//maps + /proc//mem -权限要求: root 或 CAP_SYS_PTRACE -""" -import functools -import os -import re -import sys -import time - -from key_scan_common import ( - collect_db_files, scan_memory_for_keys, cross_verify_keys, save_results, -) - -print = functools.partial(print, flush=True) - - -def _safe_readlink(path): - try: - return os.path.realpath(os.readlink(path)) - except OSError: - return "" - - -_KNOWN_COMMS = {"wechat", "wechatappex", "weixin"} -_INTERPRETER_PREFIXES = ("python", "bash", "sh", "zsh", "node", "perl", "ruby") - - -def _is_wechat_process(pid): - """检查 pid 是否为微信进程。 - - 优先精确匹配 comm 名称(wechat、WeChatAppEx 等), - 再用 exe 路径子串匹配作为 fallback,同时排除解释器进程。 - """ - if pid == os.getpid(): - return False - try: - with open(f"/proc/{pid}/comm") as f: - comm = f.read().strip() - # 优先精确匹配 comm(最可靠) - if comm.lower() in _KNOWN_COMMS: - return True - exe_path = _safe_readlink(f"/proc/{pid}/exe") - exe_name = os.path.basename(exe_path) - # 排除脚本解释器进程(避免匹配 python3.11 wechat-decrypt 等) - if any(exe_name.lower().startswith(p) for p in _INTERPRETER_PREFIXES): - return False - # fallback: exe 名称子串匹配 - return "wechat" in exe_name.lower() or "weixin" in exe_name.lower() - except (PermissionError, FileNotFoundError, ProcessLookupError): - return False - - -def get_pids(): - """返回所有疑似微信主进程的 (pid, rss_kb) 列表,按内存降序。""" - pids = [] - for pid_str in os.listdir("/proc"): - if not pid_str.isdigit(): - continue - pid = int(pid_str) - try: - if not _is_wechat_process(pid): - continue - with open(f"/proc/{pid}/statm") as f: - rss_pages = int(f.read().split()[1]) - rss_kb = rss_pages * 4 - pids.append((pid, rss_kb)) - except (PermissionError, FileNotFoundError, ProcessLookupError): - continue - - if not pids: - raise RuntimeError("未检测到 Linux 微信进程") - - pids.sort(key=lambda item: item[1], reverse=True) - for pid, rss_kb in pids: - exe_path = _safe_readlink(f"/proc/{pid}/exe") - print(f"[+] WeChat PID={pid} ({rss_kb // 1024}MB) {exe_path}") - return pids - - -_SKIP_MAPPINGS = {"[vdso]", "[vsyscall]", "[vvar]"} -_SKIP_PATH_PREFIXES = ("/usr/lib/", "/lib/", "/usr/share/") - - -def _get_readable_regions(pid): - """解析 /proc//maps,返回可读内存区域列表。 - - 跳过 [vdso]、[vsyscall] 等特殊映射和系统库映射, - 聚焦匿名映射和堆区(WCDB 密钥缓存所在位置)。 - """ - regions = [] - with open(f"/proc/{pid}/maps") as f: - for line in f: - parts = line.split() - if len(parts) < 2: - continue - if "r" not in parts[1]: - continue - # 跳过特殊映射和无关系统库,但保留 wcdb/wechat 相关库 - if len(parts) >= 6: - mapping_name = parts[5] - if mapping_name in _SKIP_MAPPINGS: - continue - mapping_lower = mapping_name.lower() - if (any(mapping_name.startswith(p) for p in _SKIP_PATH_PREFIXES) - and "wcdb" not in mapping_lower - and "wechat" not in mapping_lower - and "weixin" not in mapping_lower): - continue - start_s, end_s = parts[0].split("-") - start = int(start_s, 16) - size = int(end_s, 16) - start - if 0 < size < 500 * 1024 * 1024: - regions.append((start, size)) - return regions - - -def _check_permissions(): - """检查是否有读取进程内存的权限(root 或 CAP_SYS_PTRACE)。""" - if os.geteuid() == 0: - return - # 检查 CAP_SYS_PTRACE: 读取 /proc/self/status 中的 CapEff - try: - with open("/proc/self/status") as f: - for line in f: - if line.startswith("CapEff:"): - cap_eff = int(line.split(":")[1].strip(), 16) - CAP_SYS_PTRACE = 1 << 19 - if cap_eff & CAP_SYS_PTRACE: - return - break - except (OSError, ValueError): - pass - print("[!] 需要 root 权限或 CAP_SYS_PTRACE 才能读取进程内存") - print(" 请使用: sudo python3 find_all_keys.py") - print(" 或授予 capability: sudo setcap cap_sys_ptrace=ep $(which python3)") - sys.exit(1) - - -def main(): - from config import load_config - _cfg = load_config() - db_dir = _cfg["db_dir"] - out_file = _cfg["keys_file"] - - _check_permissions() - - print("=" * 60) - print(" 提取 Linux 微信数据库密钥(内存扫描)") - print("=" * 60) - - # 1. 收集 DB 文件和 salt - db_files, salt_to_dbs = collect_db_files(db_dir) - if not db_files: - raise RuntimeError(f"在 {db_dir} 未找到可解密的 .db 文件") - - print(f"\n找到 {len(db_files)} 个数据库, {len(salt_to_dbs)} 个不同的 salt") - for salt_hex, dbs in sorted(salt_to_dbs.items(), key=lambda x: len(x[1]), reverse=True): - print(f" salt {salt_hex}: {', '.join(dbs)}") - - # 2. 找到微信进程 - pids = get_pids() - - hex_re = re.compile(rb"x'([0-9a-fA-F]{64,192})'") - key_map = {} # salt_hex -> enc_key_hex - remaining_salts = set(salt_to_dbs.keys()) - all_hex_matches = 0 - t0 = time.time() - - for pid, rss_kb in pids: - try: - regions = _get_readable_regions(pid) - except PermissionError: - print(f"[WARN] 无法读取 /proc/{pid}/maps,权限不足,跳过") - continue - except (FileNotFoundError, ProcessLookupError): - print(f"[WARN] PID {pid} 已退出,跳过") - continue - - total_bytes = sum(s for _, s in regions) - total_mb = total_bytes / 1024 / 1024 - print(f"\n[*] 扫描 PID={pid} ({total_mb:.0f}MB, {len(regions)} 区域)") - - scanned_bytes = 0 - try: - mem = open(f"/proc/{pid}/mem", "rb") - except PermissionError: - print(f"[WARN] 无法打开 /proc/{pid}/mem,权限不足,跳过") - continue - except (FileNotFoundError, ProcessLookupError): - print(f"[WARN] PID {pid} 已退出,跳过") - continue - - # 防御 TOCTOU: 打开 mem 后再次确认仍为微信进程 - if not _is_wechat_process(pid): - print(f"[WARN] PID {pid} 已不是微信进程,跳过") - mem.close() - continue - - try: - for reg_idx, (base, size) in enumerate(regions): - try: - mem.seek(base) - data = mem.read(size) - except (OSError, ValueError): - continue - scanned_bytes += len(data) - - all_hex_matches += scan_memory_for_keys( - data, hex_re, db_files, salt_to_dbs, - key_map, remaining_salts, base, pid, print, - ) - - if (reg_idx + 1) % 200 == 0: - elapsed = time.time() - t0 - progress = scanned_bytes / total_bytes * 100 if total_bytes else 100 - print( - f" [{progress:.1f}%] {len(key_map)}/{len(salt_to_dbs)} salts matched, " - f"{all_hex_matches} hex patterns, {elapsed:.1f}s" - ) - finally: - mem.close() - - if not remaining_salts: - print(f"\n[+] 所有密钥已找到,跳过剩余进程") - break - - elapsed = time.time() - t0 - print(f"\n扫描完成: {elapsed:.1f}s, {len(pids)} 个进程, {all_hex_matches} hex 模式") - - cross_verify_keys(db_files, salt_to_dbs, key_map, print) - save_results(db_files, salt_to_dbs, key_map, db_dir, out_file, print) - - -if __name__ == "__main__": - try: - main() - except RuntimeError as exc: - print(f"\n[ERROR] {exc}") - sys.exit(1) diff --git a/find_all_keys_windows.py b/find_all_keys_windows.py deleted file mode 100644 index ebf681d..0000000 --- a/find_all_keys_windows.py +++ /dev/null @@ -1,154 +0,0 @@ -""" -从微信进程内存中提取所有数据库的缓存raw key - -WCDB为每个DB缓存: x'<64hex_enc_key><32hex_salt>' -salt嵌在hex字符串中,可以直接匹配DB文件的salt -""" -import ctypes -import ctypes.wintypes as wt -import os, sys, time, re - -import functools -print = functools.partial(print, flush=True) - -from key_scan_common import ( - collect_db_files, scan_memory_for_keys, cross_verify_keys, save_results, -) - -kernel32 = ctypes.windll.kernel32 -MEM_COMMIT = 0x1000 -READABLE = {0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80} - - -class MBI(ctypes.Structure): - _fields_ = [ - ("BaseAddress", ctypes.c_uint64), ("AllocationBase", ctypes.c_uint64), - ("AllocationProtect", wt.DWORD), ("_pad1", wt.DWORD), - ("RegionSize", ctypes.c_uint64), ("State", wt.DWORD), - ("Protect", wt.DWORD), ("Type", wt.DWORD), ("_pad2", wt.DWORD), - ] - - -def get_pids(): - """返回所有 Weixin.exe 进程的 (pid, mem_kb) 列表,按内存降序""" - import subprocess - r = subprocess.run(["tasklist", "/FI", "IMAGENAME eq Weixin.exe", "/FO", "CSV", "/NH"], - capture_output=True, text=True) - pids = [] - for line in r.stdout.strip().split('\n'): - if not line.strip(): - continue - p = line.strip('"').split('","') - if len(p) >= 5: - pid = int(p[1]) - mem = int(p[4].replace(',', '').replace(' K', '').strip() or '0') - pids.append((pid, mem)) - if not pids: - raise RuntimeError("Weixin.exe 未运行") - pids.sort(key=lambda x: x[1], reverse=True) - for pid, mem in pids: - print(f"[+] Weixin.exe PID={pid} ({mem // 1024}MB)") - return pids - - -def read_mem(h, addr, sz): - buf = ctypes.create_string_buffer(sz) - n = ctypes.c_size_t(0) - if kernel32.ReadProcessMemory(h, ctypes.c_uint64(addr), buf, sz, ctypes.byref(n)): - return buf.raw[:n.value] - return None - - -def enum_regions(h): - regs = [] - addr = 0 - mbi = MBI() - while addr < 0x7FFFFFFFFFFF: - if kernel32.VirtualQueryEx(h, ctypes.c_uint64(addr), ctypes.byref(mbi), ctypes.sizeof(mbi)) == 0: - break - if mbi.State == MEM_COMMIT and mbi.Protect in READABLE and 0 < mbi.RegionSize < 500 * 1024 * 1024: - regs.append((mbi.BaseAddress, mbi.RegionSize)) - nxt = mbi.BaseAddress + mbi.RegionSize - if nxt <= addr: - break - addr = nxt - return regs - - -def main(): - from config import load_config - _cfg = load_config() - db_dir = _cfg["db_dir"] - out_file = _cfg["keys_file"] - - print("=" * 60) - print(" 提取所有微信数据库密钥") - print("=" * 60) - - # 1. 收集所有DB文件及其salt - db_files, salt_to_dbs = collect_db_files(db_dir) - - print(f"\n找到 {len(db_files)} 个数据库, {len(salt_to_dbs)} 个不同的salt") - for salt_hex, dbs in sorted(salt_to_dbs.items(), key=lambda x: len(x[1]), reverse=True): - print(f" salt {salt_hex}: {', '.join(dbs)}") - - # 2. 打开所有微信进程 - pids = get_pids() - - hex_re = re.compile(b"x'([0-9a-fA-F]{64,192})'") - key_map = {} - remaining_salts = set(salt_to_dbs.keys()) - all_hex_matches = 0 - t0 = time.time() - - for pid, mem_kb in pids: - h = kernel32.OpenProcess(0x0010 | 0x0400, False, pid) - if not h: - print(f"[WARN] 无法打开进程 PID={pid},跳过") - continue - - try: - regions = enum_regions(h) - total_bytes = sum(s for _, s in regions) - total_mb = total_bytes / 1024 / 1024 - print(f"\n[*] 扫描 PID={pid} ({total_mb:.0f}MB, {len(regions)} 区域)") - - scanned_bytes = 0 - for reg_idx, (base, size) in enumerate(regions): - data = read_mem(h, base, size) - scanned_bytes += size - if not data: - continue - - all_hex_matches += scan_memory_for_keys( - data, hex_re, db_files, salt_to_dbs, - key_map, remaining_salts, base, pid, print, - ) - - if (reg_idx + 1) % 200 == 0: - elapsed = time.time() - t0 - progress = scanned_bytes / total_bytes * 100 if total_bytes else 100 - print( - f" [{progress:.1f}%] {len(key_map)}/{len(salt_to_dbs)} salts matched, " - f"{all_hex_matches} hex patterns, {elapsed:.1f}s" - ) - finally: - kernel32.CloseHandle(h) - - if not remaining_salts: - print(f"\n[+] 所有密钥已找到,跳过剩余进程") - break - - elapsed = time.time() - t0 - print(f"\n扫描完成: {elapsed:.1f}s, {len(pids)} 个进程, {all_hex_matches} hex模式") - - cross_verify_keys(db_files, salt_to_dbs, key_map, print) - save_results(db_files, salt_to_dbs, key_map, db_dir, out_file, print) - - -if __name__ == '__main__': - try: - main() - except RuntimeError as e: - print(f"\n[ERROR] {e}") - sys.exit(1) diff --git a/find_image_key.c b/find_image_key.c deleted file mode 100644 index 2fc4b89..0000000 --- a/find_image_key.c +++ /dev/null @@ -1,917 +0,0 @@ -/* - * find_image_key.c — WeChat V2 image key continuous scanner (macOS) - * - * Discovers all unique V2 encryption patterns from the image cache, - * then continuously scans WeChat process memory to find AES keys. - * User just keeps browsing images in WeChat — the scanner catches - * keys as they transiently appear in memory. - * - * Uses multi-block CCCrypt: one key setup decrypts ALL unsolved - * patterns in a single call (~1.5 min per full scan with 20 patterns). - * - * Build: - * cc -O3 -o find_image_key find_image_key.c -framework Security - * - * Usage: - * sudo ./find_image_key # auto-discover from config.json - * sudo ./find_image_key # explicit image directory - */ - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#define MAX_PATH 4096 -#define MAX_PATTERNS 8192 -#define V2_MAGIC "\x07\x08V2\x08\x07" -#define V2_MAGIC_LEN 6 -#define REGION_MAX (200 * 1024 * 1024) -#define DEEP_PRIORITY_MAX 10 /* byte-by-byte scan for top N unsolved patterns */ - -/* ---- Strict image magic detection (16 bytes available from decrypted block) ---- */ -static int is_image_magic(const unsigned char *pt) { - if (pt[0] == 0xFF && pt[1] == 0xD8 && pt[2] == 0xFF && - pt[3] >= 0xC0 && pt[3] != 0xFF) { - /* JFIF: verify "JF" at offset 6 */ - if (pt[3] == 0xE0) return (pt[6] == 'J' && pt[7] == 'F'); - /* EXIF: verify "Ex" at offset 6 */ - if (pt[3] == 0xE1) return (pt[6] == 'E' && pt[7] == 'x'); - /* Other markers: verify length field is sane (big-endian, 2..32767) */ - uint16_t len = ((uint16_t)pt[4] << 8) | pt[5]; - return (len >= 2 && len < 0x8000); - } - /* PNG: full 8-byte signature */ - if (pt[0]==0x89 && pt[1]==0x50 && pt[2]==0x4E && pt[3]==0x47 && - pt[4]==0x0D && pt[5]==0x0A && pt[6]==0x1A && pt[7]==0x0A) return 1; - /* GIF: "GIF89a" or "GIF87a" */ - if (pt[0]=='G' && pt[1]=='I' && pt[2]=='F' && pt[3]=='8' && - (pt[4]=='9' || pt[4]=='7') && pt[5]=='a') return 1; - /* WebP: "RIFF....WEBP" */ - if (pt[0]=='R' && pt[1]=='I' && pt[2]=='F' && pt[3]=='F' && - pt[8]=='W' && pt[9]=='E' && pt[10]=='B' && pt[11]=='P') return 1; - return 0; -} - -/* ---- Pattern tracking ---- */ -typedef struct { - unsigned char ct[16]; /* CT block 0 (first 16 encrypted bytes) */ - unsigned char key[16]; /* found AES key */ - int solved; - int file_count; /* how many .dat files use this pattern */ - char sample_path[MAX_PATH]; -} pattern_t; - -static pattern_t patterns[MAX_PATTERNS]; -static int npatterns = 0; -static int total_v2_files = 0; - -/* ---- Rejected key blacklist (false positives) ---- */ -#define MAX_REJECTED 256 -static unsigned char rejected_keys[MAX_REJECTED][16]; -static int n_rejected = 0; - -static int is_rejected(const unsigned char *key) { - for (int i = 0; i < n_rejected; i++) - if (memcmp(rejected_keys[i], key, 16) == 0) return 1; - return 0; -} -static void add_rejected(const unsigned char *key) { - if (n_rejected < MAX_REJECTED && !is_rejected(key)) { - memcpy(rejected_keys[n_rejected], key, 16); - n_rejected++; - } -} - -/* ---- Global scan mode ---- */ -static int g_deep_mode = 0; - -/* ---- Graceful shutdown ---- */ -static volatile sig_atomic_t stop_flag = 0; -static void sigint_handler(int sig) { (void)sig; stop_flag = 1; } - -/* ---- Utility ---- */ -static void bytes2hex(const unsigned char *d, int n, char *out) { - for (int i = 0; i < n; i++) sprintf(out + i*2, "%02x", d[i]); - out[n*2] = '\0'; -} -static int hex2bytes(const char *h, unsigned char *o, int max) { - int n = 0; - while (n < max) { - if (!h[0] || !h[1]) return 0; - if (!((h[0] >= '0' && h[0] <= '9') || (h[0] >= 'a' && h[0] <= 'f') || - (h[0] >= 'A' && h[0] <= 'F'))) return 0; - if (!((h[1] >= '0' && h[1] <= '9') || (h[1] >= 'a' && h[1] <= 'f') || - (h[1] >= 'A' && h[1] <= 'F'))) return 0; - - unsigned int b = 0; - if (sscanf(h, "%2x", &b) != 1) return 0; - o[n++] = (unsigned char)b; h += 2; - } - return n; -} - -/* Minimal JSON string extractor */ -static int json_get_string(const char *json, const char *key, - char *val, int maxlen) { - char pat[256]; - snprintf(pat, sizeof(pat), "\"%s\"", key); - const char *p = strstr(json, pat); - if (!p) return 0; - p = strchr(p + strlen(pat), '"'); - if (!p) return 0; - p++; - const char *end = strchr(p, '"'); - if (!end || (int)(end - p) >= maxlen) return 0; - memcpy(val, p, end - p); - val[end - p] = '\0'; - return 1; -} - -/* ---- Pattern discovery ---- */ -static int find_pattern_index(const unsigned char *ct) { - for (int i = 0; i < npatterns; i++) - if (memcmp(patterns[i].ct, ct, 16) == 0) return i; - return -1; -} - -static void discover_dir(const char *dir) { - DIR *d = opendir(dir); - if (!d) return; - struct dirent *ent; - while ((ent = readdir(d))) { - if (ent->d_name[0] == '.') continue; - char path[MAX_PATH]; - snprintf(path, sizeof(path), "%s/%s", dir, ent->d_name); - struct stat st; - if (lstat(path, &st) != 0) continue; - if (S_ISLNK(st.st_mode)) continue; - if (S_ISDIR(st.st_mode)) { - discover_dir(path); - continue; - } - if (!S_ISREG(st.st_mode)) continue; - size_t nlen = strlen(ent->d_name); - if (nlen < 5 || strcmp(ent->d_name + nlen - 4, ".dat") != 0) continue; - - FILE *f = fopen(path, "rb"); - if (!f) continue; - unsigned char hdr[31]; - size_t rd = fread(hdr, 1, 31, f); - fclose(f); - if (rd < 31 || memcmp(hdr, V2_MAGIC, V2_MAGIC_LEN) != 0) continue; - - unsigned char *ct = hdr + 15; - total_v2_files++; - int idx = find_pattern_index(ct); - if (idx >= 0) { - patterns[idx].file_count++; - } else if (npatterns < MAX_PATTERNS) { - memcpy(patterns[npatterns].ct, ct, 16); - patterns[npatterns].file_count = 1; - patterns[npatterns].solved = 0; - strncpy(patterns[npatterns].sample_path, path, - sizeof(patterns[npatterns].sample_path) - 1); - patterns[npatterns].sample_path[sizeof(patterns[npatterns].sample_path) - 1] = '\0'; - npatterns++; - } - } - closedir(d); -} - -/* Sort patterns by file_count descending */ -static int cmp_patterns(const void *a, const void *b) { - return ((pattern_t*)b)->file_count - ((pattern_t*)a)->file_count; -} - -/* ---- Process discovery ---- */ -static int get_wechat_pids(pid_t *pids, int max) { - int mib[4] = {CTL_KERN, KERN_PROC, KERN_PROC_ALL, 0}; - size_t sz = 0; - if (sysctl(mib, 4, NULL, &sz, NULL, 0) != KERN_SUCCESS || sz == 0) - return 0; - - size_t alloc_sz = sz + (sz >> 2); - struct kinfo_proc *procs = malloc(alloc_sz); - if (!procs) return 0; - - if (sysctl(mib, 4, procs, &alloc_sz, NULL, 0) != KERN_SUCCESS) { - free(procs); - return 0; - } - - int n = (int)(alloc_sz / sizeof(struct kinfo_proc)), cnt = 0; - for (int i = 0; i < n && cnt < max; i++) - if (strstr(procs[i].kp_proc.p_comm, "WeChat")) - pids[cnt++] = procs[i].kp_proc.p_pid; - free(procs); - return cnt; -} - -/* ---- Verification: decrypt sample file, validate JPEG marker chain ---- */ - -/* Validate JPEG structure: check marker chain (SOI → markers → SOS/EOI) */ -static int verify_jpeg_chain(const unsigned char *data, size_t len) { - if (len < 4 || data[0] != 0xFF || data[1] != 0xD8) return 0; - size_t pos = 2; - int markers = 0; - while (pos + 4 <= len) { - if (data[pos] != 0xFF) return markers >= 2; - unsigned char m = data[pos + 1]; - /* Skip fill bytes (FF FF...) */ - if (m == 0xFF) { pos++; continue; } - if (m == 0x00) return 0; /* stuffed byte outside scan = invalid */ - if (m == 0xD9) return markers >= 1; /* EOI */ - if (m == 0xDA) return markers >= 1; /* SOS = scan data follows */ - if (m < 0xC0) return 0; - uint16_t mlen = ((uint16_t)data[pos+2] << 8) | data[pos+3]; - if (mlen < 2) return 0; - pos += 2 + mlen; - markers++; - } - /* Ran out of data (first marker spans past AES region): accept if >= 1 valid marker */ - return markers >= 1; -} - -/* Validate PNG: 8-byte sig + IHDR chunk */ -static int verify_png_chain(const unsigned char *data, size_t len) { - static const unsigned char sig[8] = {0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A}; - if (len < 24 || memcmp(data, sig, 8) != 0) return 0; - /* IHDR chunk at offset 8: length(4) + "IHDR"(4) + data(13) + CRC(4) */ - return (data[12]=='I' && data[13]=='H' && data[14]=='D' && data[15]=='R'); -} - -static int verify_key(int pat_idx) { - pattern_t *p = &patterns[pat_idx]; - FILE *f = fopen(p->sample_path, "rb"); - if (!f) return 1; /* can't verify, assume ok */ - - unsigned char hdr[15]; - if (fread(hdr, 1, 15, f) != 15) { fclose(f); return 1; } - uint32_t aes_size; - memcpy(&aes_size, hdr + 6, 4); - /* PKCS7: extra padding block when aes_size is 16-byte aligned */ - uint32_t ct_size = (aes_size % 16 == 0) - ? aes_size + 16 - : ((aes_size + 15) / 16) * 16; - if (ct_size > 10 * 1024 * 1024) { fclose(f); return 1; } - - unsigned char *ct = malloc(ct_size); - size_t rd = fread(ct, 1, ct_size, f); - fclose(f); - if (rd < ct_size) { free(ct); return 1; } - - unsigned char *pt = malloc(ct_size); - size_t moved; - CCCryptorStatus st = CCCrypt(kCCDecrypt, kCCAlgorithmAES128, - kCCOptionECBMode, p->key, 16, NULL, - ct, ct_size, pt, ct_size, &moved); - free(ct); - - if (st != kCCSuccess || moved < 16) { free(pt); return 0; } - - /* Deep validation based on image type */ - int ok = 0; - if (pt[0] == 0xFF && pt[1] == 0xD8) - ok = verify_jpeg_chain(pt, moved); - else if (pt[0] == 0x89 && pt[1] == 0x50) - ok = verify_png_chain(pt, moved); - else if (pt[0] == 'G' && pt[1] == 'I' && pt[2] == 'F') - ok = (moved >= 6 && pt[3] == '8' && (pt[4]=='9'||pt[4]=='7') && pt[5]=='a'); - else if (pt[0] == 'R' && pt[1] == 'I') - ok = (moved >= 12 && pt[8]=='W' && pt[9]=='E' && pt[10]=='B' && pt[11]=='P'); - - free(pt); - return ok; -} - -/* ---- Memory scanning ---- */ - -/* - * Multi-block scan: for each candidate key, decrypt ALL unsolved - * CT blocks in one CCCrypt call (ECB processes blocks independently). - */ -static int g_task_fail_warned = 0; - -static int scan_pid(pid_t pid) { - mach_port_t task; - kern_return_t kr = task_for_pid(mach_task_self(), pid, &task); - if (kr != KERN_SUCCESS) { - if (!g_task_fail_warned) { - g_task_fail_warned = 1; - fprintf(stderr, - " WARNING: task_for_pid(%d) failed (kr=%d).\n" - " Cannot read WeChat memory. Checklist:\n" - " 1. Run with sudo\n" - " 2. Enable Developer Mode: Settings > Privacy & Security > Developer Mode\n" - " 3. Grant Terminal Full Disk Access: Settings > Privacy & Security > Full Disk Access\n" - " 4. If still failing, try: sudo DevToolsSecurity -enable\n" - " 5. Last resort: disable SIP (boot to Recovery, run: csrutil disable)\n", - pid, kr); - } - return 0; - } - - /* Build batch CT buffer for unsolved patterns */ - int unsolved_idx[MAX_PATTERNS]; - int n_unsolved = 0; - for (int i = 0; i < npatterns; i++) - if (!patterns[i].solved) unsolved_idx[n_unsolved++] = i; - if (n_unsolved == 0) { - mach_port_deallocate(mach_task_self(), task); - return 0; - } - - unsigned char *batch_ct = malloc(n_unsolved * 16); - unsigned char *batch_pt = malloc(n_unsolved * 16); - if (!batch_ct || !batch_pt) { - free(batch_ct); - free(batch_pt); - mach_port_deallocate(mach_task_self(), task); - return 0; - } - for (int i = 0; i < n_unsolved; i++) - memcpy(batch_ct + i*16, patterns[unsolved_idx[i]].ct, 16); - - mach_vm_address_t addr = 0; - mach_vm_size_t rsize; - vm_region_basic_info_data_64_t info; - mach_msg_type_number_t count; - mach_port_t obj = MACH_PORT_NULL; - - long regions = 0, found_this_pid = 0; - long long total_bytes = 0, tests = 0; - - while (!stop_flag) { - count = VM_REGION_BASIC_INFO_COUNT_64; - kr = mach_vm_region(task, &addr, &rsize, VM_REGION_BASIC_INFO_64, - (vm_region_info_t)&info, &count, &obj); - if (kr != KERN_SUCCESS) break; - regions++; - if (obj != MACH_PORT_NULL) { - mach_port_deallocate(mach_task_self(), obj); - obj = MACH_PORT_NULL; - } - - if ((info.protection & VM_PROT_READ) && rsize > 0 && rsize < REGION_MAX) { - vm_offset_t data; - mach_msg_type_number_t data_cnt; - kr = mach_vm_read(task, addr, rsize, &data, &data_cnt); - if (kr == KERN_SUCCESS) { - unsigned char *buf = (unsigned char *)data; - total_bytes += data_cnt; - - /* Method 1: every 16-byte aligned position (raw binary keys) */ - for (mach_msg_type_number_t j = 0; - j + 16 <= data_cnt && !stop_flag; j += 16) { - tests++; - size_t moved; - CCCryptorStatus st = CCCrypt( - kCCDecrypt, kCCAlgorithmAES128, kCCOptionECBMode, - buf + j, 16, NULL, - batch_ct, n_unsolved * 16, - batch_pt, n_unsolved * 16, &moved); - if (st != kCCSuccess) continue; - - for (int p = 0; p < n_unsolved; p++) { - if (is_image_magic(batch_pt + p*16)) { - if (is_rejected(buf + j)) continue; - int idx = unsolved_idx[p]; - memcpy(patterns[idx].key, buf + j, 16); - patterns[idx].solved = 1; - - char kh[33]; bytes2hex(buf + j, 16, kh); - char ch[33]; bytes2hex(patterns[idx].ct, 16, ch); - printf("\n *** FOUND KEY: %s ***\n", kh); - printf(" Pattern: %s (%d files)\n", - ch, patterns[idx].file_count); - printf(" PID %d, addr=0x%llx+0x%x\n", - pid, addr, j); - - /* Cross-check: does this key solve OTHER patterns? */ - for (int q = 0; q < n_unsolved; q++) { - if (q == p || patterns[unsolved_idx[q]].solved) - continue; - unsigned char tpt[16]; - size_t tm; - CCCrypt(kCCDecrypt, kCCAlgorithmAES128, - kCCOptionECBMode, buf + j, 16, NULL, - patterns[unsolved_idx[q]].ct, 16, - tpt, 16, &tm); - if (is_image_magic(tpt)) { - int qi = unsolved_idx[q]; - memcpy(patterns[qi].key, buf + j, 16); - patterns[qi].solved = 1; - char qch[33]; - bytes2hex(patterns[qi].ct, 16, qch); - printf(" Also solves: %s (%d files)\n", - qch, patterns[qi].file_count); - } - } - - found_this_pid++; - /* Rebuild batch for remaining unsolved */ - n_unsolved = 0; - for (int i = 0; i < npatterns; i++) - if (!patterns[i].solved) - unsolved_idx[n_unsolved++] = i; - for (int i = 0; i < n_unsolved; i++) - memcpy(batch_ct + i*16, - patterns[unsolved_idx[i]].ct, 16); - if (n_unsolved == 0) goto done; - break; /* restart block check with new batch */ - } - } - } - - /* Method 2: hex string [0-9a-f]{16+} at unaligned positions. - * WeChat may store the AES key as a hex-encoded ASCII string - * in memory (e.g. "cfcd208495d565ef" = 16 ASCII bytes). - * We use the raw ASCII bytes directly as the 16-byte AES key, - * since the key is arbitrary bytes and the hex representation - * itself is 16 bytes for a 64-bit key half. */ - int run = 0, run_start = 0; - for (mach_msg_type_number_t j = 0; - j <= data_cnt && !stop_flag; j++) { - int is_hex = (j < data_cnt) && - ((buf[j]>='a' && buf[j]<='f') || - (buf[j]>='0' && buf[j]<='9')); - if (is_hex) { - if (!run) run_start = j; - run++; - } else { - if (run >= 16) { - for (int k = run_start; k+16 <= run_start+run; k++) { - if (k % 16 == 0) continue; /* already tested */ - tests++; - size_t moved; - CCCrypt(kCCDecrypt, kCCAlgorithmAES128, - kCCOptionECBMode, buf+k, 16, NULL, - batch_ct, n_unsolved*16, - batch_pt, n_unsolved*16, &moved); - for (int p = 0; p < n_unsolved; p++) { - if (is_image_magic(batch_pt + p*16)) { - if (is_rejected(buf+k)) continue; - int idx = unsolved_idx[p]; - memcpy(patterns[idx].key, buf+k, 16); - patterns[idx].solved = 1; - char kh[33]; bytes2hex(buf+k, 16, kh); - char ch[33]; - bytes2hex(patterns[idx].ct, 16, ch); - printf("\n *** FOUND KEY: %s ***\n", kh); - printf(" Pattern: %s (%d files)\n", - ch, patterns[idx].file_count); - int ctx_len = data_cnt - run_start; - if (ctx_len > 32) ctx_len = 32; - printf(" ASCII context: %.*s\n", - ctx_len, buf + run_start); - found_this_pid++; - /* Rebuild */ - n_unsolved = 0; - for (int i = 0; i < npatterns; i++) - if (!patterns[i].solved) - unsolved_idx[n_unsolved++] = i; - for (int i = 0; i < n_unsolved; i++) - memcpy(batch_ct + i*16, - patterns[unsolved_idx[i]].ct, 16); - if (n_unsolved == 0) goto done; - break; - } - } - } - } - run = 0; - } - } - - /* Method 3 (deep mode): byte-by-byte scan for top priority patterns */ - if (g_deep_mode && n_unsolved > 0) { - /* Build priority batch: top N unsolved by file_count */ - int prio_idx[DEEP_PRIORITY_MAX]; - int n_prio = 0; - for (int i = 0; i < n_unsolved && n_prio < DEEP_PRIORITY_MAX; i++) { - int pi = unsolved_idx[i]; - if (patterns[pi].file_count >= 10) - prio_idx[n_prio++] = pi; - } - if (n_prio > 0) { - unsigned char prio_ct[DEEP_PRIORITY_MAX * 16]; - unsigned char prio_pt[DEEP_PRIORITY_MAX * 16]; - for (int i = 0; i < n_prio; i++) - memcpy(prio_ct + i*16, patterns[prio_idx[i]].ct, 16); - - for (mach_msg_type_number_t j = 0; - j + 16 <= data_cnt && !stop_flag; j++) { - if (j % 16 == 0) continue; /* already tested in Method 1 */ - tests++; - size_t moved; - CCCryptorStatus st = CCCrypt( - kCCDecrypt, kCCAlgorithmAES128, kCCOptionECBMode, - buf + j, 16, NULL, - prio_ct, n_prio * 16, - prio_pt, n_prio * 16, &moved); - if (st != kCCSuccess) continue; - - for (int p = 0; p < n_prio; p++) { - if (!is_image_magic(prio_pt + p*16)) continue; - if (is_rejected(buf + j)) continue; - int idx = prio_idx[p]; - if (patterns[idx].solved) continue; - memcpy(patterns[idx].key, buf + j, 16); - patterns[idx].solved = 1; - - char kh[33]; bytes2hex(buf + j, 16, kh); - char ch[33]; bytes2hex(patterns[idx].ct, 16, ch); - printf("\n *** FOUND KEY (deep): %s ***\n", kh); - printf(" Pattern: %s (%d files)\n", - ch, patterns[idx].file_count); - printf(" PID %d, addr=0x%llx+0x%x (unaligned)\n", - pid, addr, j); - found_this_pid++; - - /* Cross-check against all unsolved */ - for (int q = 0; q < n_unsolved; q++) { - int qi = unsolved_idx[q]; - if (qi == idx || patterns[qi].solved) continue; - unsigned char tpt[16]; - size_t tm; - CCCrypt(kCCDecrypt, kCCAlgorithmAES128, - kCCOptionECBMode, buf + j, 16, NULL, - patterns[qi].ct, 16, tpt, 16, &tm); - if (is_image_magic(tpt)) { - memcpy(patterns[qi].key, buf + j, 16); - patterns[qi].solved = 1; - char qch[33]; - bytes2hex(patterns[qi].ct, 16, qch); - printf(" Also solves: %s (%d files)\n", - qch, patterns[qi].file_count); - } - } - - /* Rebuild main batch */ - n_unsolved = 0; - for (int i = 0; i < npatterns; i++) - if (!patterns[i].solved) - unsolved_idx[n_unsolved++] = i; - for (int i = 0; i < n_unsolved; i++) - memcpy(batch_ct + i*16, - patterns[unsolved_idx[i]].ct, 16); - /* Rebuild priority batch */ - n_prio = 0; - for (int i = 0; i < n_unsolved && n_prio < DEEP_PRIORITY_MAX; i++) { - int pi2 = unsolved_idx[i]; - if (patterns[pi2].file_count >= 10) - prio_idx[n_prio++] = pi2; - } - for (int i = 0; i < n_prio; i++) - memcpy(prio_ct + i*16, patterns[prio_idx[i]].ct, 16); - if (n_unsolved == 0) goto done; - break; - } - } - } - } - - done: - mach_vm_deallocate(mach_task_self(), data, data_cnt); - if (n_unsolved == 0) break; - } - } - addr += rsize; - if (regions % 500 == 0) { - printf(" [%ld regions, %lld MB, %lld tests]\r", - regions, total_bytes/(1024*1024), tests); - fflush(stdout); - } - } - - printf(" PID %d: %ld regions, %lld MB, %lld tests, %ld keys found \n", - pid, regions, total_bytes/(1024*1024), tests, found_this_pid); - - free(batch_ct); - free(batch_pt); - mach_port_deallocate(mach_task_self(), task); - return (int)found_this_pid; -} - -/* ---- Save results ---- */ -static void save_keys(const char *dir) { - char path[MAX_PATH]; - snprintf(path, sizeof(path), "%s/image_keys.json", dir); - - int solved = 0; - for (int i = 0; i < npatterns; i++) - if (patterns[i].solved) solved++; - if (solved == 0) return; - - FILE *f = fopen(path, "w"); - if (!f) { fprintf(stderr, "Cannot write %s\n", path); return; } - - fprintf(f, "{\n"); - int first = 1; - for (int i = 0; i < npatterns; i++) { - if (!patterns[i].solved) continue; - char ct_hex[33], key_hex[33]; - bytes2hex(patterns[i].ct, 16, ct_hex); - bytes2hex(patterns[i].key, 16, key_hex); - fprintf(f, "%s \"%s\": \"%s\"", - first ? "" : ",\n", ct_hex, key_hex); - first = 0; - } - fprintf(f, "\n}\n"); - fclose(f); - printf("\nSaved %d keys to %s\n", solved, path); -} - -/* ---- Load existing keys from image_keys.json ---- */ -static int load_keys(const char *dir) { - char path[MAX_PATH]; - snprintf(path, sizeof(path), "%s/image_keys.json", dir); - FILE *f = fopen(path, "r"); - if (!f) return 0; - fseek(f, 0, SEEK_END); - long sz = ftell(f); - if (sz <= 0) { fclose(f); return 0; } - fseek(f, 0, SEEK_SET); - char *json = malloc((size_t)sz + 1); - if (!json) { fclose(f); return 0; } - size_t rd = fread(json, 1, (size_t)sz, f); - if (rd != (size_t)sz) { - free(json); - fclose(f); - return 0; - } - fclose(f); - json[rd] = '\0'; - - int loaded = 0; - /* Parse "ct_hex": "key_hex" pairs */ - const char *p = json; - while ((p = strchr(p, '"')) != NULL) { - p++; - const char *ct_end = strchr(p, '"'); - if (!ct_end || ct_end - p != 32) { p = ct_end ? ct_end + 1 : p; continue; } - char ct_str[33]; memcpy(ct_str, p, 32); ct_str[32] = '\0'; - unsigned char ct[16]; - if (hex2bytes(ct_str, ct, 16) != 16) { p = ct_end + 1; continue; } - - p = ct_end + 1; - p = strchr(p, '"'); - if (!p) break; - p++; - const char *key_end = strchr(p, '"'); - if (!key_end || key_end - p != 32) { p = key_end ? key_end + 1 : p; continue; } - char key_str[33]; memcpy(key_str, p, 32); key_str[32] = '\0'; - unsigned char key[16]; - if (hex2bytes(key_str, key, 16) != 16) { p = key_end + 1; continue; } - - /* Match to pattern */ - for (int i = 0; i < npatterns; i++) { - if (!patterns[i].solved && memcmp(patterns[i].ct, ct, 16) == 0) { - memcpy(patterns[i].key, key, 16); - patterns[i].solved = 1; - loaded++; - break; - } - } - p = key_end + 1; - } - free(json); - return loaded; -} - -/* ---- Main ---- */ -int main(int argc, char *argv[]) { - signal(SIGINT, sigint_handler); - - printf("=== WeChat V2 Image Key Scanner ===\n\n"); - if (getuid() != 0) { - fprintf(stderr, "ERROR: Run with sudo!\n"); return 1; - } - - /* Determine image directory */ - char image_dir[MAX_PATH] = ""; - char exe_dir[MAX_PATH] = "."; - int deep_mode = 0; - const char *last_slash = strrchr(argv[0], '/'); - if (last_slash) { - int len = (int)(last_slash - argv[0]); - snprintf(exe_dir, sizeof(exe_dir), "%.*s", len, argv[0]); - } - - for (int i = 1; i < argc; i++) { - if (strcmp(argv[i], "--deep") == 0) - deep_mode = 1; - else if (image_dir[0] == '\0') { - strncpy(image_dir, argv[i], sizeof(image_dir) - 1); - image_dir[sizeof(image_dir) - 1] = '\0'; - } - } - - if (image_dir[0] == '\0') { - /* Read config.json */ - char cfg_path[MAX_PATH]; - snprintf(cfg_path, sizeof(cfg_path), "%s/config.json", exe_dir); - FILE *cf = fopen(cfg_path, "r"); - if (cf) { - fseek(cf, 0, SEEK_END); - long sz = ftell(cf); - if (sz <= 0) { fclose(cf); return 1; } - fseek(cf, 0, SEEK_SET); - char *json = malloc((size_t)sz + 1); - if (!json) { fclose(cf); return 1; } - size_t rd = fread(json, 1, (size_t)sz, cf); - if (rd != (size_t)sz) { - free(json); - fclose(cf); - return 1; - } - json[rd] = '\0'; - fclose(cf); - char db_dir[MAX_PATH]; - if (json_get_string(json, "db_dir", db_dir, sizeof(db_dir))) { - char *s = strrchr(db_dir, '/'); - if (!s) s = strrchr(db_dir, '\\'); - if (s) { - int plen = (int)(s - db_dir); - snprintf(image_dir, sizeof(image_dir), - "%.*s/msg", plen, db_dir); - } - } - free(json); - } - } - - /* Auto-detect: scan ~/Library/Containers/com.tencent.xinWeChat */ - if (image_dir[0] == '\0') { - const char *home = getenv("HOME"); - if (!home) home = "/Users"; - char base[MAX_PATH]; - snprintf(base, sizeof(base), - "%s/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files", - home); - DIR *d = opendir(base); - if (d) { - struct dirent *ent; - while ((ent = readdir(d))) { - if (ent->d_name[0] == '.') continue; - char candidate[MAX_PATH]; - snprintf(candidate, sizeof(candidate), "%s/%s/msg", base, ent->d_name); - struct stat st; - if (stat(candidate, &st) == 0 && S_ISDIR(st.st_mode)) { - strncpy(image_dir, candidate, sizeof(image_dir) - 1); - printf("Auto-detected image directory:\n %s\n\n", image_dir); - break; - } - } - closedir(d); - } - } - - if (image_dir[0] == '\0') { - fprintf(stderr, "ERROR: Cannot determine image directory.\n"); - fprintf(stderr, "Tried:\n"); - fprintf(stderr, " 1. Command line argument\n"); - fprintf(stderr, " 2. config.json db_dir\n"); - fprintf(stderr, " 3. Auto-detect ~/Library/Containers/com.tencent.xinWeChat/...\n\n"); - fprintf(stderr, "Usage: sudo %s [--deep] [image_dir]\n", argv[0]); - fprintf(stderr, " image_dir: path to .../xwechat_files//msg\n"); - return 1; - } - - /* Phase 1: Discover patterns */ - printf("Discovering encryption patterns in:\n %s\n\n", image_dir); - discover_dir(image_dir); - if (npatterns == 0) { - fprintf(stderr, "No V2 .dat files found!\n"); return 1; - } - qsort(patterns, npatterns, sizeof(pattern_t), cmp_patterns); - - int total_covered = 0; - printf("Found %d patterns across %d V2 files:\n", npatterns, total_v2_files); - for (int i = 0; i < npatterns; i++) { - char ch[33]; bytes2hex(patterns[i].ct, 16, ch); - printf(" #%-2d %s (%d files)\n", i+1, ch, patterns[i].file_count); - total_covered += patterns[i].file_count; - } - if (total_covered < total_v2_files) - printf(" ... and %d files in overflow patterns\n", - total_v2_files - total_covered); - - /* Load previously found keys */ - int preloaded = load_keys(exe_dir); - if (preloaded > 0) - printf("\nLoaded %d existing keys from image_keys.json\n", preloaded); - - if (deep_mode) { - g_deep_mode = 1; - printf("\n*** DEEP MODE: byte-by-byte scan for top %d unsolved patterns ***\n", - DEEP_PRIORITY_MAX); - } - - /* Phase 2: Continuous scanning */ - printf("\nScanning WeChat memory — keep browsing images! (Ctrl+C to stop)\n"); - int round = 0; - while (!stop_flag) { - int unsolved = 0; - for (int i = 0; i < npatterns; i++) - if (!patterns[i].solved) unsolved++; - if (unsolved == 0) break; - - round++; - pid_t pids[64]; - int npids = get_wechat_pids(pids, 64); - if (npids == 0) { - printf(" No WeChat processes found, waiting...\n"); - sleep(3); - continue; - } - - printf("\n--- Round %d: %d unsolved / %d total, %d PIDs ---\n", - round, unsolved, npatterns, npids); - - int found_round = 0; - for (int i = 0; i < npids && !stop_flag; i++) { - found_round += scan_pid(pids[i]); - } - - unsolved = 0; - int solved_files = 0; - for (int i = 0; i < npatterns; i++) { - if (patterns[i].solved) solved_files += patterns[i].file_count; - else unsolved++; - } - - if (found_round > 0) { - printf("\n Progress: %d/%d patterns solved (%d/%d files)\n", - npatterns - unsolved, npatterns, - solved_files, total_v2_files); - /* Verify newly found keys */ - for (int i = 0; i < npatterns; i++) { - if (patterns[i].solved && !verify_key(i)) { - char kh[33]; bytes2hex(patterns[i].key, 16, kh); - printf(" REJECTED: %s (failed verification)\n", kh); - add_rejected(patterns[i].key); - patterns[i].solved = 0; - memset(patterns[i].key, 0, 16); - } - } - /* Save after each find */ - save_keys(exe_dir); - } - - if (unsolved > 0 && !stop_flag) { - printf(" Keep browsing images in different chats...\n"); - sleep(1); - } - } - - /* Phase 3: Summary */ - save_keys(exe_dir); - - int solved = 0, solved_files = 0; - for (int i = 0; i < npatterns; i++) { - if (patterns[i].solved) { - solved++; - solved_files += patterns[i].file_count; - } - } - - printf("\n==================================================\n"); - if (solved == npatterns) { - printf("ALL %d patterns solved! (%d files)\n", npatterns, total_v2_files); - } else { - printf("%d/%d patterns solved (%d/%d files)\n", - solved, npatterns, solved_files, total_v2_files); - printf("Unsolved:\n"); - for (int i = 0; i < npatterns; i++) { - if (patterns[i].solved) continue; - char ch[33]; bytes2hex(patterns[i].ct, 16, ch); - printf(" %s (%d files)\n", ch, patterns[i].file_count); - } - } - - /* Count unique keys */ - int unique_keys = 0; - for (int i = 0; i < npatterns; i++) { - if (!patterns[i].solved) continue; - int dup = 0; - for (int j = 0; j < i; j++) - if (patterns[j].solved && - memcmp(patterns[i].key, patterns[j].key, 16) == 0) { dup = 1; break; } - if (!dup) unique_keys++; - } - printf("%d unique key(s) found.\n", unique_keys); - printf("==================================================\n"); - - return (solved > 0) ? 0 : 1; -} diff --git a/find_image_key.py b/find_image_key.py deleted file mode 100644 index afb9ad1..0000000 --- a/find_image_key.py +++ /dev/null @@ -1,410 +0,0 @@ -"""从微信进程内存中提取图片 AES 密钥 (V2 .dat 格式) - -V2 .dat 文件结构: - [6B signature: 07 08 V2 08 07] [4B aes_size LE] [4B xor_size LE] [1B padding] - [aes_size bytes AES-ECB encrypted] [raw_data unencrypted] [xor_size bytes XOR encrypted] - -AES key: 16-byte ASCII string found in Weixin.exe process memory -XOR key: single byte, same as old format (derived from JPEG FF D9 ending) - -Usage: - 1. 打开微信, 进入聊天/朋友圈, 点击查看 2-3 张图片 - 2. 立即运行: python find_image_key.py -""" -import os -import sys -import re -import struct -import glob -import json -import time -import ctypes -from ctypes import wintypes -from Crypto.Cipher import AES -from Crypto.Util import Padding - -# Windows API constants -PROCESS_ALL_ACCESS = 0x1F0FFF -PROCESS_VM_READ = 0x0010 -PROCESS_QUERY_INFORMATION = 0x0400 -MEM_COMMIT = 0x1000 -PAGE_NOACCESS = 0x01 -PAGE_GUARD = 0x100 -PAGE_READWRITE = 0x04 -PAGE_WRITECOPY = 0x08 -PAGE_EXECUTE_READWRITE = 0x40 -PAGE_EXECUTE_WRITECOPY = 0x80 - -class MEMORY_BASIC_INFORMATION(ctypes.Structure): - _fields_ = [ - ("BaseAddress", ctypes.c_void_p), - ("AllocationBase", ctypes.c_void_p), - ("AllocationProtect", wintypes.DWORD), - ("RegionSize", ctypes.c_size_t), - ("State", wintypes.DWORD), - ("Protect", wintypes.DWORD), - ("Type", wintypes.DWORD), - ] - -kernel32 = ctypes.windll.kernel32 - -# 正则: 精确 32 字符 alphanum (前后是非 alphanum 或边界) -RE_KEY32 = re.compile(rb'(?= 2: - pids.append(int(parts[1])) - return pids - - -def find_v2_ciphertext(attach_dir): - """从多个 V2 .dat 文件中提取第一个 AES 密文块 (16 bytes)""" - v2_magic = b'\x07\x08V2\x08\x07' - - # Search _t.dat (thumbnails, likely JPEG) - pattern = os.path.join(attach_dir, "*", "*", "Img", "*_t.dat") - dat_files = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True) - - for f in dat_files[:100]: - try: - with open(f, 'rb') as fp: - header = fp.read(31) - if header[:6] == v2_magic and len(header) >= 31: - return header[15:31], os.path.basename(f) - except: - continue - return None, None - - -def find_xor_key(attach_dir): - """从缩略图文件末尾推导 XOR key (JPEG 结尾 FF D9)""" - v2_magic = b'\x07\x08V2\x08\x07' - pattern = os.path.join(attach_dir, "*", "*", "Img", "*_t.dat") - dat_files = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True) - - tail_counts = {} - for f in dat_files[:32]: - try: - sz = os.path.getsize(f) - with open(f, 'rb') as fp: - head = fp.read(6) - fp.seek(sz - 2) - tail = fp.read(2) - if head == v2_magic and len(tail) == 2: - key = (tail[0], tail[1]) - tail_counts[key] = tail_counts.get(key, 0) + 1 - except: - continue - - if not tail_counts: - return None - - most_common = max(tail_counts, key=tail_counts.get) - x, y = most_common - xor_key = x ^ 0xFF - check = y ^ 0xD9 - - if xor_key == check: - return xor_key - return xor_key # return best guess anyway - - -def try_key(key_bytes, ciphertext): - """Try decrypting ciphertext with key, return format name if successful""" - try: - cipher = AES.new(key_bytes, AES.MODE_ECB) - dec = cipher.decrypt(ciphertext) - if dec[:3] == b'\xFF\xD8\xFF': - return 'JPEG' - if dec[:4] == bytes([0x89, 0x50, 0x4E, 0x47]): - return 'PNG' - if dec[:4] == b'RIFF': - return 'WEBP' - if dec[:4] == b'wxgf': - return 'WXGF' - if dec[:3] == b'GIF': - return 'GIF' - except: - pass - return None - - -def is_rw_protect(protect): - """Check if memory region is readable/writable (where string keys live)""" - rw_flags = (PAGE_READWRITE | PAGE_WRITECOPY | - PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY) - return (protect & rw_flags) != 0 - - -def scan_memory_for_aes_key(pid, ciphertext): - """扫描微信进程内存寻找 AES key (regex 加速版)""" - access = PROCESS_VM_READ | PROCESS_QUERY_INFORMATION - h_process = kernel32.OpenProcess(access, False, pid) - if not h_process: - print(f" 无法打开进程 {pid} (尝试以管理员运行)", flush=True) - return None - - try: - # Enumerate memory regions - address = 0 - mbi = MEMORY_BASIC_INFORMATION() - rw_regions = [] - all_regions = [] - - while address < 0x7FFFFFFFFFFF: - result = kernel32.VirtualQueryEx( - h_process, ctypes.c_void_p(address), - ctypes.byref(mbi), ctypes.sizeof(mbi) - ) - if result == 0: - break - if (mbi.State == MEM_COMMIT and - mbi.Protect != PAGE_NOACCESS and - (mbi.Protect & PAGE_GUARD) == 0 and - mbi.RegionSize <= 50 * 1024 * 1024): - region = (mbi.BaseAddress, mbi.RegionSize, mbi.Protect) - all_regions.append(region) - if is_rw_protect(mbi.Protect): - rw_regions.append(region) - next_addr = address + mbi.RegionSize - if next_addr <= address: - break - address = next_addr - - rw_mb = sum(r[1] for r in rw_regions) / 1024 / 1024 - all_mb = sum(r[1] for r in all_regions) / 1024 / 1024 - print(f" RW 区域: {len(rw_regions)} ({rw_mb:.0f} MB), 总计: {len(all_regions)} ({all_mb:.0f} MB)", flush=True) - - # Phase 1: 只扫描 RW 区域 (key 字符串最可能在这里) - print(" === Phase 1: 扫描 RW 内存 ===", flush=True) - result = _scan_regions(h_process, rw_regions, ciphertext) - if result: - return result - - # Phase 2: 扫描所有可读区域 - print(" === Phase 2: 扫描所有内存 ===", flush=True) - # 排除已扫描的 RW 区域 - rw_set = set((r[0], r[1]) for r in rw_regions) - other_regions = [r for r in all_regions if (r[0], r[1]) not in rw_set] - result = _scan_regions(h_process, other_regions, ciphertext) - if result: - return result - - return None - - finally: - kernel32.CloseHandle(h_process) - - -def _scan_regions(h_process, regions, ciphertext): - """扫描指定内存区域列表,返回找到的 key 或 None""" - candidates_32 = 0 - candidates_16 = 0 - t0 = time.time() - - for idx, (base_addr, region_size, _protect) in enumerate(regions): - if idx % 100 == 0: - elapsed = time.time() - t0 - print(f" 扫描 {idx}/{len(regions)} ({elapsed:.1f}s)", end='\r', flush=True) - - buffer = ctypes.create_string_buffer(region_size) - bytes_read = ctypes.c_size_t(0) - ok = kernel32.ReadProcessMemory( - h_process, ctypes.c_void_p(base_addr), - buffer, region_size, ctypes.byref(bytes_read) - ) - if not ok or bytes_read.value < 32: - continue - - data = buffer.raw[:bytes_read.value] - - # 用正则找 32 字符 alphanum (C 级速度) - for m in RE_KEY32.finditer(data): - key_bytes = m.group() - candidates_32 += 1 - - # 前 16 字符作为 AES-128 key - fmt = try_key(key_bytes[:16], ciphertext) - if fmt: - key_str = key_bytes.decode('ascii') - print(f"\n*** 找到 AES key (32-char)! → {fmt} ***", flush=True) - print(f" 完整: {key_str}", flush=True) - print(f" AES key: {key_str[:16]}", flush=True) - return key_str[:16] - - # 也试完整 32 字节作 AES-256 - fmt = try_key(key_bytes, ciphertext) - if fmt: - key_str = key_bytes.decode('ascii') - print(f"\n*** 找到 AES key (32-byte)! → {fmt} ***", flush=True) - print(f" 完整: {key_str}", flush=True) - return key_str - - # 也找独立的 16 字符 alphanum - for m in RE_KEY16.finditer(data): - key_bytes = m.group() - candidates_16 += 1 - - fmt = try_key(key_bytes, ciphertext) - if fmt: - key_str = key_bytes.decode('ascii') - print(f"\n*** 找到 AES key (16-char)! → {fmt} ***", flush=True) - print(f" AES key: {key_str}", flush=True) - return key_str - - elapsed = time.time() - t0 - print(f"\n 测试: {candidates_32} x 32-char + {candidates_16} x 16-char ({elapsed:.1f}s)", flush=True) - return None - - -def verify_and_decrypt(attach_dir, aes_key_str, xor_key): - """完整解密一个 V2 文件作为验证""" - v2_magic = b'\x07\x08V2\x08\x07' - key = aes_key_str.encode('ascii')[:16] - - pattern = os.path.join(attach_dir, "*", "*", "Img", "*_t.dat") - dat_files = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True) - - for f in dat_files[:10]: - try: - with open(f, 'rb') as fp: - data = fp.read() - if data[:6] != v2_magic: - continue - - sig, aes_size, xor_size = struct.unpack_from('<6sLL', data) - - # AES 对齐: 向上取整到 16 的倍数 (PKCS7 填充) - aligned_aes_size = aes_size - aligned_aes_size -= ~(~aligned_aes_size % 16) - - offset = 15 - aes_data = data[offset:offset + aligned_aes_size] - cipher = AES.new(key, AES.MODE_ECB) - dec_aes = Padding.unpad(cipher.decrypt(aes_data), AES.block_size) - offset += aligned_aes_size - - # Raw portion - raw_data = data[offset:len(data) - xor_size] - offset += len(raw_data) - - # XOR portion - xor_data = data[offset:] - dec_xor = bytes(b ^ xor_key for b in xor_data) if xor_key is not None else xor_data - - result = dec_aes + raw_data + dec_xor - - fmt = "unknown" - ext = ".bin" - if result[:3] == b'\xFF\xD8\xFF': - fmt, ext = "JPEG", ".jpg" - elif result[:4] == bytes([0x89, 0x50, 0x4E, 0x47]): - fmt, ext = "PNG", ".png" - elif result[:4] == b'RIFF': - fmt, ext = "WEBP", ".webp" - elif result[:4] == b'wxgf': - fmt, ext = "WXGF", ".hevc" - - print(f" {os.path.basename(f)} -> {fmt} ({len(result):,}B)", flush=True) - - if fmt != "unknown": - out_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "decoded_images") - os.makedirs(out_dir, exist_ok=True) - out_path = os.path.join(out_dir, os.path.splitext(os.path.basename(f))[0] + ext) - with open(out_path, 'wb') as fp: - fp.write(result) - print(f" saved: {out_path}", flush=True) - return True - except Exception as e: - continue - return False - - -def main(): - config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.json') - with open(config_path, encoding="utf-8") as f: - config = json.load(f) - - db_dir = config['db_dir'] - base_dir = os.path.dirname(db_dir) - attach_dir = os.path.join(base_dir, 'msg', 'attach') - - # 1. XOR key - print("=== XOR Key ===", flush=True) - xor_key = find_xor_key(attach_dir) - if xor_key is not None: - print(f"XOR key: 0x{xor_key:02x}", flush=True) - - # 2. V2 ciphertext - print("\n=== V2 ciphertext ===", flush=True) - ciphertext, ct_file = find_v2_ciphertext(attach_dir) - if ciphertext is None: - print("No V2 .dat files found") - return - print(f"File: {ct_file}", flush=True) - print(f"Cipher: {ciphertext.hex()}", flush=True) - - # 3. Check if already have key in config - if config.get('image_aes_key'): - print(f"\nExisting image_aes_key: {config['image_aes_key']}", flush=True) - fmt = try_key(config['image_aes_key'].encode('ascii')[:16], ciphertext) - if fmt: - print(f"Key valid! -> {fmt}", flush=True) - print("\n=== Verify decrypt ===", flush=True) - verify_and_decrypt(attach_dir, config['image_aes_key'], xor_key) - return - else: - print("Saved key invalid, re-scanning...", flush=True) - - # 4. Scan memory - print("\n=== Scanning WeChat process memory ===", flush=True) - pids = get_wechat_pids() - if not pids: - print("WeChat not running!") - return - print(f"PIDs: {pids}", flush=True) - print("Tip: View 2-3 images in WeChat first, then run this script immediately\n", flush=True) - - aes_key = None - for pid in pids: - print(f"Scanning PID {pid}...", flush=True) - aes_key = scan_memory_for_aes_key(pid, ciphertext) - if aes_key: - break - - if aes_key: - print(f"\n=== Result ===", flush=True) - print(f"AES key: {aes_key}", flush=True) - print(f"XOR key: 0x{xor_key:02x}" if xor_key is not None else "XOR key: unknown", flush=True) - - config['image_aes_key'] = aes_key - if xor_key is not None: - config['image_xor_key'] = xor_key - with open(config_path, 'w', encoding="utf-8") as f: - json.dump(config, f, indent=2, ensure_ascii=False) - print(f"Saved to {config_path}", flush=True) - - print("\n=== Verify decrypt ===", flush=True) - verify_and_decrypt(attach_dir, aes_key, xor_key) - else: - print("\nAES key not found!", flush=True) - print("Steps:", flush=True) - print(" 1. Login WeChat and keep it running", flush=True) - print(" 2. Open Moments or a chat, view 2-3 images (tap to open full size)", flush=True) - print(" 3. Immediately re-run this script", flush=True) - - -if __name__ == '__main__': - main() diff --git a/find_image_key_monitor.py b/find_image_key_monitor.py deleted file mode 100644 index 437a47f..0000000 --- a/find_image_key_monitor.py +++ /dev/null @@ -1,318 +0,0 @@ -"""持续监控微信进程内存,捕获图片 AES 密钥 - -运行此脚本后,在微信中打开查看几张图片。 -脚本会自动检测到 key 并保存到 config.json。 - -按 Ctrl+C 退出。 -""" -import os -import sys -import re -import struct -import glob -import json -import time -import ctypes -from ctypes import wintypes -from Crypto.Cipher import AES -from Crypto.Util import Padding - -# Windows API constants -PROCESS_VM_READ = 0x0010 -PROCESS_QUERY_INFORMATION = 0x0400 -MEM_COMMIT = 0x1000 -PAGE_NOACCESS = 0x01 -PAGE_GUARD = 0x100 -PAGE_READWRITE = 0x04 -PAGE_WRITECOPY = 0x08 -PAGE_EXECUTE_READWRITE = 0x40 -PAGE_EXECUTE_WRITECOPY = 0x80 - -class MEMORY_BASIC_INFORMATION(ctypes.Structure): - _fields_ = [ - ("BaseAddress", ctypes.c_void_p), - ("AllocationBase", ctypes.c_void_p), - ("AllocationProtect", wintypes.DWORD), - ("RegionSize", ctypes.c_size_t), - ("State", wintypes.DWORD), - ("Protect", wintypes.DWORD), - ("Type", wintypes.DWORD), - ] - -kernel32 = ctypes.windll.kernel32 - -# Regex for key patterns -RE_KEY32 = re.compile(rb'(?= 2: - pids.append(int(parts[1])) - return pids - - -def find_v2_ciphertext(attach_dir): - v2_magic = b'\x07\x08V2\x08\x07' - pattern = os.path.join(attach_dir, "*", "*", "Img", "*_t.dat") - dat_files = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True) - for f in dat_files[:100]: - try: - with open(f, 'rb') as fp: - header = fp.read(31) - if header[:6] == v2_magic and len(header) >= 31: - return header[15:31], os.path.basename(f) - except: - continue - return None, None - - -def find_xor_key(attach_dir): - v2_magic = b'\x07\x08V2\x08\x07' - pattern = os.path.join(attach_dir, "*", "*", "Img", "*_t.dat") - dat_files = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True) - tail_counts = {} - for f in dat_files[:32]: - try: - sz = os.path.getsize(f) - with open(f, 'rb') as fp: - head = fp.read(6) - fp.seek(sz - 2) - tail = fp.read(2) - if head == v2_magic and len(tail) == 2: - key = (tail[0], tail[1]) - tail_counts[key] = tail_counts.get(key, 0) + 1 - except: - continue - if not tail_counts: - return None - most_common = max(tail_counts, key=tail_counts.get) - return most_common[0] ^ 0xFF - - -def try_key(key_bytes, ciphertext): - try: - cipher = AES.new(key_bytes, AES.MODE_ECB) - dec = cipher.decrypt(ciphertext) - if dec[:3] == b'\xFF\xD8\xFF': return 'JPEG' - if dec[:4] == bytes([0x89, 0x50, 0x4E, 0x47]): return 'PNG' - if dec[:4] == b'RIFF': return 'WEBP' - if dec[:4] == b'wxgf': return 'WXGF' - if dec[:3] == b'GIF': return 'GIF' - except: - pass - return None - - -def is_rw_protect(protect): - rw_flags = (PAGE_READWRITE | PAGE_WRITECOPY | - PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY) - return (protect & rw_flags) != 0 - - -def get_rw_regions(h_process): - """Get RW committed memory regions""" - address = 0 - mbi = MEMORY_BASIC_INFORMATION() - regions = [] - while address < 0x7FFFFFFFFFFF: - result = kernel32.VirtualQueryEx( - h_process, ctypes.c_void_p(address), - ctypes.byref(mbi), ctypes.sizeof(mbi) - ) - if result == 0: - break - if (mbi.State == MEM_COMMIT and - mbi.Protect != PAGE_NOACCESS and - (mbi.Protect & PAGE_GUARD) == 0 and - mbi.RegionSize <= 50 * 1024 * 1024 and - is_rw_protect(mbi.Protect)): - regions.append((mbi.BaseAddress, mbi.RegionSize)) - next_addr = address + mbi.RegionSize - if next_addr <= address: - break - address = next_addr - return regions - - -def quick_scan(h_process, regions, ciphertext): - """Fast scan of RW regions, return key or None""" - for base_addr, region_size in regions: - buffer = ctypes.create_string_buffer(region_size) - bytes_read = ctypes.c_size_t(0) - ok = kernel32.ReadProcessMemory( - h_process, ctypes.c_void_p(base_addr), - buffer, region_size, ctypes.byref(bytes_read) - ) - if not ok or bytes_read.value < 32: - continue - - data = buffer.raw[:bytes_read.value] - - # 32-char keys (first 16 as AES-128) - for m in RE_KEY32.finditer(data): - key_bytes = m.group() - fmt = try_key(key_bytes[:16], ciphertext) - if fmt: - return key_bytes.decode('ascii')[:16], fmt - fmt = try_key(key_bytes, ciphertext) - if fmt: - return key_bytes.decode('ascii'), fmt - - # Standalone 16-char keys - for m in RE_KEY16.finditer(data): - key_bytes = m.group() - fmt = try_key(key_bytes, ciphertext) - if fmt: - return key_bytes.decode('ascii'), fmt - - return None, None - - -def verify_and_decrypt(attach_dir, aes_key_str, xor_key): - """Decrypt one V2 file as verification""" - v2_magic = b'\x07\x08V2\x08\x07' - key = aes_key_str.encode('ascii')[:16] - pattern = os.path.join(attach_dir, "*", "*", "Img", "*_t.dat") - dat_files = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True) - - for f in dat_files[:10]: - try: - with open(f, 'rb') as fp: - data = fp.read() - if data[:6] != v2_magic: - continue - sig, aes_size, xor_size = struct.unpack_from('<6sLL', data) - aligned_aes_size = aes_size - aligned_aes_size -= ~(~aligned_aes_size % 16) - offset = 15 - aes_data = data[offset:offset + aligned_aes_size] - cipher = AES.new(key, AES.MODE_ECB) - dec_aes = Padding.unpad(cipher.decrypt(aes_data), AES.block_size) - offset += aligned_aes_size - raw_data = data[offset:len(data) - xor_size] - offset += len(raw_data) - xor_data = data[offset:] - dec_xor = bytes(b ^ xor_key for b in xor_data) if xor_key is not None else xor_data - result = dec_aes + raw_data + dec_xor - - fmt, ext = "unknown", ".bin" - if result[:3] == b'\xFF\xD8\xFF': fmt, ext = "JPEG", ".jpg" - elif result[:4] == bytes([0x89, 0x50, 0x4E, 0x47]): fmt, ext = "PNG", ".png" - elif result[:4] == b'RIFF': fmt, ext = "WEBP", ".webp" - elif result[:4] == b'wxgf': fmt, ext = "WXGF", ".hevc" - - if fmt != "unknown": - out_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "decoded_images") - os.makedirs(out_dir, exist_ok=True) - out_path = os.path.join(out_dir, os.path.splitext(os.path.basename(f))[0] + ext) - with open(out_path, 'wb') as fp: - fp.write(result) - print(f" Verified: {os.path.basename(f)} -> {fmt} ({len(result):,}B)", flush=True) - print(f" Saved: {out_path}", flush=True) - return True - except: - continue - return False - - -def main(): - config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.json') - with open(config_path, encoding="utf-8") as f: - config = json.load(f) - - db_dir = config['db_dir'] - base_dir = os.path.dirname(db_dir) - attach_dir = os.path.join(base_dir, 'msg', 'attach') - - xor_key = find_xor_key(attach_dir) - print(f"XOR key: 0x{xor_key:02x}" if xor_key else "XOR key: unknown", flush=True) - - ciphertext, ct_file = find_v2_ciphertext(attach_dir) - if ciphertext is None: - print("No V2 .dat files found") - return - print(f"V2 cipher: {ciphertext.hex()} ({ct_file})", flush=True) - - # Check existing key - if config.get('image_aes_key'): - fmt = try_key(config['image_aes_key'].encode('ascii')[:16], ciphertext) - if fmt: - print(f"Existing key valid: {config['image_aes_key']} -> {fmt}", flush=True) - return - - pids = get_wechat_pids() - if not pids: - print("WeChat not running!") - return - - # Find the main PID (largest memory footprint) - main_pid = pids[0] - print(f"\nMonitoring PID {main_pid} (main WeChat process)", flush=True) - print("=" * 60, flush=True) - print("NOW: Open WeChat and tap to view 2-3 images (full size)", flush=True) - print("The script will automatically detect the key...", flush=True) - print("=" * 60, flush=True) - - access = PROCESS_VM_READ | PROCESS_QUERY_INFORMATION - h_process = kernel32.OpenProcess(access, False, main_pid) - if not h_process: - print(f"Cannot open process {main_pid} (run as admin?)", flush=True) - return - - try: - # Get regions once (they don't change much) - regions = get_rw_regions(h_process) - total_mb = sum(r[1] for r in regions) / 1024 / 1024 - print(f"RW regions: {len(regions)} ({total_mb:.0f} MB)", flush=True) - - scan_count = 0 - while True: - scan_count += 1 - t0 = time.time() - aes_key, fmt = quick_scan(h_process, regions, ciphertext) - elapsed = time.time() - t0 - - if aes_key: - print(f"\n{'='*60}", flush=True) - print(f"*** FOUND AES key! -> {fmt} ***", flush=True) - print(f"AES key: {aes_key}", flush=True) - print(f"XOR key: 0x{xor_key:02x}" if xor_key else "XOR key: unknown", flush=True) - print(f"{'='*60}", flush=True) - - config['image_aes_key'] = aes_key - if xor_key is not None: - config['image_xor_key'] = xor_key - with open(config_path, 'w', encoding="utf-8") as f: - json.dump(config, f, indent=2, ensure_ascii=False) - print(f"Saved to {config_path}", flush=True) - - verify_and_decrypt(attach_dir, aes_key, xor_key) - return - - print(f" Scan #{scan_count}: no key found ({elapsed:.1f}s)", end='\r', flush=True) - - # Wait 5 seconds before next scan - time.sleep(5) - - # Refresh regions periodically (every 5 scans) - if scan_count % 5 == 0: - regions = get_rw_regions(h_process) - - except KeyboardInterrupt: - print("\nStopped by user", flush=True) - finally: - kernel32.CloseHandle(h_process) - - -if __name__ == '__main__': - main() diff --git a/key_scan_common.py b/key_scan_common.py deleted file mode 100644 index 7975328..0000000 --- a/key_scan_common.py +++ /dev/null @@ -1,169 +0,0 @@ -""" -跨平台共享的内存扫描逻辑:HMAC 验证、DB 收集、hex 模式匹配与结果输出。 - -Windows / Linux 版分别实现进程发现和内存读取,共用此模块的核心算法。 -""" -import hashlib -import hmac as hmac_mod -import json -import os -import re -import struct -import time - -PAGE_SZ = 4096 -KEY_SZ = 32 -SALT_SZ = 16 - - -def verify_enc_key(enc_key, db_page1): - """通过 HMAC-SHA512 校验 page 1 验证 enc_key 是否正确。""" - salt = db_page1[:SALT_SZ] - mac_salt = bytes(b ^ 0x3A for b in salt) - mac_key = hashlib.pbkdf2_hmac("sha512", enc_key, mac_salt, 2, dklen=KEY_SZ) - hmac_data = db_page1[SALT_SZ: PAGE_SZ - 80 + 16] - stored_hmac = db_page1[PAGE_SZ - 64: PAGE_SZ] - hm = hmac_mod.new(mac_key, hmac_data, hashlib.sha512) - hm.update(struct.pack(" 96 and hex_len % 2 == 0: - enc_key_hex = hex_str[:64] - salt_hex = hex_str[-32:] - if salt_hex in remaining_salts: - enc_key = bytes.fromhex(enc_key_hex) - for rel, path, sz, s, page1 in db_files: - if s == salt_hex and verify_enc_key(enc_key, page1): - key_map[salt_hex] = enc_key_hex - remaining_salts.discard(salt_hex) - dbs = salt_to_dbs[salt_hex] - print_fn(f"\n [FOUND] salt={salt_hex} (long hex {hex_len})") - print_fn(f" enc_key={enc_key_hex}") - print_fn(f" PID={pid} 地址: 0x{addr:016X}") - print_fn(f" 数据库: {', '.join(dbs)}") - break - - return matches - - -def cross_verify_keys(db_files, salt_to_dbs, key_map, print_fn): - """用已找到的 key 交叉验证未匹配的 salt。""" - missing_salts = set(salt_to_dbs.keys()) - set(key_map.keys()) - if not missing_salts or not key_map: - return - print_fn(f"\n还有 {len(missing_salts)} 个 salt 未匹配,尝试交叉验证...") - for salt_hex in list(missing_salts): - for rel, path, sz, s, page1 in db_files: - if s == salt_hex: - for known_salt, known_key_hex in key_map.items(): - enc_key = bytes.fromhex(known_key_hex) - if verify_enc_key(enc_key, page1): - key_map[salt_hex] = known_key_hex - print_fn(f" [CROSS] salt={salt_hex} 可用 key from salt={known_salt}") - missing_salts.discard(salt_hex) - break - - -def save_results(db_files, salt_to_dbs, key_map, db_dir, out_file, print_fn): - """输出扫描结果并保存 JSON。""" - print_fn(f"\n{'=' * 60}") - print_fn(f"结果: {len(key_map)}/{len(salt_to_dbs)} salts 找到密钥") - - result = {} - for rel, path, sz, salt_hex, page1 in db_files: - if salt_hex in key_map: - result[rel] = { - "enc_key": key_map[salt_hex], - "salt": salt_hex, - "size_mb": round(sz / 1024 / 1024, 1) - } - print_fn(f" OK: {rel} ({sz / 1024 / 1024:.1f}MB)") - else: - print_fn(f" MISSING: {rel} (salt={salt_hex})") - - if not result: - print_fn(f"\n[!] 未提取到任何密钥,保留已有的 {out_file}(如存在)") - raise RuntimeError("未能从任何微信进程中提取到密钥") - - result["_db_dir"] = db_dir - with open(out_file, 'w', encoding='utf-8') as f: - json.dump(result, f, indent=2, ensure_ascii=False) - print_fn(f"\n密钥保存到: {out_file}") - - missing = [rel for rel, path, sz, salt_hex, page1 in db_files if salt_hex not in key_map] - if missing: - print_fn(f"\n未找到密钥的数据库:") - for rel in missing: - print_fn(f" {rel}") diff --git a/latency_test.py b/latency_test.py deleted file mode 100644 index 62a670e..0000000 --- a/latency_test.py +++ /dev/null @@ -1,175 +0,0 @@ -"""测量消息延迟 - 用mtime检测WAL变化(WAL文件是预分配固定大小的)""" -import time, os, sys, io, hashlib, struct, sqlite3, json -from datetime import datetime -from Crypto.Cipher import AES - -sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') - -PAGE_SZ = 4096; KEY_SZ = 32; SALT_SZ = 16; RESERVE_SZ = 80 -SQLITE_HDR = b'SQLite format 3\x00' -WAL_HEADER_SZ = 32; WAL_FRAME_HEADER_SZ = 24 - -from config import load_config -_cfg = load_config() -DB_DIR = _cfg["db_dir"] -KEYS_FILE = _cfg["keys_file"] -DECRYPTED = os.path.join(_cfg["decrypted_dir"], "session", "session.db") - -with open(KEYS_FILE, encoding="utf-8") as f: - keys = json.load(f) -enc_key = bytes.fromhex(keys["session/session.db"]["enc_key"]) - -session_db = os.path.join(DB_DIR, "session", "session.db") -wal_path = session_db + "-wal" - - -def decrypt_page(enc_key, page_data, pgno): - iv = page_data[PAGE_SZ - RESERVE_SZ: PAGE_SZ - RESERVE_SZ + 16] - if pgno == 1: - encrypted = page_data[SALT_SZ: PAGE_SZ - RESERVE_SZ] - cipher = AES.new(enc_key, AES.MODE_CBC, iv) - decrypted = cipher.decrypt(encrypted) - return bytearray(SQLITE_HDR + decrypted + b'\x00' * RESERVE_SZ) - else: - encrypted = page_data[:PAGE_SZ - RESERVE_SZ] - cipher = AES.new(enc_key, AES.MODE_CBC, iv) - decrypted = cipher.decrypt(encrypted) - return decrypted + b'\x00' * RESERVE_SZ - - -def full_decrypt(src, dst): - t0 = time.perf_counter() - total = os.path.getsize(src) // PAGE_SZ - with open(src, 'rb') as fin, open(dst, 'wb') as fout: - for pgno in range(1, total + 1): - page = fin.read(PAGE_SZ) - if len(page) < PAGE_SZ: break - fout.write(decrypt_page(enc_key, page, pgno)) - return total, (time.perf_counter() - t0) * 1000 - - -def decrypt_wal_full(wal_path, dst): - """解密WAL当前有效frame,patch到dst (校验salt跳过旧周期遗留frame)""" - t0 = time.perf_counter() - wal_sz = os.path.getsize(wal_path) - frame_size = WAL_FRAME_HEADER_SZ + PAGE_SZ - patched = 0 - - with open(wal_path, 'rb') as wf, open(dst, 'r+b') as df: - wal_hdr = wf.read(WAL_HEADER_SZ) - wal_salt1 = struct.unpack('>I', wal_hdr[16:20])[0] - wal_salt2 = struct.unpack('>I', wal_hdr[20:24])[0] - - while wf.tell() + frame_size <= wal_sz: - fh = wf.read(WAL_FRAME_HEADER_SZ) - if len(fh) < WAL_FRAME_HEADER_SZ: break - pgno = struct.unpack('>I', fh[0:4])[0] - frame_salt1 = struct.unpack('>I', fh[8:12])[0] - frame_salt2 = struct.unpack('>I', fh[12:16])[0] - ep = wf.read(PAGE_SZ) - if len(ep) < PAGE_SZ: break - if pgno == 0 or pgno > 1000000: continue - if frame_salt1 != wal_salt1 or frame_salt2 != wal_salt2: continue - dec = decrypt_page(enc_key, ep, pgno) - df.seek((pgno - 1) * PAGE_SZ) - df.write(dec) - patched += 1 - - return patched, (time.perf_counter() - t0) * 1000 - - -# 初始化: 全量解密 -print("初始全量解密...", flush=True) -pages, ms = full_decrypt(session_db, DECRYPTED) -print(f" DB: {pages}页 {ms:.0f}ms", flush=True) -if os.path.exists(wal_path): - patched, ms2 = decrypt_wal_full(wal_path, DECRYPTED) - print(f" WAL: {patched}页 {ms2:.0f}ms", flush=True) - -# 获取初始状态 -conn = sqlite3.connect(DECRYPTED) -prev_sessions = {} -for r in conn.execute("SELECT username, last_timestamp FROM SessionTable WHERE last_timestamp>0"): - prev_sessions[r[0]] = r[1] -conn.close() - -# 记录初始mtime -prev_wal_mtime = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0 -prev_db_mtime = os.path.getmtime(session_db) -wal_sz = os.path.getsize(wal_path) if os.path.exists(wal_path) else 0 - -print(f"\nWAL大小: {wal_sz} bytes (固定预分配)", flush=True) -print(f"跟踪 {len(prev_sessions)} 个会话", flush=True) -print(f"\n等待微信新消息... (60秒超时, 30ms轮询)\n", flush=True) - -start = time.time() - -while time.time() - start < 60: - time.sleep(0.03) - - # 用mtime检测变化 - try: - wal_mtime = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0 - db_mtime = os.path.getmtime(session_db) - except: - continue - - if wal_mtime == prev_wal_mtime and db_mtime == prev_db_mtime: - continue - - t_detect = time.perf_counter() - detect_str = datetime.now().strftime('%H:%M:%S.%f')[:-3] - - wal_changed = wal_mtime != prev_wal_mtime - db_changed = db_mtime != prev_db_mtime - print(f"[{detect_str}] 变化检测: WAL={'变' if wal_changed else '不变'} DB={'变' if db_changed else '不变'}", flush=True) - - # 如果DB变了(checkpoint), 全量重解密 - if db_changed and not wal_changed: - pages, ms = full_decrypt(session_db, DECRYPTED) - print(f" 全量解密: {pages}页 {ms:.0f}ms", flush=True) - else: - # WAL变了, 重新patch所有WAL frame (因为不知道哪些是新的) - # 先全量解密DB基础 - pages, ms = full_decrypt(session_db, DECRYPTED) - patched, ms2 = decrypt_wal_full(wal_path, DECRYPTED) - print(f" DB {pages}页/{ms:.0f}ms + WAL {patched}页/{ms2:.0f}ms", flush=True) - - t_decrypt = time.perf_counter() - - # 查询变化 - conn = sqlite3.connect(DECRYPTED) - new_msgs = [] - for r in conn.execute(""" - SELECT username, last_timestamp, summary, last_sender_display_name - FROM SessionTable WHERE last_timestamp > 0 - """): - uname, ts, summary, sender = r - if ts > prev_sessions.get(uname, 0): - delay = time.time() - ts - new_msgs.append((uname, ts, summary or '', sender or '', delay)) - prev_sessions[uname] = ts - conn.close() - - t_query = time.perf_counter() - - decrypt_ms = (t_decrypt - t_detect) * 1000 - query_ms = (t_query - t_decrypt) * 1000 - total_ms = (t_query - t_detect) * 1000 - - print(f" 处理总耗时: {total_ms:.1f}ms (解密{decrypt_ms:.1f}ms + 查询{query_ms:.1f}ms)", flush=True) - - for uname, ts, summary, sender, delay in sorted(new_msgs, key=lambda x: x[1]): - if ':\n' in summary: - summary = summary.split(':\n', 1)[1] - msg_time = datetime.fromtimestamp(ts).strftime('%H:%M:%S') - print(f" >>> 消息时间={msg_time} | 微信→DB延迟={delay:.1f}s | {sender}: {summary}", flush=True) - - if not new_msgs: - print(f" (无新消息变化)", flush=True) - - prev_wal_mtime = wal_mtime - prev_db_mtime = db_mtime - print(flush=True) - -print("超时退出", flush=True) diff --git a/main.py b/main.py deleted file mode 100644 index 885f975..0000000 --- a/main.py +++ /dev/null @@ -1,116 +0,0 @@ -""" -WeChat Decrypt 一键启动 - -python main.py # 提取密钥 + 启动 Web UI -python main.py decrypt # 提取密钥 + 解密全部数据库 -""" -import json -import os -import sys - -import functools -print = functools.partial(print, flush=True) - -from key_utils import strip_key_metadata - - -def check_wechat_running(): - """检查微信是否在运行,返回 True/False""" - from find_all_keys import get_pids - try: - get_pids() - return True - except RuntimeError: - return False - - -def ensure_keys(keys_file, db_dir): - """确保密钥文件存在且匹配当前 db_dir,否则重新提取""" - if os.path.exists(keys_file): - try: - with open(keys_file, encoding="utf-8") as f: - keys = json.load(f) - except (json.JSONDecodeError, ValueError): - keys = {} - # 检查密钥是否匹配当前 db_dir(防止切换账号后误复用旧密钥) - saved_dir = keys.pop("_db_dir", None) - if saved_dir and os.path.normcase(os.path.normpath(saved_dir)) != os.path.normcase(os.path.normpath(db_dir)): - print(f"[!] 密钥文件对应的目录已变更,需要重新提取") - print(f" 旧: {saved_dir}") - print(f" 新: {db_dir}") - keys = {} - keys = strip_key_metadata(keys) - if keys: - print(f"[+] 已有 {len(keys)} 个数据库密钥") - return - - print("[*] 密钥文件不存在,正在从微信进程提取...") - print() - from find_all_keys import main as extract_keys - try: - extract_keys() - except RuntimeError as e: - print(f"\n[!] 密钥提取失败: {e}") - sys.exit(1) - print() - - # 提取后再次检查 - if not os.path.exists(keys_file): - print("[!] 密钥提取失败") - sys.exit(1) - try: - with open(keys_file, encoding="utf-8") as f: - keys = json.load(f) - except (json.JSONDecodeError, ValueError): - keys = {} - if not strip_key_metadata(keys): - print("[!] 未能提取到任何密钥") - print(" 可能原因:选择了错误的微信数据目录,或微信需要重启") - print(" 请检查 config.json 中的 db_dir 是否与当前登录的微信账号匹配") - sys.exit(1) - - -def main(): - print("=" * 60) - print(" WeChat Decrypt") - print("=" * 60) - print() - - # 1. 加载配置(自动检测 db_dir) - from config import load_config - cfg = load_config() - - # 2. 检查微信进程 - if not check_wechat_running(): - print(f"[!] 未检测到微信进程 ({cfg.get('wechat_process', 'WeChat')})") - print(" 请先启动微信并登录,然后重新运行") - sys.exit(1) - print("[+] 微信进程运行中") - - # 3. 提取密钥 - ensure_keys(cfg["keys_file"], cfg["db_dir"]) - - # 4. 根据子命令执行 - cmd = sys.argv[1] if len(sys.argv) > 1 else "web" - - if cmd == "decrypt": - print("[*] 开始解密全部数据库...") - print() - from decrypt_db import main as decrypt_all - decrypt_all() - elif cmd == "web": - print("[*] 启动 Web UI...") - print() - from monitor_web import main as start_web - start_web() - else: - print(f"[!] 未知命令: {cmd}") - print() - print("用法:") - print(" python main.py 启动实时消息监听 (Web UI)") - print(" python main.py decrypt 解密全部数据库到 decrypted/") - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/mcp_server.py b/mcp_server.py deleted file mode 100644 index 5c5101d..0000000 --- a/mcp_server.py +++ /dev/null @@ -1,1738 +0,0 @@ -r""" -WeChat MCP Server - query WeChat messages, contacts via Claude - -Based on FastMCP (stdio transport), reuses existing decryption. -Runs on Windows Python (needs access to D:\ WeChat databases). -""" - -import os, sys, json, time, sqlite3, tempfile, struct, hashlib, atexit, re -import hmac as hmac_mod -from contextlib import closing -from datetime import datetime -import xml.etree.ElementTree as ET -from Crypto.Cipher import AES -from mcp.server.fastmcp import FastMCP -import zstandard as zstd -from decode_image import ImageResolver -from key_utils import get_key_info, key_path_variants, strip_key_metadata - -# ============ 加密常量 ============ -PAGE_SZ = 4096 -KEY_SZ = 32 -SALT_SZ = 16 -RESERVE_SZ = 80 -SQLITE_HDR = b'SQLite format 3\x00' -WAL_HEADER_SZ = 32 -WAL_FRAME_HEADER_SZ = 24 - -# ============ 配置加载 ============ -SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -CONFIG_FILE = os.path.join(SCRIPT_DIR, "config.json") - -with open(CONFIG_FILE, encoding="utf-8") as f: - _cfg = json.load(f) -for _key in ("keys_file", "decrypted_dir"): - if _key in _cfg and not os.path.isabs(_cfg[_key]): - _cfg[_key] = os.path.join(SCRIPT_DIR, _cfg[_key]) - -DB_DIR = _cfg["db_dir"] -KEYS_FILE = _cfg["keys_file"] -DECRYPTED_DIR = _cfg["decrypted_dir"] - -# 图片相关路径 -_db_dir = _cfg["db_dir"] -if os.path.basename(_db_dir) == "db_storage": - WECHAT_BASE_DIR = os.path.dirname(_db_dir) -else: - WECHAT_BASE_DIR = _db_dir - -DECODED_IMAGE_DIR = _cfg.get("decoded_image_dir") -if not DECODED_IMAGE_DIR: - DECODED_IMAGE_DIR = os.path.join(SCRIPT_DIR, "decoded_images") -elif not os.path.isabs(DECODED_IMAGE_DIR): - DECODED_IMAGE_DIR = os.path.join(SCRIPT_DIR, DECODED_IMAGE_DIR) - -with open(KEYS_FILE, encoding="utf-8") as f: - ALL_KEYS = strip_key_metadata(json.load(f)) - -# ============ 解密函数 ============ - -def decrypt_page(enc_key, page_data, pgno): - iv = page_data[PAGE_SZ - RESERVE_SZ : PAGE_SZ - RESERVE_SZ + 16] - if pgno == 1: - encrypted = page_data[SALT_SZ : PAGE_SZ - RESERVE_SZ] - cipher = AES.new(enc_key, AES.MODE_CBC, iv) - decrypted = cipher.decrypt(encrypted) - return bytes(bytearray(SQLITE_HDR + decrypted + b'\x00' * RESERVE_SZ)) - else: - encrypted = page_data[: PAGE_SZ - RESERVE_SZ] - cipher = AES.new(enc_key, AES.MODE_CBC, iv) - decrypted = cipher.decrypt(encrypted) - return decrypted + b'\x00' * RESERVE_SZ - - -def full_decrypt(db_path, out_path, enc_key): - file_size = os.path.getsize(db_path) - total_pages = file_size // PAGE_SZ - 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, total_pages + 1): - page = fin.read(PAGE_SZ) - if len(page) < PAGE_SZ: - if len(page) > 0: - page = page + b'\x00' * (PAGE_SZ - len(page)) - else: - break - fout.write(decrypt_page(enc_key, page, pgno)) - return total_pages - - -def decrypt_wal(wal_path, out_path, enc_key): - if not os.path.exists(wal_path): - return 0 - wal_size = os.path.getsize(wal_path) - if wal_size <= WAL_HEADER_SZ: - return 0 - frame_size = WAL_FRAME_HEADER_SZ + PAGE_SZ - patched = 0 - with open(wal_path, 'rb') as wf, open(out_path, 'r+b') as df: - wal_hdr = wf.read(WAL_HEADER_SZ) - wal_salt1 = struct.unpack('>I', wal_hdr[16:20])[0] - wal_salt2 = struct.unpack('>I', wal_hdr[20:24])[0] - while wf.tell() + frame_size <= wal_size: - fh = wf.read(WAL_FRAME_HEADER_SZ) - if len(fh) < WAL_FRAME_HEADER_SZ: - break - pgno = struct.unpack('>I', fh[0:4])[0] - frame_salt1 = struct.unpack('>I', fh[8:12])[0] - frame_salt2 = struct.unpack('>I', fh[12:16])[0] - ep = wf.read(PAGE_SZ) - if len(ep) < PAGE_SZ: - break - if pgno == 0 or pgno > 1000000: - continue - if frame_salt1 != wal_salt1 or frame_salt2 != wal_salt2: - continue - dec = decrypt_page(enc_key, ep, pgno) - df.seek((pgno - 1) * PAGE_SZ) - df.write(dec) - patched += 1 - return patched - - -# ============ DB 缓存 ============ - -class DBCache: - """缓存解密后的 DB,通过 mtime 检测变化。使用固定文件名,重启后可复用。""" - - CACHE_DIR = os.path.join(tempfile.gettempdir(), "wechat_mcp_cache") - MTIME_FILE = os.path.join(tempfile.gettempdir(), "wechat_mcp_cache", "_mtimes.json") - - def __init__(self): - self._cache = {} # rel_key -> (db_mtime, wal_mtime, tmp_path) - os.makedirs(self.CACHE_DIR, exist_ok=True) - self._load_persistent_cache() - - def _cache_path(self, rel_key): - """rel_key -> 固定的缓存文件路径""" - h = hashlib.md5(rel_key.encode()).hexdigest()[:12] - return os.path.join(self.CACHE_DIR, f"{h}.db") - - def _load_persistent_cache(self): - """启动时从磁盘恢复缓存映射,验证 mtime 后复用""" - if not os.path.exists(self.MTIME_FILE): - return - try: - with open(self.MTIME_FILE, encoding="utf-8") as f: - saved = json.load(f) - except (json.JSONDecodeError, OSError): - return - reused = 0 - for rel_key, info in saved.items(): - tmp_path = info["path"] - if not os.path.exists(tmp_path): - continue - rel_path = rel_key.replace('\\', os.sep) - db_path = os.path.join(DB_DIR, rel_path) - wal_path = db_path + "-wal" - try: - db_mtime = os.path.getmtime(db_path) - wal_mtime = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0 - except OSError: - continue - if db_mtime == info["db_mt"] and wal_mtime == info["wal_mt"]: - self._cache[rel_key] = (db_mtime, wal_mtime, tmp_path) - reused += 1 - if reused: - print(f"[DBCache] reused {reused} cached decrypted DBs from previous run", flush=True) - - def _save_persistent_cache(self): - """持久化缓存映射到磁盘""" - data = {} - for rel_key, (db_mt, wal_mt, path) in self._cache.items(): - data[rel_key] = {"db_mt": db_mt, "wal_mt": wal_mt, "path": path} - try: - with open(self.MTIME_FILE, 'w', encoding="utf-8") as f: - json.dump(data, f) - except OSError: - pass - - def get(self, rel_key): - key_info = get_key_info(ALL_KEYS, rel_key) - if not key_info: - return None - rel_path = rel_key.replace('\\', '/').replace('/', os.sep) - db_path = os.path.join(DB_DIR, rel_path) - wal_path = db_path + "-wal" - if not os.path.exists(db_path): - return None - - try: - db_mtime = os.path.getmtime(db_path) - wal_mtime = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0 - except OSError: - return None - - if rel_key in self._cache: - c_db_mt, c_wal_mt, c_path = self._cache[rel_key] - if c_db_mt == db_mtime and c_wal_mt == wal_mtime and os.path.exists(c_path): - return c_path - - tmp_path = self._cache_path(rel_key) - enc_key = bytes.fromhex(key_info["enc_key"]) - full_decrypt(db_path, tmp_path, enc_key) - if os.path.exists(wal_path): - decrypt_wal(wal_path, tmp_path, enc_key) - self._cache[rel_key] = (db_mtime, wal_mtime, tmp_path) - self._save_persistent_cache() - return tmp_path - - def cleanup(self): - """正常退出时保存缓存映射(不删文件,下次启动可复用)""" - self._save_persistent_cache() - - -_cache = DBCache() -atexit.register(_cache.cleanup) - - -# ============ 联系人缓存 ============ - -_contact_names = None # {username: display_name} -_contact_full = None # [{username, nick_name, remark}] -_contact_tags = None # {label_id: {name, sort_order, members: [{username, display_name}]}} -_self_username = None -_XML_UNSAFE_RE = re.compile(r'> 3 - wire_type = tag & 0x07 - if wire_type == 0: # varint - while pos < n and data[pos] & 0x80: - pos += 1 - pos += 1 - elif wire_type == 2: # length-delimited - length = 0; shift = 0 - while pos < n: - b = data[pos]; pos += 1 - length |= (b & 0x7f) << shift - if not (b & 0x80): - break - shift += 7 - if field_num == 30: - try: - return data[pos:pos + length].decode('utf-8') - except Exception: - return None - pos += length - elif wire_type == 1: # 64-bit - pos += 8 - elif wire_type == 5: # 32-bit - pos += 4 - else: - break - return None - - -def _load_contact_tags(): - """加载并缓存联系人标签数据""" - global _contact_tags - if _contact_tags is not None: - return _contact_tags - - db_path = _get_contact_db_path() - if not db_path: - return {} - - try: - conn = sqlite3.connect(db_path) - except Exception: - return {} - - try: - # 1. 加载标签定义 - try: - label_rows = conn.execute( - "SELECT label_id_, label_name_, sort_order_ FROM contact_label ORDER BY sort_order_" - ).fetchall() - except sqlite3.OperationalError: - return {} - if not label_rows: - return {} - - labels = {} - for lid, lname, sort_order in label_rows: - labels[lid] = {'name': lname, 'sort_order': sort_order, 'members': []} - - # 2. 扫描联系人的标签关联 - names = get_contact_names() - rows = conn.execute( - "SELECT username, extra_buffer FROM contact WHERE extra_buffer IS NOT NULL" - ).fetchall() - - for username, buf in rows: - label_str = _extract_pb_field_30(buf) - if not label_str: - continue - display = names.get(username, username) - for lid_s in label_str.split(','): - try: - lid = int(lid_s.strip()) - except (ValueError, AttributeError): - continue - if lid in labels: - labels[lid]['members'].append({'username': username, 'display_name': display}) - - _contact_tags = labels - return _contact_tags - except Exception: - return {} - finally: - conn.close() - - -# ============ 辅助函数 ============ - -def format_msg_type(t): - base_type, _ = _split_msg_type(t) - return { - 1: '文本', 3: '图片', 34: '语音', 42: '名片', - 43: '视频', 47: '表情', 48: '位置', 49: '链接/文件', - 50: '通话', 10000: '系统', 10002: '撤回', - }.get(base_type, f'type={t}') - - -def _split_msg_type(t): - try: - t = int(t) - except (TypeError, ValueError): - return 0, 0 - # WeChat packs the base type into the low 32 bits and app subtype into the high 32 bits. - if t > 0xFFFFFFFF: - return t & 0xFFFFFFFF, t >> 32 - return t, 0 - - -def resolve_username(chat_name): - """将聊天名/备注名/wxid 解析为 username""" - names = get_contact_names() - - # 直接是 username - if chat_name in names or chat_name.startswith('wxid_') or '@chatroom' in chat_name: - return chat_name - - # 模糊匹配(优先精确包含) - chat_lower = chat_name.lower() - for uname, display in names.items(): - if chat_lower == display.lower(): - return uname - for uname, display in names.items(): - if chat_lower in display.lower(): - return uname - - return None - - -_zstd_dctx = zstd.ZstdDecompressor() - - -def _decompress_content(content, ct): - """解压 zstd 压缩的消息内容""" - if ct and ct == 4 and isinstance(content, bytes): - try: - return _zstd_dctx.decompress(content).decode('utf-8', errors='replace') - except Exception: - return None - if isinstance(content, bytes): - try: - return content.decode('utf-8', errors='replace') - except Exception: - return None - return content - - -def _parse_message_content(content, local_type, is_group): - """解析消息内容,返回 (sender_id, text)""" - if content is None: - return '', '' - if isinstance(content, bytes): - return '', '(二进制内容)' - - sender = '' - text = content - if is_group and ':\n' in content: - sender, text = content.split(':\n', 1) - - return sender, text - - -def _collapse_text(text): - if not text: - return '' - return re.sub(r'\s+', ' ', text).strip() - - -def _get_self_username(): - global _self_username - if _self_username: - return _self_username - - if not DB_DIR: - return '' - - names = get_contact_names() - account_dir = os.path.basename(os.path.dirname(DB_DIR)) - candidates = [account_dir] - - m = re.fullmatch(r'(.+)_([0-9a-fA-F]{4,})', account_dir) - if m: - candidates.insert(0, m.group(1)) - - for candidate in candidates: - if candidate and candidate in names: - _self_username = candidate - return _self_username - - return '' - - -def _load_name2id_maps(conn): - id_to_username = {} - try: - rows = conn.execute("SELECT rowid, user_name FROM Name2Id").fetchall() - except sqlite3.Error: - return id_to_username - - for rowid, user_name in rows: - if not user_name: - continue - id_to_username[rowid] = user_name - return id_to_username - - -def _display_name_for_username(username, names): - if not username: - return '' - if username == _get_self_username(): - return 'me' - return names.get(username, username) - - -def _resolve_sender_label(real_sender_id, sender_from_content, is_group, chat_username, chat_display_name, names, id_to_username): - sender_username = id_to_username.get(real_sender_id, '') - - if is_group: - if sender_username and sender_username != chat_username: - return _display_name_for_username(sender_username, names) - if sender_from_content: - return _display_name_for_username(sender_from_content, names) - return '' - - if sender_username == chat_username: - return chat_display_name - if sender_username: - return _display_name_for_username(sender_username, names) - return '' - - -def _resolve_quote_sender_label(ref_user, ref_display_name, is_group, chat_username, chat_display_name, names): - if is_group: - if ref_user: - return _display_name_for_username(ref_user, names) - return ref_display_name or '' - - self_username = _get_self_username() - if ref_user: - if ref_user == chat_username: - return chat_display_name - if self_username and ref_user == self_username: - return 'me' - return names.get(ref_user, ref_display_name or ref_user) - if ref_display_name: - if ref_display_name == chat_display_name: - return chat_display_name - self_display_name = names.get(self_username, self_username) if self_username else '' - if self_display_name and ref_display_name == self_display_name: - return 'me' - return ref_display_name - return '' - - -def _parse_xml_root(content): - if not content or len(content) > _XML_PARSE_MAX_LEN or _XML_UNSAFE_RE.search(content): - return None - - try: - return ET.fromstring(content) - except ET.ParseError: - return None - - -def _parse_int(value, fallback=0): - try: - return int(value) - except (TypeError, ValueError): - return fallback - - -def _format_app_message_text(content, local_type, is_group, chat_username, chat_display_name, names): - if not content or ' 160: - ref_content = ref_content[:160] + "..." - - quote_text = title or "[引用消息]" - if ref_content: - ref_label = _resolve_quote_sender_label( - ref_user, ref_display_name, is_group, chat_username, chat_display_name, names - ) - prefix = f"回复 {ref_label}: " if ref_label else "回复: " - quote_text += f"\n ↳ {prefix}{ref_content}" - return quote_text - - if app_type == 6: - return f"[文件] {title}" if title else "[文件]" - if app_type == 5: - return f"[链接] {title}" if title else "[链接]" - if app_type in (33, 36, 44): - return f"[小程序] {title}" if title else "[小程序]" - if title: - return f"[链接/文件] {title}" - return "[链接/文件]" - - -def _format_voip_message_text(content): - if not content or ' limit_max: - raise ValueError(f"limit 不能大于 {limit_max}") - if offset < 0: - raise ValueError("offset 不能小于 0") - - -def _parse_time_value(value, field_name, is_end=False): - value = (value or '').strip() - if not value: - return None - - formats = [ - ('%Y-%m-%d %H:%M:%S', False), - ('%Y-%m-%d %H:%M', False), - ('%Y-%m-%d', True), - ] - for fmt, date_only in formats: - try: - dt = datetime.strptime(value, fmt) - if date_only and is_end: - dt = dt.replace(hour=23, minute=59, second=59) - return int(dt.timestamp()) - except ValueError: - continue - - raise ValueError( - f"{field_name} 格式无效: {value}。支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS" - ) - - -def _parse_time_range(start_time='', end_time=''): - start_ts = _parse_time_value(start_time, 'start_time', is_end=False) - end_ts = _parse_time_value(end_time, 'end_time', is_end=True) - if start_ts is not None and end_ts is not None and start_ts > end_ts: - raise ValueError('start_time 不能晚于 end_time') - return start_ts, end_ts - - -def _build_message_filters(start_ts=None, end_ts=None, keyword=''): - clauses = [] - params = [] - if start_ts is not None: - clauses.append('create_time >= ?') - params.append(start_ts) - if end_ts is not None: - clauses.append('create_time <= ?') - params.append(end_ts) - if keyword: - clauses.append('message_content LIKE ?') - params.append(f'%{keyword}%') - return clauses, params - - -def _query_messages(conn, table_name, start_ts=None, end_ts=None, keyword='', limit=20, offset=0): - if not _is_safe_msg_table_name(table_name): - raise ValueError(f'非法消息表名: {table_name}') - - clauses, params = _build_message_filters(start_ts, end_ts, keyword) - where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else '' - sql = f""" - SELECT local_id, local_type, create_time, real_sender_id, message_content, - WCDB_CT_message_content - FROM [{table_name}] - {where_sql} - ORDER BY create_time DESC - """ - if limit is None: - return conn.execute(sql, params).fetchall() - sql += "\n LIMIT ? OFFSET ?" - return conn.execute(sql, (*params, limit, offset)).fetchall() - - -def _resolve_chat_context(chat_name): - username = resolve_username(chat_name) - if not username: - return None - - names = get_contact_names() - display_name = names.get(username, username) - message_tables = _find_msg_tables_for_user(username) - if not message_tables: - return { - 'query': chat_name, - 'username': username, - 'display_name': display_name, - 'db_path': None, - 'table_name': None, - 'message_tables': [], - 'is_group': '@chatroom' in username, - } - - primary = message_tables[0] - return { - 'query': chat_name, - 'username': username, - 'display_name': display_name, - 'db_path': primary['db_path'], - 'table_name': primary['table_name'], - 'message_tables': message_tables, - 'is_group': '@chatroom' in username, - } - - -def _resolve_chat_contexts(chat_names): - if not chat_names: - raise ValueError('chat_names 不能为空') - - resolved = [] - unresolved = [] - missing_tables = [] - seen = set() - - for chat_name in chat_names: - name = (chat_name or '').strip() - if not name: - unresolved.append('(空)') - continue - ctx = _resolve_chat_context(name) - if not ctx: - unresolved.append(name) - continue - if not ctx['message_tables']: - missing_tables.append(ctx['display_name']) - continue - if ctx['username'] in seen: - continue - seen.add(ctx['username']) - resolved.append(ctx) - - return resolved, unresolved, missing_tables - - -def _normalize_chat_names(chat_name): - if chat_name is None: - return [] - if isinstance(chat_name, str): - value = chat_name.strip() - return [value] if value else [] - if isinstance(chat_name, (list, tuple, set)): - normalized = [] - for item in chat_name: - if item is None: - continue - value = str(item).strip() - if value: - normalized.append(value) - return normalized - value = str(chat_name).strip() - return [value] if value else [] - - -def _format_history_lines(rows, username, display_name, is_group, names, id_to_username): - lines = [] - ctx = { - 'username': username, - 'display_name': display_name, - 'is_group': is_group, - } - for row in reversed(rows): - _, line = _build_history_line(row, ctx, names, id_to_username) - lines.append(line) - return lines - - -def _build_search_entry(row, ctx, names, id_to_username): - local_id, local_type, create_time, real_sender_id, content, ct = row - content = _decompress_content(content, ct) - if content is None: - return None - - sender, text = _format_message_text( - local_id, local_type, content, ctx['is_group'], ctx['username'], ctx['display_name'], names - ) - if text and len(text) > 300: - text = text[:300] + '...' - - sender_label = _resolve_sender_label( - real_sender_id, - sender, - ctx['is_group'], - ctx['username'], - ctx['display_name'], - names, - id_to_username, - ) - time_str = datetime.fromtimestamp(create_time).strftime('%Y-%m-%d %H:%M') - entry = f"[{time_str}] [{ctx['display_name']}]" - if sender_label: - entry += f" {sender_label}:" - entry += f" {text}" - return create_time, entry - - -def _build_history_line(row, ctx, names, id_to_username): - local_id, local_type, create_time, real_sender_id, content, ct = row - time_str = datetime.fromtimestamp(create_time).strftime('%Y-%m-%d %H:%M') - content = _decompress_content(content, ct) - if content is None: - content = '(无法解压)' - - sender, text = _format_message_text( - local_id, local_type, content, ctx['is_group'], ctx['username'], ctx['display_name'], names - ) - - sender_label = _resolve_sender_label( - real_sender_id, sender, ctx['is_group'], ctx['username'], ctx['display_name'], names, id_to_username - ) - if sender_label: - return create_time, f'[{time_str}] {sender_label}: {text}' - return create_time, f'[{time_str}] {text}' - - -def _get_chat_message_tables(ctx): - if ctx.get('message_tables'): - return ctx['message_tables'] - if ctx.get('db_path') and ctx.get('table_name'): - return [{'db_path': ctx['db_path'], 'table_name': ctx['table_name']}] - return [] - - -def _iter_table_contexts(ctx): - for table in _get_chat_message_tables(ctx): - yield { - 'query': ctx['query'], - 'username': ctx['username'], - 'display_name': ctx['display_name'], - 'db_path': table['db_path'], - 'table_name': table['table_name'], - 'is_group': ctx['is_group'], - } - - -def _candidate_page_size(limit, offset): - return limit + offset - - -def _message_query_batch_size(candidate_limit): - return candidate_limit - - -def _history_query_batch_size(candidate_limit): - return min(candidate_limit, _HISTORY_QUERY_BATCH_SIZE) - - -def _page_ranked_entries(entries, limit, offset): - ordered = sorted(entries, key=lambda item: item[0], reverse=True) - paged = ordered[offset:offset + limit] - paged.sort(key=lambda item: item[0]) - return paged - - -def _collect_chat_history_lines(ctx, names, start_ts=None, end_ts=None, limit=20, offset=0): - collected = [] - failures = [] - candidate_limit = _candidate_page_size(limit, offset) - batch_size = _history_query_batch_size(candidate_limit) - - for table_ctx in _iter_table_contexts(ctx): - try: - with closing(sqlite3.connect(table_ctx['db_path'])) as conn: - id_to_username = _load_name2id_maps(conn) - fetch_offset = 0 - collected_before_table = len(collected) - # 当前页上的消息一定落在各分表最近的 offset+limit 条记录内。 - while len(collected) - collected_before_table < candidate_limit: - rows = _query_messages( - conn, - table_ctx['table_name'], - start_ts=start_ts, - end_ts=end_ts, - limit=batch_size, - offset=fetch_offset, - ) - if not rows: - break - fetch_offset += len(rows) - - for row in rows: - try: - collected.append(_build_history_line(row, table_ctx, names, id_to_username)) - except Exception as e: - failures.append( - f"{table_ctx['display_name']} local_id={row[0]} create_time={row[2]}: {e}" - ) - if len(collected) - collected_before_table >= candidate_limit: - break - - if len(rows) < batch_size: - break - except Exception as e: - failures.append(f"{table_ctx['db_path']}: {e}") - - paged = _page_ranked_entries(collected, limit, offset) - return [line for _, line in paged], failures - - -def _collect_chat_search_entries(ctx, names, keyword, start_ts=None, end_ts=None, candidate_limit=20): - collected = [] - failures = [] - contexts_by_db = {} - for table_ctx in _iter_table_contexts(ctx): - contexts_by_db.setdefault(table_ctx['db_path'], []).append(table_ctx) - - for db_path, db_contexts in contexts_by_db.items(): - try: - with closing(sqlite3.connect(db_path)) as conn: - db_entries, db_failures = _collect_search_entries( - conn, - db_contexts, - names, - keyword, - start_ts=start_ts, - end_ts=end_ts, - candidate_limit=candidate_limit, - ) - collected.extend(db_entries) - failures.extend(db_failures) - except Exception as e: - failures.extend(f"{table_ctx['display_name']}: {e}" for table_ctx in db_contexts) - - return collected, failures - - -def _load_search_contexts_from_db(conn, db_path, names): - tables = conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'" - ).fetchall() - - table_to_username = {} - try: - for (user_name,) in conn.execute("SELECT user_name FROM Name2Id").fetchall(): - if not user_name: - continue - table_hash = hashlib.md5(user_name.encode()).hexdigest() - table_to_username[f"Msg_{table_hash}"] = user_name - except sqlite3.Error: - pass - - contexts = [] - for (table_name,) in tables: - username = table_to_username.get(table_name, '') - display_name = names.get(username, username) if username else table_name - contexts.append({ - 'query': display_name, - 'username': username, - 'display_name': display_name, - 'db_path': db_path, - 'table_name': table_name, - 'is_group': '@chatroom' in username, - }) - return contexts - - -def _collect_search_entries(conn, contexts, names, keyword, start_ts=None, end_ts=None, candidate_limit=20): - collected = [] - failures = [] - id_to_username = _load_name2id_maps(conn) - batch_size = _message_query_batch_size(candidate_limit) - - for ctx in contexts: - try: - fetch_offset = 0 - collected_before_table = len(collected) - # 全局分页只需要每个分表最新的 offset+limit 条有效命中,无需把整表命中读进内存。 - while len(collected) - collected_before_table < candidate_limit: - rows = _query_messages( - conn, - ctx['table_name'], - start_ts=start_ts, - end_ts=end_ts, - keyword=keyword, - limit=batch_size, - offset=fetch_offset, - ) - if not rows: - break - fetch_offset += len(rows) - - for row in rows: - formatted = _build_search_entry(row, ctx, names, id_to_username) - if formatted: - collected.append(formatted) - if len(collected) - collected_before_table >= candidate_limit: - break - - if len(rows) < batch_size: - break - except Exception as e: - failures.append(f"{ctx['display_name']}: {e}") - - return collected, failures - - -def _page_search_entries(entries, limit, offset): - return _page_ranked_entries(entries, limit, offset) - - -def _search_single_chat(ctx, keyword, start_ts, end_ts, start_time, end_time, limit, offset): - names = get_contact_names() - candidate_limit = _candidate_page_size(limit, offset) - - entries, failures = _collect_chat_search_entries( - ctx, - names, - keyword, - start_ts=start_ts, - end_ts=end_ts, - candidate_limit=candidate_limit, - ) - - paged = _page_search_entries(entries, limit, offset) - - if not paged: - if failures: - return "查询失败: " + ";".join(failures) - return f"未在 {ctx['display_name']} 中找到包含 \"{keyword}\" 的消息" - - header = f"在 {ctx['display_name']} 中搜索 \"{keyword}\" 找到 {len(paged)} 条结果(offset={offset}, limit={limit})" - if start_time or end_time: - header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}" - if failures: - header += "\n查询失败: " + ";".join(failures) - return header + ":\n\n" + "\n\n".join(item[1] for item in paged) - - -def _search_multiple_chats(chat_names, keyword, start_ts, end_ts, start_time, end_time, limit, offset): - try: - resolved_contexts, unresolved, missing_tables = _resolve_chat_contexts(chat_names) - except ValueError as e: - return f"错误: {e}" - - if not resolved_contexts: - details = [] - if unresolved: - details.append("未找到联系人: " + "、".join(unresolved)) - if missing_tables: - details.append("无消息表: " + "、".join(missing_tables)) - suffix = f"\n{chr(10).join(details)}" if details else "" - return f"错误: 没有可查询的聊天对象{suffix}" - - names = get_contact_names() - candidate_limit = _candidate_page_size(limit, offset) - collected = [] - failures = [] - for ctx in resolved_contexts: - chat_entries, chat_failures = _collect_chat_search_entries( - ctx, - names, - keyword, - start_ts=start_ts, - end_ts=end_ts, - candidate_limit=candidate_limit, - ) - collected.extend(chat_entries) - failures.extend(chat_failures) - - paged = _page_search_entries(collected, limit, offset) - - notes = [] - if unresolved: - notes.append("未找到联系人: " + "、".join(unresolved)) - if missing_tables: - notes.append("无消息表: " + "、".join(missing_tables)) - if failures: - notes.append("查询失败: " + ";".join(failures)) - - if not paged: - header = f"在 {len(resolved_contexts)} 个聊天对象中未找到包含 \"{keyword}\" 的消息" - if start_time or end_time: - header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}" - if notes: - header += "\n" + "\n".join(notes) - return header - - header = ( - f"在 {len(resolved_contexts)} 个聊天对象中搜索 \"{keyword}\" 找到 {len(paged)} 条结果" - f"(offset={offset}, limit={limit})" - ) - if start_time or end_time: - header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}" - if notes: - header += "\n" + "\n".join(notes) - return header + ":\n\n" + "\n\n".join(item[1] for item in paged) - - -def _search_all_messages(keyword, start_ts, end_ts, start_time, end_time, limit, offset): - names = get_contact_names() - collected = [] - failures = [] - candidate_limit = _candidate_page_size(limit, offset) - - for rel_key in MSG_DB_KEYS: - path = _cache.get(rel_key) - if not path: - continue - - try: - with closing(sqlite3.connect(path)) as conn: - contexts = _load_search_contexts_from_db(conn, path, names) - db_entries, db_failures = _collect_search_entries( - conn, - contexts, - names, - keyword, - start_ts=start_ts, - end_ts=end_ts, - candidate_limit=candidate_limit, - ) - collected.extend(db_entries) - failures.extend(db_failures) - except Exception as e: - failures.append(f"{rel_key}: {e}") - - paged = _page_search_entries(collected, limit, offset) - - if not paged: - header = f"未找到包含 \"{keyword}\" 的消息" - if start_time or end_time: - header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}" - if failures: - header += "\n查询失败: " + ";".join(failures) - return header - - header = f"搜索 \"{keyword}\" 找到 {len(paged)} 条结果(offset={offset}, limit={limit})" - if start_time or end_time: - header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}" - if failures: - header += "\n查询失败: " + ";".join(failures) - return header + ":\n\n" + "\n\n".join(item[1] for item in paged) - - -# ============ MCP Server ============ - -mcp = FastMCP("wechat", instructions="查询微信消息、联系人等数据") - -# 新消息追踪 -_last_check_state = {} # {username: last_timestamp} - - -@mcp.tool() -def get_recent_sessions(limit: int = 20) -> str: - """获取微信最近会话列表,包含最新消息摘要、未读数、时间等。 - 用于了解最近有哪些人/群在聊天。 - - Args: - limit: 返回的会话数量,默认20 - """ - path = _cache.get(os.path.join("session", "session.db")) - if not path: - return "错误: 无法解密 session.db" - - names = get_contact_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 r in rows: - username, unread, summary, ts, msg_type, sender, sender_name = r - display = names.get(username, username) - is_group = '@chatroom' in username - - if isinstance(summary, bytes): - try: - summary = _zstd_dctx.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) - - time_str = datetime.fromtimestamp(ts).strftime('%m-%d %H:%M') - - entry = f"[{time_str}] {display}" - if is_group: - entry += " [群]" - if unread and unread > 0: - entry += f" ({unread}条未读)" - entry += f"\n {format_msg_type(msg_type)}: " - if sender_display: - entry += f"{sender_display}: " - entry += str(summary or "(无内容)") - - results.append(entry) - - return f"最近 {len(results)} 个会话:\n\n" + "\n\n".join(results) - - -@mcp.tool() -def get_chat_history(chat_name: str, limit: int = 50, offset: int = 0, start_time: str = "", end_time: str = "") -> str: - """获取指定聊天的消息记录。 - - Args: - chat_name: 聊天对象的名字、备注名或wxid,自动模糊匹配 - limit: 返回的消息数量,默认50;支持较大的值,建议配合 offset 分页使用 - offset: 分页偏移量,默认0 - start_time: 起始时间,支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS - end_time: 结束时间,支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS - """ - try: - _validate_pagination(limit, offset, limit_max=None) - start_ts, end_ts = _parse_time_range(start_time, end_time) - except ValueError as e: - return f"错误: {e}" - - ctx = _resolve_chat_context(chat_name) - if not ctx: - return f"找不到聊天对象: {chat_name}\n提示: 可以用 get_contacts(query='{chat_name}') 搜索联系人" - if not ctx['db_path']: - return f"找不到 {ctx['display_name']} 的消息记录(可能在未解密的DB中或无消息)" - - names = get_contact_names() - lines, failures = _collect_chat_history_lines( - ctx, - names, - start_ts=start_ts, - end_ts=end_ts, - limit=limit, - offset=offset, - ) - - if not lines: - if failures: - return "查询失败: " + ";".join(failures) - return f"{ctx['display_name']} 无消息记录" - - header = f"{ctx['display_name']} 的消息记录(返回 {len(lines)} 条,offset={offset}, limit={limit})" - if ctx['is_group']: - header += " [群聊]" - if start_time or end_time: - header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}" - if failures: - header += "\n查询失败: " + ";".join(failures) - return header + ":\n\n" + "\n".join(lines) - - -@mcp.tool() -def search_messages( - keyword: str, - chat_name: str | list[str] | None = None, - start_time: str = "", - end_time: str = "", - limit: int = 20, - offset: int = 0, -) -> str: - """搜索消息内容,支持全库、单个聊天对象、多个聊天对象,以及时间范围和分页。 - - Args: - keyword: 搜索关键词 - chat_name: 聊天对象名称,可为空、单个字符串或字符串列表 - start_time: 起始时间,可为空 - end_time: 结束时间,可为空 - limit: 返回的结果数量,默认20,最大500 - offset: 分页偏移量,默认0 - """ - if not keyword or len(keyword) < 1: - return "请提供搜索关键词" - - chat_names = _normalize_chat_names(chat_name) - - try: - _validate_pagination(limit, offset) - start_ts, end_ts = _parse_time_range(start_time, end_time) - except ValueError as e: - return f"错误: {e}" - - if len(chat_names) == 1: - ctx = _resolve_chat_context(chat_names[0]) - if not ctx: - return f"找不到聊天对象: {chat_names[0]}\n提示: 可以用 get_contacts(query='{chat_names[0]}') 搜索联系人" - if not ctx['db_path']: - return f"找不到 {ctx['display_name']} 的消息记录(可能在未解密的DB中或无消息)" - return _search_single_chat( - ctx, - keyword, - start_ts, - end_ts, - start_time, - end_time, - limit, - offset, - ) - - if len(chat_names) > 1: - return _search_multiple_chats( - chat_names, - keyword, - start_ts, - end_ts, - start_time, - end_time, - limit, - offset, - ) - - return _search_all_messages( - keyword, - start_ts, - end_ts, - start_time, - end_time, - limit, - offset, - ) - -@mcp.tool() -def get_contacts(query: str = "", limit: int = 50) -> str: - """搜索或列出微信联系人。 - - Args: - query: 搜索关键词(匹配昵称、备注名、wxid),留空列出所有 - limit: 返回数量,默认50 - """ - contacts = get_contact_full() - if not contacts: - return "错误: 无法加载联系人数据" - - if query: - q = query.lower() - filtered = [ - c for c in contacts - if q in c['nick_name'].lower() - or q in c['remark'].lower() - or q in c['username'].lower() - ] - else: - filtered = contacts - - filtered = filtered[:limit] - - if not filtered: - return f"未找到匹配 \"{query}\" 的联系人" - - lines = [] - for c in filtered: - line = c['username'] - if c['remark']: - line += f" 备注: {c['remark']}" - if c['nick_name']: - line += f" 昵称: {c['nick_name']}" - lines.append(line) - - header = f"找到 {len(filtered)} 个联系人" - if query: - header += f"(搜索: {query})" - return header + ":\n\n" + "\n".join(lines) - - -@mcp.tool() -def get_contact_tags() -> str: - """列出所有微信联系人标签及成员数量。""" - tags = _load_contact_tags() - if not tags: - return "未找到标签数据(contact_label 表可能不存在)" - - sorted_tags = sorted(tags.values(), key=lambda t: t['sort_order']) - total_assoc = sum(len(t['members']) for t in sorted_tags) - - lines = [f"共 {len(sorted_tags)} 个标签,{total_assoc} 个关联:\n"] - for t in sorted_tags: - lines.append(f" [{t['name']}] {len(t['members'])}人") - return "\n".join(lines) - - -@mcp.tool() -def get_tag_members(tag_name: str) -> str: - """获取指定标签下的所有联系人。支持模糊匹配标签名。 - - Args: - tag_name: 标签名称,支持精确和模糊匹配 - """ - tags = _load_contact_tags() - if not tags: - return "未找到标签数据(contact_label 表可能不存在)" - - q = tag_name.strip().lower() - - # 精确匹配 - exact = [t for t in tags.values() if t['name'].lower() == q] - if exact: - matched = exact[0] - else: - # 模糊匹配 (contains) - fuzzy = [t for t in tags.values() if q in t['name'].lower()] - if not fuzzy: - all_names = [t['name'] for t in sorted(tags.values(), key=lambda t: t['sort_order'])] - return f"未找到匹配 \"{tag_name}\" 的标签。\n\n现有标签: {', '.join(all_names)}" - if len(fuzzy) == 1: - matched = fuzzy[0] - else: - names = [t['name'] for t in fuzzy] - return f"找到 {len(fuzzy)} 个匹配的标签,请指定:\n" + "\n".join(f" [{n}]" for n in names) - - members = matched['members'] - if not members: - return f"标签 [{matched['name']}] 没有成员" - - lines = [f"标签 [{matched['name']}] 共 {len(members)} 人:\n"] - for m in members: - line = m['username'] - if m['display_name'] != m['username']: - line += f" {m['display_name']}" - lines.append(f" {line}") - return "\n".join(lines) - - -@mcp.tool() -def get_new_messages() -> str: - """获取自上次调用以来的新消息。首次调用返回最近的会话状态。""" - global _last_check_state - - path = _cache.get(os.path.join("session", "session.db")) - if not path: - return "错误: 无法解密 session.db" - - names = get_contact_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 - """).fetchall() - - curr_state = {} - for r in rows: - username, unread, summary, ts, msg_type, sender, sender_name = r - curr_state[username] = { - 'unread': unread, 'summary': summary, 'timestamp': ts, - 'msg_type': msg_type, 'sender': sender or '', 'sender_name': sender_name or '', - } - - if not _last_check_state: - _last_check_state = {u: s['timestamp'] for u, s in curr_state.items()} - # 首次调用,返回有未读的会话 - unread_msgs = [] - for username, s in curr_state.items(): - if s['unread'] and s['unread'] > 0: - display = names.get(username, username) - is_group = '@chatroom' in username - summary = s['summary'] - if isinstance(summary, bytes): - try: - summary = _zstd_dctx.decompress(summary).decode('utf-8', errors='replace') - except Exception: - summary = '(压缩内容)' - if isinstance(summary, str) and ':\n' in summary: - summary = summary.split(':\n', 1)[1] - time_str = datetime.fromtimestamp(s['timestamp']).strftime('%H:%M') - tag = "[群]" if is_group else "" - unread_msgs.append(f"[{time_str}] {display}{tag} ({s['unread']}条未读): {summary}") - - if unread_msgs: - return f"当前 {len(unread_msgs)} 个未读会话:\n\n" + "\n".join(unread_msgs) - return "当前无未读消息(已记录状态,下次调用将返回新消息)" - - # 对比上次状态 - new_msgs = [] - for username, s in curr_state.items(): - prev_ts = _last_check_state.get(username, 0) - if s['timestamp'] > prev_ts: - display = names.get(username, username) - is_group = '@chatroom' in username - summary = s['summary'] - if isinstance(summary, bytes): - try: - summary = _zstd_dctx.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 s['sender']: - sender_display = names.get(s['sender'], s['sender_name'] or s['sender']) - - time_str = datetime.fromtimestamp(s['timestamp']).strftime('%H:%M:%S') - entry = f"[{time_str}] {display}" - if is_group: - entry += " [群]" - entry += f": {format_msg_type(s['msg_type'])}" - if sender_display: - entry += f" ({sender_display})" - entry += f" - {summary}" - new_msgs.append((s['timestamp'], entry)) - - _last_check_state = {u: s['timestamp'] for u, s in curr_state.items()} - - if not new_msgs: - return "无新消息" - - new_msgs.sort(key=lambda x: x[0]) - entries = [m[1] for m in new_msgs] - return f"{len(entries)} 条新消息:\n\n" + "\n".join(entries) - - -# ============ 图片解密 ============ - -_image_resolver = ImageResolver(WECHAT_BASE_DIR, DECODED_IMAGE_DIR, _cache) - - -@mcp.tool() -def decode_image(chat_name: str, local_id: int) -> str: - """解密微信聊天中的一张图片。 - - 先用 get_chat_history 查看消息,图片消息会显示 local_id, - 然后用此工具解密对应图片。 - - Args: - chat_name: 聊天对象的名字、备注名或wxid - local_id: 图片消息的 local_id(从 get_chat_history 获取) - """ - username = resolve_username(chat_name) - if not username: - return f"找不到聊天对象: {chat_name}" - - result = _image_resolver.decode_image(username, local_id) - if result['success']: - return ( - f"解密成功!\n" - f" 文件: {result['path']}\n" - f" 格式: {result['format']}\n" - f" 大小: {result['size']:,} bytes\n" - f" MD5: {result['md5']}" - ) - else: - error = result['error'] - if 'md5' in result: - error += f"\n MD5: {result['md5']}" - return f"解密失败: {error}" - - -@mcp.tool() -def get_chat_images(chat_name: str, limit: int = 20) -> str: - """列出某个聊天中的图片消息。 - - 返回图片的时间、local_id、MD5、文件大小等信息。 - 可以配合 decode_image 工具解密指定图片。 - - Args: - chat_name: 聊天对象的名字、备注名或wxid - limit: 返回数量,默认20 - """ - username = resolve_username(chat_name) - if not username: - return f"找不到聊天对象: {chat_name}" - - names = get_contact_names() - display_name = names.get(username, username) - - db_path, table_name = _find_msg_table_for_user(username) - if not db_path: - return f"找不到 {display_name} 的消息记录" - - images = _image_resolver.list_chat_images(db_path, table_name, username, limit) - if not images: - return f"{display_name} 无图片消息" - - lines = [] - for img in images: - time_str = datetime.fromtimestamp(img['create_time']).strftime('%Y-%m-%d %H:%M') - line = f"[{time_str}] local_id={img['local_id']}" - if img.get('md5'): - line += f" MD5={img['md5']}" - if img.get('size'): - size_kb = img['size'] / 1024 - line += f" {size_kb:.0f}KB" - if not img.get('md5'): - line += " (无资源信息)" - lines.append(line) - - return f"{display_name} 的 {len(lines)} 张图片:\n\n" + "\n".join(lines) - - -if __name__ == "__main__": - mcp.run() diff --git a/monitor.py b/monitor.py deleted file mode 100644 index 7408684..0000000 --- a/monitor.py +++ /dev/null @@ -1,260 +0,0 @@ -""" -微信实时消息监听器 - -原理: 定期解密 session.db (2MB, <1秒), 检测新消息 -session.db 包含每个聊天的最新消息摘要、发送者、时间戳 -""" -import hashlib, struct, os, sys, json, time, sqlite3, io -import hmac as hmac_mod -from datetime import datetime -from Crypto.Cipher import AES -import zstandard as zstd -from key_utils import get_key_info, strip_key_metadata - -_zstd_dctx = zstd.ZstdDecompressor() - -sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') - -import functools -print = functools.partial(print, flush=True) - -PAGE_SZ = 4096 -KEY_SZ = 32 -SALT_SZ = 16 -IV_SZ = 16 -HMAC_SZ = 64 -RESERVE_SZ = 80 -SQLITE_HDR = b'SQLite format 3\x00' - -from config import load_config -_cfg = load_config() -DB_DIR = _cfg["db_dir"] -KEYS_FILE = _cfg["keys_file"] -CONTACT_CACHE = os.path.join(_cfg["decrypted_dir"], "contact", "contact.db") - -POLL_INTERVAL = 3 # 秒 - - -def derive_mac_key(enc_key, salt): - mac_salt = bytes(b ^ 0x3a for b in salt) - return hashlib.pbkdf2_hmac("sha512", enc_key, mac_salt, 2, dklen=KEY_SZ) - - -def decrypt_page(enc_key, page_data, pgno): - iv = page_data[PAGE_SZ - RESERVE_SZ : PAGE_SZ - RESERVE_SZ + IV_SZ] - if pgno == 1: - encrypted = page_data[SALT_SZ : PAGE_SZ - RESERVE_SZ] - cipher = AES.new(enc_key, AES.MODE_CBC, iv) - decrypted = cipher.decrypt(encrypted) - page = bytearray(SQLITE_HDR + decrypted + b'\x00' * RESERVE_SZ) - return bytes(page) - else: - encrypted = page_data[:PAGE_SZ - RESERVE_SZ] - cipher = AES.new(enc_key, AES.MODE_CBC, iv) - decrypted = cipher.decrypt(encrypted) - return decrypted + b'\x00' * RESERVE_SZ - - -def decrypt_db_to_memory(db_path, enc_key): - """解密DB到内存中的bytes, 返回可用于sqlite3的数据""" - file_size = os.path.getsize(db_path) - total_pages = file_size // PAGE_SZ - if file_size % PAGE_SZ != 0: - total_pages += 1 - - chunks = [] - with open(db_path, 'rb') as fin: - for pgno in range(1, total_pages + 1): - page = fin.read(PAGE_SZ) - if len(page) < PAGE_SZ: - if len(page) > 0: - page = page + b'\x00' * (PAGE_SZ - len(page)) - else: - break - decrypted = decrypt_page(enc_key, page, pgno) - chunks.append(decrypted) - - return b''.join(chunks) - - -def decrypt_db_to_sqlite(db_path, enc_key): - """解密DB并返回sqlite3连接 (内存数据库)""" - data = decrypt_db_to_memory(db_path, enc_key) - - # 写临时文件 (sqlite3不支持直接从bytes打开) - tmp_path = db_path + ".tmp_monitor" - with open(tmp_path, 'wb') as f: - f.write(data) - - conn = sqlite3.connect(tmp_path) - conn.row_factory = sqlite3.Row - return conn, tmp_path - - -def load_contact_names(): - """从已解密的contact.db加载联系人昵称映射""" - names = {} - if not os.path.exists(CONTACT_CACHE): - return names - try: - conn = sqlite3.connect(CONTACT_CACHE) - rows = conn.execute( - "SELECT username, nick_name, remark FROM contact" - ).fetchall() - for r in rows: - username, nick, remark = r - names[username] = remark if remark else nick if nick else username - conn.close() - except Exception as e: - print(f"[WARN] 加载联系人失败: {e}") - return names - - -def get_session_state(conn): - """获取当前session状态""" - state = {} - try: - 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 - """).fetchall() - for r in rows: - state[r[0]] = { - 'unread': r[1], - 'summary': r[2] or '', - 'timestamp': r[3], - 'msg_type': r[4], - 'sender': r[5] or '', - 'sender_name': r[6] or '', - } - except Exception as e: - print(f"[ERROR] 读取session失败: {e}") - return state - - -def format_msg_type(t): - types = { - 1: '文本', 3: '图片', 34: '语音', 42: '名片', - 43: '视频', 47: '表情', 48: '位置', 49: '链接/文件', - 50: '语音/视频通话', 10000: '系统消息', 10002: '撤回', - } - return types.get(t, f'type={t}') - - -def main(): - print("=" * 60) - print(" 微信实时消息监听器") - print("=" * 60) - - # 加载密钥 - with open(KEYS_FILE, encoding="utf-8") as f: - keys = strip_key_metadata(json.load(f)) - - session_key_info = get_key_info(keys, os.path.join("session", "session.db")) - if not session_key_info: - print("[ERROR] 找不到session.db的密钥") - sys.exit(1) - - enc_key = bytes.fromhex(session_key_info["enc_key"]) - session_db = os.path.join(DB_DIR, "session", "session.db") - - # 加载联系人 - print("加载联系人...") - contact_names = load_contact_names() - print(f"已加载 {len(contact_names)} 个联系人") - - # 初始状态 - print("读取初始状态...") - conn, tmp_path = decrypt_db_to_sqlite(session_db, enc_key) - prev_state = get_session_state(conn) - conn.close() - os.remove(tmp_path) - - print(f"跟踪 {len(prev_state)} 个会话") - print(f"轮询间隔: {POLL_INTERVAL}秒") - print(f"\n{'='*60}") - print("开始监听... (Ctrl+C 停止)\n") - - poll_count = 0 - try: - while True: - time.sleep(POLL_INTERVAL) - poll_count += 1 - - try: - conn, tmp_path = decrypt_db_to_sqlite(session_db, enc_key) - curr_state = get_session_state(conn) - conn.close() - os.remove(tmp_path) - except Exception as e: - if poll_count % 10 == 0: - print(f"[{datetime.now().strftime('%H:%M:%S')}] 读取失败: {e}") - continue - - # 比较差异 - for username, curr in curr_state.items(): - prev = prev_state.get(username) - - if prev is None: - # 新会话 - display = contact_names.get(username, username) - ts = datetime.fromtimestamp(curr['timestamp']).strftime('%H:%M:%S') - print(f"[{ts}] 新会话 [{display}]") - print(f" {curr['summary']}") - print() - continue - - # 检查时间戳变化 (有新消息) - if curr['timestamp'] > prev['timestamp']: - display = contact_names.get(username, username) - ts = datetime.fromtimestamp(curr['timestamp']).strftime('%H:%M:%S') - msg_type = format_msg_type(curr['msg_type']) - sender = curr['sender_name'] or curr['sender'] or '' - - # 群聊显示发送者 - if '@chatroom' in username and sender: - sender_display = contact_names.get(curr['sender'], sender) - print(f"[{ts}] [{display}] {sender_display}:") - else: - print(f"[{ts}] [{display}]") - - # 消息内容 - summary = curr['summary'] - if isinstance(summary, bytes): - try: - summary = _zstd_dctx.decompress(summary).decode('utf-8', errors='replace') - except Exception: - summary = '(压缩内容)' - if summary: - # 群消息格式: "wxid_xxx:\n内容" - 提取内容部分 - if ':\n' in summary: - summary = summary.split(':\n', 1)[1] - print(f" [{msg_type}] {summary}") - else: - print(f" [{msg_type}]") - - # 未读数变化 - if curr['unread'] > 0: - print(f" (未读: {curr['unread']})") - print() - - prev_state = curr_state - - # 心跳 - if poll_count % 20 == 0: - now = datetime.now().strftime('%H:%M:%S') - print(f"--- {now} 运行中 (第{poll_count}次轮询) ---") - - except KeyboardInterrupt: - print(f"\n监听结束, 共 {poll_count} 次轮询") - - # 清理 - tmp = session_db + ".tmp_monitor" - if os.path.exists(tmp): - os.remove(tmp) - - -if __name__ == '__main__': - main() diff --git a/monitor_web.py b/monitor_web.py deleted file mode 100644 index 69b95aa..0000000 --- a/monitor_web.py +++ /dev/null @@ -1,2095 +0,0 @@ -""" -微信实时消息监听器 - Web UI (SSE推送 + mtime检测) - -http://localhost:5678 -- 30ms轮询WAL/DB文件的mtime变化(WAL是预分配固定大小,不能用size检测) -- 检测到变化后:全量解密DB + 全量WAL patch -- SSE 服务器推送 -""" -import hashlib, struct, os, sys, json, time, sqlite3, io, threading, queue, traceback -import hmac as hmac_mod -from concurrent.futures import ThreadPoolExecutor -from datetime import datetime -from http.server import HTTPServer, BaseHTTPRequestHandler -from socketserver import ThreadingMixIn -from Crypto.Cipher import AES -import urllib.parse -import glob as glob_mod -import zstandard as zstd -from decode_image import extract_md5_from_packed_info, decrypt_dat_file, is_v2_format -from key_utils import get_key_info, strip_key_metadata - -_zstd_dctx = zstd.ZstdDecompressor() - -PAGE_SZ = 4096 -KEY_SZ = 32 -SALT_SZ = 16 -RESERVE_SZ = 80 -SQLITE_HDR = b'SQLite format 3\x00' -WAL_HEADER_SZ = 32 -WAL_FRAME_HEADER_SZ = 24 - -from config import load_config -_cfg = load_config() -DB_DIR = _cfg["db_dir"] -KEYS_FILE = _cfg["keys_file"] -CONTACT_CACHE = os.path.join(_cfg["decrypted_dir"], "contact", "contact.db") -DECRYPTED_SESSION = os.path.join(_cfg["decrypted_dir"], "session", "session.db") -DECODED_IMAGE_DIR = _cfg.get("decoded_image_dir", os.path.join(os.path.dirname(os.path.abspath(__file__)), "decoded_images")) -MONITOR_CACHE_DIR = os.path.join(_cfg["decrypted_dir"], "_monitor_cache") -WECHAT_BASE_DIR = _cfg.get("wechat_base_dir", "") -IMAGE_AES_KEY = _cfg.get("image_aes_key") # V2 格式 AES key (从微信内存提取) -IMAGE_XOR_KEY = _cfg.get("image_xor_key", 0x88) # XOR key - -POLL_MS = 30 # 高频轮询WAL/DB的mtime,30ms一次 -PORT = 5678 - -sse_clients = [] -sse_lock = threading.Lock() -messages_log = [] -messages_lock = threading.Lock() -MAX_LOG = 500 -_img_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix='img') -_hidden_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix='hidden') - -# ---- Emoji 缓存 (md5 → {cdn_url, aes_key, encrypt_url}) ---- -_emoji_lookup = {} # md5 → dict -_emoji_lookup_lock = threading.Lock() - -_emoji_keys_dict = None # 保存 keys 引用供刷新用 -_emoji_last_refresh = 0 - -def _build_emoji_lookup(keys_dict): - """从 emoticon.db 构建 emoji md5 → URL 映射(直接解密,不走 cache)""" - global _emoji_lookup, _emoji_keys_dict, _emoji_last_refresh - _emoji_keys_dict = keys_dict - key_info = get_key_info(keys_dict, os.path.join("emoticon", "emoticon.db")) - if not key_info: - print("[emoji] 无 emoticon.db key,跳过", flush=True) - return - - src = os.path.join(DB_DIR, "emoticon", "emoticon.db") - if not os.path.exists(src): - return - - import tempfile - dst = os.path.join(tempfile.gettempdir(), "wechat_emoticon_dec.db") - enc_key = bytes.fromhex(key_info["enc_key"]) - - try: - full_decrypt(src, dst, enc_key) - wal = src + "-wal" - if os.path.exists(wal): - decrypt_wal_full(wal, dst, enc_key) - except Exception as e: - print(f"[emoji] emoticon.db 解密失败: {e}", flush=True) - return - - try: - conn = sqlite3.connect(f"file:{dst}?mode=ro", uri=True) - new_lookup = {} - - # 1. NonStore 表情(有独立 cdn_url) - rows = conn.execute( - "SELECT md5, aes_key, cdn_url, encrypt_url, product_id FROM kNonStoreEmoticonTable" - ).fetchall() - # 收集每个 package 的 cdn_url 模板 - pkg_cdn_template = {} # package_id → cdn_url (任意一个) - for md5, aes_key, cdn_url, encrypt_url, product_id in rows: - if md5: - new_lookup[md5] = { - 'cdn_url': cdn_url or '', - 'aes_key': aes_key or '', - 'encrypt_url': encrypt_url or '', - } - if product_id and cdn_url: - pkg_cdn_template[product_id] = cdn_url - - non_store_count = len(new_lookup) - - # 2. Store 表情(尝试构造 cdn_url) - store_rows = conn.execute( - "SELECT package_id_, md5_ FROM kStoreEmoticonFilesTable" - ).fetchall() - store_added = 0 - for pkg_id, md5 in store_rows: - if md5 and md5 not in new_lookup: - # 尝试用同 package 的模板构造 URL - template = pkg_cdn_template.get(pkg_id, '') - if template and '&' in template: - # 替换 m= 参数为新 md5 - import re - constructed = re.sub(r'm=[0-9a-f]+', f'm={md5}', template) - new_lookup[md5] = { - 'cdn_url': constructed, - 'aes_key': '', - 'encrypt_url': '', - } - store_added += 1 - - conn.close() - with _emoji_lookup_lock: - _emoji_lookup = new_lookup - _emoji_last_refresh = time.time() - print(f"[emoji] 已加载 {non_store_count} NonStore + {store_added} Store = {len(new_lookup)} 个表情映射", flush=True) - except Exception as e: - print(f"[emoji] 构建映射失败: {e}", flush=True) - finally: - try: - os.unlink(dst) - except OSError: - pass - -def _download_emoji(md5): - """从 CDN 下载表情并缓存到 decoded_images/,返回文件名或 None""" - with _emoji_lookup_lock: - info = _emoji_lookup.get(md5) - if not info: - # Lookup miss: 刷新 emoticon.db(最多每60秒一次) - if _emoji_keys_dict and time.time() - _emoji_last_refresh > 60: - print(f" [emoji] lookup miss, 刷新 emoticon.db...", flush=True) - _build_emoji_lookup(_emoji_keys_dict) - with _emoji_lookup_lock: - info = _emoji_lookup.get(md5) - if not info: - return None - - # 先检查是否已缓存 - for ext in ('.gif', '.png', '.jpg', '.webp'): - cached = os.path.join(DECODED_IMAGE_DIR, f"emoji_{md5}{ext}") - if os.path.exists(cached): - return f"emoji_{md5}{ext}" - - cdn_url = info.get('cdn_url', '') - aes_key = info.get('aes_key', '') - encrypt_url = info.get('encrypt_url', '') - - data = None - # 方法1: 从 cdn_url 直接下载(未加密) - if cdn_url: - try: - import urllib.request - req = urllib.request.Request(cdn_url, headers={'User-Agent': 'Mozilla/5.0'}) - resp = urllib.request.urlopen(req, timeout=15) - data = resp.read() - except Exception as e: - print(f" [emoji] cdn下载失败 {md5[:12]}: {e}", flush=True) - - # 方法2: 从 encrypt_url 下载 + AES-CBC 解密 - if not data and encrypt_url and aes_key: - try: - import urllib.request - req = urllib.request.Request(encrypt_url, headers={'User-Agent': 'Mozilla/5.0'}) - resp = urllib.request.urlopen(req, timeout=15) - enc_data = resp.read() - key_bytes = bytes.fromhex(aes_key) - cipher = AES.new(key_bytes, AES.MODE_CBC, iv=key_bytes) - data = cipher.decrypt(enc_data) - # 去除 PKCS7 padding - if data: - pad = data[-1] - if 1 <= pad <= 16 and data[-pad:] == bytes([pad]) * pad: - data = data[:-pad] - except Exception as e: - print(f" [emoji] encrypt下载解密失败 {md5[:12]}: {e}", flush=True) - - if not data or len(data) < 4: - return None - - # 检测格式 - if data[:3] == b'\xff\xd8\xff': - ext = '.jpg' - elif data[:4] == b'\x89PNG': - ext = '.png' - elif data[:3] == b'GIF': - ext = '.gif' - elif data[:4] == b'RIFF': - ext = '.webp' - elif data[:4] in (b'wxgf', b'wxam'): - # WXGF/WXAM 需要转换 - ext = '.gif' - tmp_path = os.path.join(DECODED_IMAGE_DIR, f"emoji_{md5}.wxgf") - with open(tmp_path, 'wb') as f: - f.write(data) - jpg_path = _convert_hevc_to_jpeg(tmp_path, os.path.join(DECODED_IMAGE_DIR, f"emoji_{md5}.jpg")) - try: - os.unlink(tmp_path) - except OSError: - pass - if jpg_path: - return f"emoji_{md5}.jpg" - return None - else: - ext = '.bin' - - out_name = f"emoji_{md5}{ext}" - out_path = os.path.join(DECODED_IMAGE_DIR, out_name) - with open(out_path, 'wb') as f: - f.write(data) - print(f" [emoji] 下载缓存: {out_name} ({len(data)//1024}KB)", flush=True) - return out_name - - -class MonitorDBCache: - """轻量 DB 缓存,mtime 检测变化时重新解密(线程安全)""" - - def __init__(self, keys, tmp_dir): - self.keys = keys - self.tmp_dir = tmp_dir - os.makedirs(tmp_dir, exist_ok=True) - self._state = {} # rel_key → (db_mtime, wal_mtime) - self._locks = {} # per-key 锁,防止并发解密同一 DB - self._meta_lock = threading.Lock() - - def _get_lock(self, rel_key): - with self._meta_lock: - if rel_key not in self._locks: - self._locks[rel_key] = threading.Lock() - return self._locks[rel_key] - - def invalidate(self, rel_key): - """强制清除缓存状态,下次 get() 会重新全量解密""" - lock = self._get_lock(rel_key) - with lock: - self._state.pop(rel_key, None) - - def get(self, rel_key): - """返回解密后的临时文件路径,mtime 变化时自动重新解密""" - key_info = get_key_info(self.keys, rel_key) - if not key_info: - return None - - lock = self._get_lock(rel_key) - with lock: - enc_key = bytes.fromhex(key_info["enc_key"]) - rel_path = rel_key.replace('\\', '/').replace('/', os.sep) - db_path = os.path.join(DB_DIR, rel_path) - wal_path = db_path + "-wal" - - if not os.path.exists(db_path): - return None - - try: - db_mtime = os.path.getmtime(db_path) - wal_mtime = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0 - except OSError: - return None - - out_name = rel_key.replace('\\', '_').replace('/', '_') - out_path = os.path.join(self.tmp_dir, out_name) - - prev = self._state.get(rel_key) - - if prev is None or db_mtime != prev[0]: - t0 = time.perf_counter() - for _retry in range(3): - try: - full_decrypt(db_path, out_path, enc_key) - break - except PermissionError: - if _retry < 2: - time.sleep(1) - else: - raise - if os.path.exists(wal_path): - decrypt_wal_full(wal_path, out_path, enc_key) - ms = (time.perf_counter() - t0) * 1000 - print(f" [cache] {rel_key} 全量解密 {ms:.0f}ms", flush=True) - self._state[rel_key] = (db_mtime, wal_mtime) - elif wal_mtime != prev[1]: - t0 = time.perf_counter() - decrypt_wal_full(wal_path, out_path, enc_key) - ms = (time.perf_counter() - t0) * 1000 - print(f" [cache] {rel_key} WAL patch {ms:.0f}ms", flush=True) - self._state[rel_key] = (db_mtime, wal_mtime) - - return out_path - - -def build_username_db_map(): - """从已解密的 Name2Id 表构建 username → [db_keys] 映射 - - 同一个 username 可能存在于多个 message_N.db 中, - 按 DB 文件修改时间倒序排列(最新的排前面)。 - """ - # 先获取每个 DB 的 mtime 用于排序 - db_mtimes = {} - for i in range(5): - rel_key = os.path.join("message", f"message_{i}.db") - db_path = os.path.join(DB_DIR, "message", f"message_{i}.db") - try: - db_mtimes[rel_key] = os.path.getmtime(db_path) - except OSError: - db_mtimes[rel_key] = 0 - - mapping = {} # username → [db_keys], 最新的在前 - decrypted_msg_dir = os.path.join(_cfg["decrypted_dir"], "message") - for i in range(5): - db_path = os.path.join(decrypted_msg_dir, f"message_{i}.db") - if not os.path.exists(db_path): - continue - rel_key = os.path.join("message", f"message_{i}.db") - try: - conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) - for row in conn.execute("SELECT user_name FROM Name2Id").fetchall(): - if row[0] not in mapping: - mapping[row[0]] = [] - mapping[row[0]].append(rel_key) - conn.close() - except Exception as e: - print(f" [WARN] Name2Id message_{i}.db: {e}", flush=True) - - # 对每个 username 的 db_keys 按 mtime 倒序(最新的优先) - for username in mapping: - mapping[username].sort(key=lambda k: db_mtimes.get(k, 0), reverse=True) - - return mapping - - -def decrypt_page(enc_key, page_data, pgno): - """解密单个加密页面""" - iv = page_data[PAGE_SZ - RESERVE_SZ: PAGE_SZ - RESERVE_SZ + 16] - if pgno == 1: - encrypted = page_data[SALT_SZ: PAGE_SZ - RESERVE_SZ] - cipher = AES.new(enc_key, AES.MODE_CBC, iv) - decrypted = cipher.decrypt(encrypted) - return bytearray(SQLITE_HDR + decrypted + b'\x00' * RESERVE_SZ) - else: - encrypted = page_data[:PAGE_SZ - RESERVE_SZ] - cipher = AES.new(enc_key, AES.MODE_CBC, iv) - decrypted = cipher.decrypt(encrypted) - return decrypted + b'\x00' * RESERVE_SZ - - -def full_decrypt(db_path, out_path, enc_key): - """首次全量解密""" - t0 = time.perf_counter() - file_size = os.path.getsize(db_path) - total_pages = file_size // PAGE_SZ - - 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, total_pages + 1): - page = fin.read(PAGE_SZ) - if len(page) < PAGE_SZ: - if len(page) > 0: - page = page + b'\x00' * (PAGE_SZ - len(page)) - else: - break - fout.write(decrypt_page(enc_key, page, pgno)) - - ms = (time.perf_counter() - t0) * 1000 - return total_pages, ms - - -def decrypt_wal_full(wal_path, out_path, enc_key): - """解密WAL当前有效frame,patch到已解密的DB副本 - - WAL是预分配固定大小(4MB),包含当前有效frame和上一轮遗留的旧frame。 - 通过WAL header中的salt值区分:只有frame header的salt匹配WAL header的才是有效frame。 - - 返回: (patched_pages, elapsed_ms) - """ - t0 = time.perf_counter() - - if not os.path.exists(wal_path): - return 0, 0 - - wal_size = os.path.getsize(wal_path) - if wal_size <= WAL_HEADER_SZ: - return 0, 0 - - frame_size = WAL_FRAME_HEADER_SZ + PAGE_SZ # 24 + 4096 = 4120 - patched = 0 - - with open(wal_path, 'rb') as wf, open(out_path, 'r+b') as df: - # 读WAL header,获取当前salt值 - wal_hdr = wf.read(WAL_HEADER_SZ) - wal_salt1 = struct.unpack('>I', wal_hdr[16:20])[0] - wal_salt2 = struct.unpack('>I', wal_hdr[20:24])[0] - - while wf.tell() + frame_size <= wal_size: - fh = wf.read(WAL_FRAME_HEADER_SZ) - if len(fh) < WAL_FRAME_HEADER_SZ: - break - pgno = struct.unpack('>I', fh[0:4])[0] - frame_salt1 = struct.unpack('>I', fh[8:12])[0] - frame_salt2 = struct.unpack('>I', fh[12:16])[0] - - ep = wf.read(PAGE_SZ) - if len(ep) < PAGE_SZ: - break - - # 校验: pgno有效 且 salt匹配当前WAL周期 - if pgno == 0 or pgno > 1000000: - continue - if frame_salt1 != wal_salt1 or frame_salt2 != wal_salt2: - continue # 旧周期遗留的frame,跳过 - - dec = decrypt_page(enc_key, ep, pgno) - df.seek((pgno - 1) * PAGE_SZ) - df.write(dec) - patched += 1 - - ms = (time.perf_counter() - t0) * 1000 - return patched, ms - - -def load_contact_names(): - names = {} - try: - conn = sqlite3.connect(CONTACT_CACHE) - for r in conn.execute("SELECT username, nick_name, remark FROM contact").fetchall(): - names[r[0]] = r[2] if r[2] else r[1] if r[1] else r[0] - conn.close() - except: - pass - return names - - -def _extract_pb_field_30(data): - """从 extra_buffer (protobuf) 中提取 Field #30 的字符串值(联系人标签ID)""" - if not data: - return None - pos = 0 - n = len(data) - while pos < n: - tag = 0 - shift = 0 - while pos < n: - b = data[pos]; pos += 1 - tag |= (b & 0x7f) << shift - if not (b & 0x80): - break - shift += 7 - field_num = tag >> 3 - wire_type = tag & 0x07 - if wire_type == 0: - while pos < n and data[pos] & 0x80: - pos += 1 - pos += 1 - elif wire_type == 2: - length = 0; shift = 0 - while pos < n: - b = data[pos]; pos += 1 - length |= (b & 0x7f) << shift - if not (b & 0x80): - break - shift += 7 - if field_num == 30: - try: - return data[pos:pos + length].decode('utf-8') - except Exception: - return None - pos += length - elif wire_type == 1: - pos += 8 - elif wire_type == 5: - pos += 4 - else: - break - return None - - -def load_contact_tags(): - """加载联系人标签及其成员""" - try: - conn = sqlite3.connect(CONTACT_CACHE) - try: - label_rows = conn.execute( - "SELECT label_id_, label_name_, sort_order_ FROM contact_label ORDER BY sort_order_" - ).fetchall() - except Exception: - conn.close() - return [] - if not label_rows: - conn.close() - return [] - - labels = {} - for lid, lname, sort_order in label_rows: - labels[lid] = {'id': lid, 'name': lname, 'sort_order': sort_order, 'members': []} - - names = load_contact_names() - rows = conn.execute( - "SELECT username, extra_buffer FROM contact WHERE extra_buffer IS NOT NULL" - ).fetchall() - conn.close() - - for username, buf in rows: - label_str = _extract_pb_field_30(buf) - if not label_str: - continue - display = names.get(username, username) - for lid_s in label_str.split(','): - try: - lid = int(lid_s.strip()) - except (ValueError, AttributeError): - continue - if lid in labels: - labels[lid]['members'].append({'username': username, 'display_name': display}) - - result = sorted(labels.values(), key=lambda t: t['sort_order']) - for t in result: - t['member_count'] = len(t['members']) - return result - except Exception: - return [] - - -def format_msg_type(t): - return { - 1: '文本', 3: '图片', 34: '语音', 42: '名片', - 43: '视频', 47: '表情', 48: '位置', 49: '链接/文件', - 50: '通话', 10000: '系统', 10002: '撤回', - }.get(t, f'type={t}') - - -def msg_type_icon(t): - return { - 1: '💬', 3: '🖼️', 34: '🎤', 42: '👤', - 43: '🎬', 47: '😀', 48: '📍', 49: '🔗', - 50: '📞', 10000: '⚙️', 10002: '↩️', - }.get(t, '📨') - - -def broadcast_sse(msg_data): - event_type = msg_data.get('event', '') - data_line = f"data: {json.dumps(msg_data, ensure_ascii=False)}\n" - if event_type: - payload = f"event: {event_type}\n{data_line}\n" - else: - payload = f"{data_line}\n" - with sse_lock: - dead = [] - for q in sse_clients: - try: - q.put_nowait(payload) - except: - dead.append(q) - for q in dead: - sse_clients.remove(q) - - -def _convert_hevc_to_jpeg(hevc_path, jpeg_path): - """将 wxgf/HEVC 文件转为 JPEG - - wxgf 是微信自有格式: wxgf header + ICC profile + HEVC NAL units - 通过扫描 HEVC VPS start code (00 00 00 01 40 01) 定位 Annex B 流, - 再用 PyAV (ffmpeg) 解码首帧为 JPEG。 - """ - try: - import av - - with open(hevc_path, 'rb') as f: - data = f.read() - - # 扫描 HEVC Annex B VPS start code: 00 00 00 01 40 01 - vps_sig = b'\x00\x00\x00\x01\x40\x01' - hevc_start = data.find(vps_sig) - if hevc_start < 0: - # fallback: 找 SPS (00 00 00 01 42 01) - hevc_start = data.find(b'\x00\x00\x00\x01\x42\x01') - if hevc_start < 0: - print(f" [img] wxgf 中未找到 HEVC VPS/SPS", flush=True) - return None - - # 提取 HEVC Annex B 流并用 PyAV 解码 - h265_path = hevc_path + '.h265' - with open(h265_path, 'wb') as f: - f.write(data[hevc_start:]) - - try: - container = av.open(h265_path, format='hevc') - for frame in container.decode(video=0): - img = frame.to_image() - img.save(jpeg_path, "JPEG", quality=90) - container.close() - return jpeg_path - container.close() - finally: - if os.path.exists(h265_path): - os.unlink(h265_path) - - except ImportError: - print(f" [img] 需要 PyAV: pip install av", flush=True) - except Exception as e: - print(f" [img] HEVC→JPEG 失败: {e}", flush=True) - return None - - -# ============ 监听器 ============ - -class SessionMonitor: - def __init__(self, enc_key, session_db, contact_names, db_cache=None, username_db_map=None): - self.enc_key = enc_key - self.session_db = session_db - self.wal_path = session_db + "-wal" - self.contact_names = contact_names - self.db_cache = db_cache - self.username_db_map = username_db_map or {} - self.prev_state = {} - self.decrypt_ms = 0 - self.patched_pages = 0 - # 已显示消息去重: {(username, timestamp, base_msg_type), ...} - self._shown_keys = set() - - def resolve_image(self, username, timestamp): - """解密图片: username+timestamp → 解密后的图片文件名,失败返回 None""" - if not self.db_cache or not self.username_db_map: - return None - - # 1. 找到 username 对应的所有 message_N.db(按 mtime 倒序) - db_keys = self.username_db_map.get(username) - if not db_keys: - return None - - # 2. 遍历候选 DB,找到包含该 timestamp 消息的那个 - table_name = f"Msg_{hashlib.md5(username.encode()).hexdigest()}" - local_id = None - for db_key in db_keys: - for _try in range(2): - msg_db_path = self.db_cache.get(db_key) - if not msg_db_path: - break - try: - conn = sqlite3.connect(f"file:{msg_db_path}?mode=ro", uri=True) - # 微信4.0 图片的 local_type 可能是复合编码: (sub<<32)|3 - row = conn.execute(f""" - SELECT local_id FROM [{table_name}] - WHERE (local_type = 3 OR (local_type > 4294967296 AND local_type % 4294967296 = 3)) - AND create_time = ? - """, (timestamp,)).fetchone() - if not row: - row = conn.execute(f""" - SELECT local_id FROM [{table_name}] - WHERE (local_type = 3 OR (local_type > 4294967296 AND local_type % 4294967296 = 3)) - AND ABS(create_time - ?) <= 3 - ORDER BY ABS(create_time - ?) LIMIT 1 - """, (timestamp, timestamp)).fetchone() - conn.close() - if row: - local_id = row[0] - break - except Exception as e: - if 'malformed' in str(e) and _try == 0: - print(f" [img] {db_key} malformed, 强制刷新...", flush=True) - self.db_cache.invalidate(db_key) - continue - if 'no such table' not in str(e): - print(f" [img] 查询 {db_key}/{table_name} 失败: {e}", flush=True) - break - if local_id: - break - - if not local_id: - print(f" [img] 未找到 local_id: {username} t={timestamp}", flush=True) - return None - - # 4. 查 message_resource.db 获取 MD5 - # local_id 不全局唯一,需要同时匹配 create_time - file_md5 = None - for _try in range(2): - res_path = self.db_cache.get(os.path.join("message", "message_resource.db")) - if not res_path: - return None - try: - conn = sqlite3.connect(f"file:{res_path}?mode=ro", uri=True) - row = conn.execute( - "SELECT packed_info FROM MessageResourceInfo " - "WHERE message_local_id = ? AND message_create_time = ? " - "AND (message_local_type = 3 OR message_local_type % 4294967296 = 3)", - (local_id, timestamp) - ).fetchone() - if not row: - row = conn.execute( - "SELECT packed_info FROM MessageResourceInfo " - "WHERE message_create_time = ? " - "AND (message_local_type = 3 OR message_local_type % 4294967296 = 3)", - (timestamp,) - ).fetchone() - conn.close() - if row and row[0]: - file_md5 = extract_md5_from_packed_info(row[0]) - break - except Exception as e: - if 'malformed' in str(e) and _try == 0: - print(f" [img] resource DB malformed, 强制刷新...", flush=True) - self.db_cache.invalidate(os.path.join("message", "message_resource.db")) - continue - print(f" [img] 查询 message_resource 失败: {e}", flush=True) - return None - - if not file_md5: - print(f" [img] 未找到 MD5: local_id={local_id} t={timestamp}", flush=True) - return None - - # 5. 查找 .dat 文件 - attach_dir = os.path.join(WECHAT_BASE_DIR, "msg", "attach") - username_hash = hashlib.md5(username.encode()).hexdigest() - search_base = os.path.join(attach_dir, username_hash) - - if not os.path.isdir(search_base): - print(f" [img] attach 目录不存在: {search_base}", flush=True) - return None - - pattern = os.path.join(search_base, "*", "Img", f"{file_md5}*.dat") - dat_files = sorted(glob_mod.glob(pattern)) - if not dat_files: - print(f" [img] 未找到 .dat: MD5={file_md5}", flush=True) - return None - - # 分类 .dat 文件 - # 优先级: 原图.dat(最大) > _h.dat > _W.dat > _t.dat(缩略图) - ranked = [] - for f in dat_files: - fname = os.path.basename(f).lower() - sz = os.path.getsize(f) - if '_t_' in fname: - rank = 5 # _t_W.dat 缩略图变体 - elif '_t.' in fname: - rank = 4 # _t.dat 缩略图 - elif '_w.' in fname: - rank = 2 # _W.dat (V2 可转 JPEG) - elif '_h.' in fname: - rank = 1 # 高清 - elif fname == f"{file_md5}.dat".lower(): - rank = 0 # 原图 (最优先) - else: - rank = 0 - ranked.append((rank, sz, f)) - ranked.sort(key=lambda x: (x[0], -x[1])) - - # 6. 解密图片 - os.makedirs(DECODED_IMAGE_DIR, exist_ok=True) - out_base = os.path.join(DECODED_IMAGE_DIR, file_md5) - rank_names = {0: 'orig', 1: 'h', 2: 'W', 4: 't', 5: 't_W'} - browser_formats = ('jpg', 'png', 'gif', 'webp') - - # 已有可用缓存则跳过 - for ext in browser_formats: - candidate = f"{out_base}.{ext}" - if os.path.exists(candidate): - cached_sz = os.path.getsize(candidate) - best_rank = ranked[0][0] if ranked else 99 - if cached_sz > 20480 or best_rank >= 4: - return os.path.basename(candidate) - os.unlink(candidate) - print(f" [img] 缩略图升级: {cached_sz/1024:.0f}KB → 重解密", flush=True) - break - - for rank, sz, selected in ranked: - sel_type = rank_names.get(rank, '?') - print(f" [img] 尝试 {sel_type}({sz/1024:.0f}KB): {os.path.basename(selected)}", flush=True) - - if is_v2_format(selected) and not IMAGE_AES_KEY: - print(f" [img] V2 格式缺少 AES key, 跳过", flush=True) - continue - - result_path, fmt = decrypt_dat_file(selected, f"{out_base}.tmp", IMAGE_AES_KEY, IMAGE_XOR_KEY) - if not result_path: - print(f" [img] 解密失败, 跳过", flush=True) - continue - - # HEVC/wxgf → 用 pillow-heif 转 JPEG - if fmt in ('hevc', 'bin'): - jpg_path = _convert_hevc_to_jpeg(result_path, f"{out_base}.jpg") - os.unlink(result_path) - if jpg_path: - size_kb = os.path.getsize(jpg_path) / 1024 - print(f" [img] HEVC→JPEG 成功: {os.path.basename(jpg_path)} ({size_kb:.0f}KB)", flush=True) - return os.path.basename(jpg_path) - print(f" [img] HEVC→JPEG 转换失败, 尝试下一个", flush=True) - continue - - final = f"{out_base}.{fmt}" - if os.path.exists(final): - os.unlink(final) - os.rename(result_path, final) - size_kb = os.path.getsize(final) / 1024 - print(f" [img] 解密成功: {os.path.basename(final)} ({size_kb:.0f}KB)", flush=True) - return os.path.basename(final) - - print(f" [img] 所有 .dat 均无法解密", flush=True) - return '__v2_unsupported__' - - def _async_resolve_image(self, username, timestamp, msg_data): - """后台线程: 解密图片并通过 SSE 推送更新""" - delays = [0.3, 1.0, 2.0] - for attempt in range(3): - try: - img_name = self.resolve_image(username, timestamp) - if img_name == '__v2_unsupported__': - msg_data['content'] = '[图片 - 新加密格式暂不支持预览]' - broadcast_sse({ - 'event': 'image_update', - 'timestamp': timestamp, - 'username': username, - 'v2_unsupported': True, - }) - return - elif img_name: - image_url = f'/img/{img_name}' - msg_data['image_url'] = image_url - broadcast_sse({ - 'event': 'image_update', - 'timestamp': timestamp, - 'username': username, - 'image_url': image_url, - }) - print(f" [img] 异步解密成功: {img_name}", flush=True) - return - elif attempt < 2: - time.sleep(delays[attempt]) - except Exception as e: - print(f" [img] 异步解密失败(attempt={attempt}): {e}", flush=True) - if attempt < 2: - time.sleep(delays[attempt]) - - def _fresh_decrypt_query(self, db_key, table_name, prev_ts, curr_ts): - """独立解密 message DB 到临时文件并查询,避免共享缓存竞态""" - key_info = get_key_info(self.db_cache.keys, db_key) - if not key_info: - return [] - enc_key = bytes.fromhex(key_info["enc_key"]) - rel_path = db_key.replace('\\', '/').replace('/', os.sep) - db_path = os.path.join(DB_DIR, rel_path) - wal_path = db_path + "-wal" - if not os.path.exists(db_path): - return [] - - import tempfile - fd, tmp_path = tempfile.mkstemp(suffix='.db') - os.close(fd) - try: - t0 = time.perf_counter() - full_decrypt(db_path, tmp_path, enc_key) - if os.path.exists(wal_path): - decrypt_wal_full(wal_path, tmp_path, enc_key) - ms = (time.perf_counter() - t0) * 1000 - print(f" [hidden] {db_key} 独立解密 {ms:.0f}ms", flush=True) - - conn = sqlite3.connect(f"file:{tmp_path}?mode=ro", uri=True) - rows = conn.execute(f""" - SELECT create_time, local_type, message_content, WCDB_CT_message_content - FROM [{table_name}] - WHERE create_time >= ? AND create_time <= ? - ORDER BY create_time ASC - """, (prev_ts, curr_ts)).fetchall() - conn.close() - return rows - except Exception as e: - print(f" [hidden] {db_key} 独立解密失败: {e}", flush=True) - return [] - finally: - try: - os.unlink(tmp_path) - except OSError: - pass - - def _check_hidden_messages(self, username, prev_ts, curr_ts, curr_msg_type, display, is_group, sender): - """检查时间窗口内是否有被 session 摘要覆盖的消息(文字、图片、表情等) - - 先用共享缓存查询(快),失败或可疑时用独立解密(慢但可靠)。 - """ - if not self.username_db_map: - return - db_keys = self.username_db_map.get(username) - if not db_keys: - return - - table_name = f"Msg_{hashlib.md5(username.encode()).hexdigest()}" - print(f" [hidden] 检查 {display[:15]} prev_ts={prev_ts} curr_ts={curr_ts} type={curr_msg_type}", flush=True) - - # 等待 message DB 写入完成 - time.sleep(1.0) - - # 快速路径: 用共享缓存查询(带重试) - all_rows = [] - cache_failed = False - for _try in range(3): - all_rows.clear() - if self.db_cache: - for db_key in db_keys: - dec_path = self.db_cache.get(db_key) - if not dec_path: - continue - try: - conn = sqlite3.connect(f"file:{dec_path}?mode=ro", uri=True) - rows = conn.execute(f""" - SELECT create_time, local_type, message_content, WCDB_CT_message_content - FROM [{table_name}] - WHERE create_time >= ? AND create_time <= ? - ORDER BY create_time ASC - """, (prev_ts, curr_ts)).fetchall() - conn.close() - all_rows.extend(rows) - except Exception as e: - print(f" [hidden] 缓存查询失败 {db_key}: {e}", flush=True) - cache_failed = True - break - # 检查是否找到了 curr_ts 的消息(说明缓存是最新的) - has_curr = any(r[0] == curr_ts for r in all_rows) - if has_curr or cache_failed: - break - # 缓存可能还没更新到最新数据,短暂等待后重试 - if _try < 2: - time.sleep(1.5) - print(f" [hidden] 缓存未包含最新消息,重试({_try+1})...", flush=True) - - # 仅在缓存查询出错时才用昂贵的独立解密 - if cache_failed: - print(f" [hidden] 缓存异常,启动独立解密...", flush=True) - all_rows = [] - for db_key in db_keys: - rows = self._fresh_decrypt_query(db_key, table_name, prev_ts, curr_ts) - all_rows.extend(rows) - if rows: - break - else: - print(f" [hidden] 缓存查到 {len(all_rows)} 条", flush=True) - - # 过滤出隐藏消息 - hidden_msgs = [] - for ts, lt, mc, ct in all_rows: - base = lt % 4294967296 if lt > 4294967296 else lt - # 跳过已显示的消息(精确匹配 username+timestamp+type) - if (username, ts, base) in self._shown_keys: - continue - # 解压 zstd - if isinstance(mc, bytes) and ct == 4: - try: - mc = _zstd_dctx.decompress(mc).decode('utf-8', errors='replace') - except Exception: - mc = mc.decode('utf-8', errors='replace') if isinstance(mc, bytes) else '' - elif isinstance(mc, bytes): - mc = mc.decode('utf-8', errors='replace') - hidden_msgs.append((ts, base, mc or '')) - - print(f" [hidden] 找到 {len(hidden_msgs)} 条隐藏消息", flush=True) - - if not hidden_msgs: - return - - global messages_log - for ts, base, mc in hidden_msgs: - self._shown_keys.add((username, ts, base)) - msg_data = { - 'time': datetime.fromtimestamp(ts).strftime('%H:%M:%S'), - 'timestamp': ts, - 'chat': display, - 'username': username, - 'is_group': is_group, - 'sender': sender, - } - if base == 3: - # 隐藏的图片消息 - time.sleep(0.5) - img_name = self.resolve_image(username, ts) - if img_name and img_name != '__v2_unsupported__': - msg_data.update({ - 'type': '图片', 'type_icon': '\U0001f5bc\ufe0f', - 'content': '', 'image_url': f'/img/{img_name}', - }) - print(f" [hidden] 补充图片: {img_name} t={ts}", flush=True) - else: - continue - elif base == 1: - # 隐藏的文字消息 - msg_data.update({ - 'type': '文本', 'type_icon': '\U0001f4ac', - 'content': mc, - }) - print(f" [hidden] 补充文字: {mc[:30]} t={ts}", flush=True) - elif base == 47: - # 隐藏的表情消息 - rich = self.resolve_rich_content(username, ts, 47) - msg_data.update({ - 'type': '表情', 'type_icon': '\U0001f600', - 'content': '[表情]', - }) - if rich: - msg_data['rich_content'] = rich - print(f" [hidden] 补充表情 t={ts}", flush=True) - elif base == 49: - # 隐藏的富媒体消息 - rich = self.resolve_rich_content(username, ts, 49) - msg_data.update({ - 'type': format_msg_type(base), 'type_icon': msg_type_icon(base), - 'content': mc[:100] if mc else '', - }) - if rich: - msg_data['rich_content'] = rich - print(f" [hidden] 补充富媒体 t={ts}", flush=True) - else: - # 其他类型 - msg_data.update({ - 'type': format_msg_type(base), 'type_icon': msg_type_icon(base), - 'content': mc[:100] if mc else f'[{format_msg_type(base)}]', - }) - print(f" [hidden] 补充type={base} t={ts}", flush=True) - - with messages_lock: - messages_log.append(msg_data) - if len(messages_log) > MAX_LOG: - messages_log = messages_log[-MAX_LOG:] - broadcast_sse(msg_data) - - def _query_msg_content(self, username, timestamp, base_type): - """通用: 从 message_*.db 查找指定类型消息的 XML 内容 - - base_type: 基础类型 (47, 49, 43, 34 等) - 微信4.0 的 local_type 是复合编码: (sub_type << 32) | base_type - """ - db_keys = self.username_db_map.get(username, []) - if not db_keys: - return None - - tbl = f"Msg_{hashlib.md5(username.encode()).hexdigest()}" - for dk in db_keys: - for _try in range(2): - dec_path = self.db_cache.get(dk) - if not dec_path: - break - try: - conn = sqlite3.connect(f"file:{dec_path}?mode=ro", uri=True) - row = conn.execute(f''' - SELECT message_content, WCDB_CT_message_content, local_type - FROM "{tbl}" - WHERE (local_type = ? OR (local_type > 4294967296 AND local_type % 4294967296 = ?)) - AND create_time BETWEEN ? AND ? - ORDER BY create_time DESC LIMIT 1 - ''', (base_type, base_type, timestamp - 5, timestamp + 5)).fetchone() - conn.close() - - if not row: - break # 表存在但没找到匹配行,换下一个 DB - mc, ct_flag, full_type = row - if isinstance(mc, bytes) and ct_flag == 4: - mc = _zstd_dctx.decompress(mc).decode('utf-8', errors='replace') - elif isinstance(mc, bytes): - mc = mc.decode('utf-8', errors='replace') - if not mc: - break - - xml_start = mc.find('') - if xml_start < 0: - xml_start = mc.find(' 0: - mc = mc[xml_start:] - - return mc, full_type - - except Exception as e: - if 'malformed' in str(e) and _try == 0: - print(f" [rich] {dk} malformed, 强制刷新...", flush=True) - self.db_cache.invalidate(dk) - continue - if 'no such table' not in str(e): - print(f" [rich] 查询 {dk} 失败: {e}", flush=True) - break - return None - - def _parse_rich_content(self, username, timestamp, msg_type): - """解析富媒体消息, 返回 dict 或 None""" - import xml.etree.ElementTree as ET - - if msg_type == 47: - # --- 表情 --- - result = self._query_msg_content(username, timestamp, 47) - if not result: - print(f" [emoji] 查询失败 user={username[:10]} ts={timestamp}", flush=True) - return None - mc, _ = result - if '> 32 if full_type > 4294967296 else 0 - if '= 20: - break - except ET.ParseError: - pass - return { - 'type': 'chatlog', - 'title': title, - 'des': des[:200] if des else '', - 'items': items, - } - else: - # 其他子类型: 用 title 显示 - if title: - return { - 'type': 'link', - 'title': title, - 'des': des[:200] if des else '', - 'url': url, - } - except ET.ParseError: - pass - return None - - elif msg_type == 43: - # --- 视频 --- - result = self._query_msg_content(username, timestamp, 43) - if not result: - return None - mc, _ = result - try: - root = ET.fromstring(mc) - video = root.find('.//videomsg') - if video is None: - return None - length = int(video.get('playlength') or 0) - return { - 'type': 'video', - 'duration': length, - } - except ET.ParseError: - pass - return None - - elif msg_type == 34: - # --- 语音 --- - result = self._query_msg_content(username, timestamp, 34) - if not result: - return None - mc, _ = result - try: - root = ET.fromstring(mc) - voice = root.find('.//voicemsg') - if voice is None: - return None - length_ms = int(voice.get('voicelength') or 0) - return { - 'type': 'voice', - 'duration': round(length_ms / 1000, 1), - } - except ET.ParseError: - pass - return None - - return None - - def _async_resolve_rich(self, username, timestamp, msg_type, msg_data): - """后台线程: 解析富媒体内容并推送 SSE(带重试)""" - delays = [0.5, 1.5, 3.0] - for attempt in range(3): - try: - time.sleep(delays[attempt]) - info = self._parse_rich_content(username, timestamp, msg_type) - if info: - msg_data['rich'] = info - broadcast_sse({ - 'event': 'rich_update', - 'timestamp': timestamp, - 'username': username, - 'rich': info, - }) - print(f" [rich] {info['type']} 解析成功", flush=True) - return - except Exception as e: - print(f" [rich] 解析失败: {e}", flush=True) - print(f" [rich] type={msg_type} 3次重试均失败: {username}", flush=True) - - def query_state(self): - """查询已解密副本的session状态""" - conn = sqlite3.connect(f"file:{DECRYPTED_SESSION}?mode=ro", uri=True) - state = {} - for r in 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 - """).fetchall(): - state[r[0]] = { - 'unread': r[1], 'summary': r[2] or '', 'timestamp': r[3], - 'msg_type': r[4], 'sender': r[5] or '', 'sender_name': r[6] or '', - } - conn.close() - return state - - def do_full_refresh(self): - """全量解密DB + 全量WAL patch""" - # 先解密主DB - pages, ms = full_decrypt(self.session_db, DECRYPTED_SESSION, self.enc_key) - total_ms = ms - wal_patched = 0 - - # 再patch所有WAL frames - if os.path.exists(self.wal_path): - wal_patched, ms2 = decrypt_wal_full(self.wal_path, DECRYPTED_SESSION, self.enc_key) - total_ms += ms2 - - self.decrypt_ms = total_ms - self.patched_pages = pages + wal_patched - return self.patched_pages - - def check_updates(self): - global messages_log - try: - t0 = time.perf_counter() - self.do_full_refresh() - t1 = time.perf_counter() - curr_state = self.query_state() - t2 = time.perf_counter() - print(f" [perf] decrypt={self.patched_pages}页/{(t1-t0)*1000:.1f}ms, query={(t2-t1)*1000:.1f}ms", flush=True) - except Exception as e: - print(f" [ERROR] check_updates: {e}", flush=True) - return - - # 收集所有新消息,按时间排序后再推送 - new_msgs = [] - for username, curr in curr_state.items(): - prev = self.prev_state.get(username) - # 检测: 时间戳变化 OR 同一秒内消息类型变化(文字+图片组合) - is_new = prev and (curr['timestamp'] > prev['timestamp'] or - (curr['timestamp'] == prev['timestamp'] and curr['msg_type'] != prev.get('msg_type'))) - if is_new: - display = self.contact_names.get(username, username) - is_group = '@chatroom' in username - # 新群/新联系人不在缓存中时,重新加载联系人 - if display == username and username not in self.contact_names: - refreshed = load_contact_names() - self.contact_names.update(refreshed) - display = self.contact_names.get(username, username) - sender = '' - if is_group: - sender = self.contact_names.get(curr['sender'], curr['sender_name'] or curr['sender']) - - summary = curr['summary'] - if isinstance(summary, bytes): - try: - summary = _zstd_dctx.decompress(summary).decode('utf-8', errors='replace') - except Exception: - summary = '(压缩内容)' - if summary and ':\n' in summary: - summary = summary.split(':\n', 1)[1] - - msg_data = { - 'time': datetime.fromtimestamp(curr['timestamp']).strftime('%H:%M:%S'), - 'timestamp': curr['timestamp'], - 'chat': display, - 'username': username, - 'is_group': is_group, - 'sender': sender, - 'type': format_msg_type(curr['msg_type']), - 'type_icon': msg_type_icon(curr['msg_type']), - 'content': summary, - 'unread': curr['unread'], - 'decrypt_ms': round(self.decrypt_ms, 1), - 'pages': self.patched_pages, - } - - new_msgs.append(msg_data) - self._shown_keys.add((username, curr['timestamp'], curr['msg_type'])) - - # 图片消息: 后台异步解密(不阻塞轮询) - if curr['msg_type'] == 3: - _img_executor.submit( - self._async_resolve_image, - username, curr['timestamp'], msg_data - ) - - # 富媒体消息: 后台解析内容 - if curr['msg_type'] in (47, 49, 43, 34): - _img_executor.submit( - self._async_resolve_rich, - username, curr['timestamp'], curr['msg_type'], msg_data - ) - - # 检查时间窗口内是否有被 session 摘要覆盖的消息 - # (比如用户发了 图片+文字,session只记录最后一条) - prev_ts = prev['timestamp'] if prev else curr['timestamp'] - 5 - _hidden_executor.submit( - self._check_hidden_messages, - username, prev_ts, curr['timestamp'], curr['msg_type'], - display, is_group, sender - ) - - # 按时间排序 - new_msgs.sort(key=lambda m: m['timestamp']) - - for msg in new_msgs: - with messages_lock: - messages_log.append(msg) - if len(messages_log) > MAX_LOG: - messages_log = messages_log[-MAX_LOG:] - - broadcast_sse(msg) - - try: - now = time.time() - msg_age = now - msg['timestamp'] - tag = f"{self.patched_pages}pg/{self.decrypt_ms:.0f}ms" - sender = msg['sender'] - now_str = datetime.fromtimestamp(now).strftime('%H:%M:%S') - if sender: - print(f"[{msg['time']} 延迟={msg_age:.1f}s] [{msg['chat']}] {sender}: {msg['content']} ({tag})", flush=True) - else: - print(f"[{msg['time']} 延迟={msg_age:.1f}s] [{msg['chat']}] {msg['content']} ({tag})", flush=True) - except Exception: - pass # Windows CMD编码问题,不影响SSE推送 - - self.prev_state = curr_state - - # 清理过期的去重 key(保留最近 5 分钟) - cutoff = int(time.time()) - 300 - self._shown_keys = {k for k in self._shown_keys if k[1] > cutoff} - -def monitor_thread(enc_key, session_db, contact_names, db_cache=None, username_db_map=None): - mon = SessionMonitor(enc_key, session_db, contact_names, db_cache, username_db_map) - wal_path = mon.wal_path - - # 初始全量解密 - pages, ms = full_decrypt(session_db, DECRYPTED_SESSION, enc_key) - wal_patched = 0 - wal_ms = 0 - if os.path.exists(wal_path): - wal_patched, wal_ms = decrypt_wal_full(wal_path, DECRYPTED_SESSION, enc_key) - print(f"[init] DB {pages}页/{ms:.0f}ms + WAL {wal_patched}页/{wal_ms:.0f}ms", flush=True) - else: - print(f"[init] DB {pages}页/{ms:.0f}ms", flush=True) - - mon.prev_state = mon.query_state() - print(f"[monitor] 跟踪 {len(mon.prev_state)} 个会话", flush=True) - print(f"[monitor] mtime轮询模式 (每{POLL_MS}ms)", flush=True) - - # mtime-based 轮询: WAL是预分配固定大小,不能用size检测 - poll_interval = POLL_MS / 1000 - prev_wal_mtime = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0 - prev_db_mtime = os.path.getmtime(session_db) - - while True: - time.sleep(poll_interval) - try: - # 用mtime检测WAL和DB变化 - try: - wal_mtime = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0 - db_mtime = os.path.getmtime(session_db) - except OSError: - continue - - if wal_mtime == prev_wal_mtime and db_mtime == prev_db_mtime: - continue # 无变化 - - t_detect = time.perf_counter() - wal_changed = wal_mtime != prev_wal_mtime - db_changed = db_mtime != prev_db_mtime - - mon.check_updates() - - t_done = time.perf_counter() - try: - detect_str = datetime.now().strftime('%H:%M:%S.%f')[:-3] - print(f" [{detect_str}] WAL={'变' if wal_changed else '-'} DB={'变' if db_changed else '-'} 总耗时={(t_done-t_detect)*1000:.1f}ms", flush=True) - except Exception: - pass - - prev_wal_mtime = wal_mtime - prev_db_mtime = db_mtime - - except Exception as e: - print(f"[poll] 错误: {e}", flush=True) - time.sleep(1) - - -# ============ Web ============ - -HTML_PAGE = ''' - - - - -微信消息监听 - - - -
-

WeChat Monitor

-
SSE 实时
-
0 消息
- -
-
-
-

通知设置

-
-
-

全局

-
-
-
-
-

规则

-
- -
-
-
- -
-
📡

等待新消息...

WAL增量解密 · SSE推送

-
- - -''' - - -class Handler(BaseHTTPRequestHandler): - def log_message(self, *a): pass - def handle(self): - try: - super().handle() - except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError, OSError): - pass # 浏览器关闭连接,正常 - - def do_GET(self): - if self.path in ('/', '/index.html'): - self.send_response(200) - self.send_header('Content-Type', 'text/html; charset=utf-8') - self.end_headers() - self.wfile.write(HTML_PAGE.encode('utf-8')) - - elif self.path.startswith('/api/history'): - parsed = urllib.parse.urlparse(self.path) - params = urllib.parse.parse_qs(parsed.query) - filter_chat = params.get('chat', [''])[0].strip().lower() - since_ts = 0 - try: - since_ts = int(params.get('since', ['0'])[0]) - except (ValueError, TypeError): - pass - limit_val = 500 - try: - limit_val = min(int(params.get('limit', ['500'])[0]), 2000) - except (ValueError, TypeError): - pass - - with messages_lock: - data = sorted(messages_log, key=lambda m: m.get('timestamp', 0)) - - if since_ts: - data = [m for m in data if m.get('timestamp', 0) > since_ts] - if filter_chat: - data = [m for m in data if filter_chat in m.get('chat', '').lower() - or filter_chat in m.get('username', '').lower()] - data = data[-limit_val:] - - self.send_response(200) - self.send_header('Content-Type', 'application/json; charset=utf-8') - self.end_headers() - self.wfile.write(json.dumps(data, ensure_ascii=False).encode('utf-8')) - - elif self.path.startswith('/img/'): - filename = urllib.parse.unquote(self.path[5:]) - # 安全: 防目录穿越 - if '/' in filename or '\\' in filename or '..' in filename: - self.send_error(403) - return - filepath = os.path.join(DECODED_IMAGE_DIR, filename) - if not os.path.isfile(filepath): - self.send_error(404) - return - ext = os.path.splitext(filename)[1].lower() - ct = { - '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', - '.png': 'image/png', '.gif': 'image/gif', - '.webp': 'image/webp', '.bmp': 'image/bmp', - '.tif': 'image/tiff', - }.get(ext, 'application/octet-stream') - with open(filepath, 'rb') as f: - data = f.read() - self.send_response(200) - self.send_header('Content-Type', ct) - self.send_header('Content-Length', str(len(data))) - self.send_header('Cache-Control', 'public, max-age=86400') - self.end_headers() - self.wfile.write(data) - - elif self.path.startswith('/api/tags'): - parsed = urllib.parse.urlparse(self.path) - params = urllib.parse.parse_qs(parsed.query) - name_filter = params.get('name', [''])[0].strip().lower() - - tags = load_contact_tags() - if name_filter: - tags = [t for t in tags if name_filter in t['name'].lower()] - - self.send_response(200) - self.send_header('Content-Type', 'application/json; charset=utf-8') - self.end_headers() - self.wfile.write(json.dumps(tags, ensure_ascii=False).encode('utf-8')) - - elif self.path == '/stream': - self.send_response(200) - self.send_header('Content-Type', 'text/event-stream') - self.send_header('Cache-Control', 'no-cache') - self.send_header('Connection', 'keep-alive') - self.end_headers() - - q = queue.Queue() - with sse_lock: - sse_clients.append(q) - try: - while True: - try: - payload = q.get(timeout=15) - self.wfile.write(payload.encode('utf-8')) - self.wfile.flush() - except queue.Empty: - self.wfile.write(b': hb\n\n') - self.wfile.flush() - except: - pass - finally: - with sse_lock: - if q in sse_clients: - sse_clients.remove(q) - else: - self.send_error(404) - - -class ThreadedServer(ThreadingMixIn, HTTPServer): - daemon_threads = True - allow_reuse_address = True - - -def main(): - print("=" * 60, flush=True) - print(" 微信实时监听 (WAL增量 + SSE推送)", flush=True) - print("=" * 60, flush=True) - - with open(KEYS_FILE, encoding="utf-8") as f: - keys = strip_key_metadata(json.load(f)) - - session_key_info = get_key_info(keys, os.path.join("session", "session.db")) - if not session_key_info: - print("[ERROR] 找不到 session.db 的密钥", flush=True) - sys.exit(1) - enc_key = bytes.fromhex(session_key_info["enc_key"]) - session_db = os.path.join(DB_DIR, "session", "session.db") - - print("加载联系人...", flush=True) - contact_names = load_contact_names() - print(f"已加载 {len(contact_names)} 个联系人", flush=True) - - print("构建 username→DB 映射...", flush=True) - username_db_map = build_username_db_map() - print(f"已映射 {len(username_db_map)} 个用户名", flush=True) - - # 启动时清理可能损坏的缓存 - if os.path.isdir(MONITOR_CACHE_DIR): - for f in os.listdir(MONITOR_CACHE_DIR): - fp = os.path.join(MONITOR_CACHE_DIR, f) - if f.endswith('.db'): - try: - c = sqlite3.connect(fp) - c.execute("SELECT 1 FROM sqlite_master LIMIT 1") - c.close() - except Exception: - try: - os.unlink(fp) - print(f"[cleanup] 删除损坏缓存: {f}", flush=True) - except PermissionError: - print(f"[cleanup] 缓存被占用跳过: {f}", flush=True) - - db_cache = MonitorDBCache(keys, MONITOR_CACHE_DIR) - - # 后台预热所有 message DB(图片/emoji 解密必需) - def _warmup(): - try: - t0 = time.perf_counter() - warmup_keys = [os.path.join("message", "message_resource.db")] - for i in range(5): - k = os.path.join("message", f"message_{i}.db") - if get_key_info(keys, k): - warmup_keys.append(k) - for k in warmup_keys: - t1 = time.perf_counter() - try: - db_cache.get(k) - print(f"[warmup] {k} {(time.perf_counter()-t1)*1000:.0f}ms", flush=True) - except Exception as e: - print(f"[warmup] {k} 失败: {e}", flush=True) - except Exception as e: - print(f"[warmup] 异常: {e}", flush=True) - # 构建 emoji 映射(独立解密,不走 cache) - _build_emoji_lookup(keys) - print(f"[warmup] 全部完成 {(time.perf_counter()-t0)*1000:.0f}ms", flush=True) - threading.Thread(target=_warmup, daemon=True).start() - - t = threading.Thread(target=monitor_thread, args=(enc_key, session_db, contact_names, db_cache, username_db_map), daemon=True) - t.start() - - server = ThreadedServer(('0.0.0.0', PORT), Handler) - print(f"\n=> http://localhost:{PORT}", flush=True) - print("Ctrl+C 停止\n", flush=True) - - try: - os.system(f'cmd.exe /c start http://localhost:{PORT}') - except Exception: - pass - - try: - server.serve_forever() - except KeyboardInterrupt: - print("\n已停止") - - -if __name__ == '__main__': - main() 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/requirements.txt b/requirements.txt deleted file mode 100644 index af9f591..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -pycryptodome>=3.19,<4 -zstandard>=0.22,<1 -mcp>=1.0,<2 diff --git a/src/cli/contacts.rs b/src/cli/contacts.rs new file mode 100644 index 0000000..c32ee3d --- /dev/null +++ b/src/cli/contacts.rs @@ -0,0 +1,28 @@ +use anyhow::Result; +use crate::ipc::Request; +use super::super::cli::transport; + +pub fn cmd_contacts(query: Option, limit: usize, json: bool) -> Result<()> { + let req = Request::Contacts { query, limit }; + let resp = transport::send(req)?; + + let contacts = resp.data.get("contacts") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + let total = resp.data["total"].as_i64().unwrap_or(contacts.len() as i64); + + if json { + println!("{}", serde_json::to_string_pretty(&contacts)?); + return Ok(()); + } + + println!("共 {} 个联系人(显示 {} 个):\n", total, contacts.len()); + for c in &contacts { + let display = c["display"].as_str().unwrap_or(""); + let username = c["username"].as_str().unwrap_or(""); + println!(" {:<20} {}", display, username); + } + + Ok(()) +} diff --git a/src/cli/daemon_cmd.rs b/src/cli/daemon_cmd.rs new file mode 100644 index 0000000..44e6660 --- /dev/null +++ b/src/cli/daemon_cmd.rs @@ -0,0 +1,99 @@ +use anyhow::Result; +use crate::config; +use crate::cli::DaemonCommands; +use crate::cli::transport; + +pub fn cmd_daemon(cmd: DaemonCommands) -> Result<()> { + match cmd { + DaemonCommands::Status => cmd_status(), + DaemonCommands::Stop => cmd_stop(), + DaemonCommands::Logs { follow, lines } => cmd_logs(follow, lines), + } +} + +fn cmd_status() -> Result<()> { + if transport::is_alive() { + let pid_path = config::pid_path(); + let pid = std::fs::read_to_string(&pid_path) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|_| "?".into()); + println!("wx-daemon 运行中 (PID {})", pid); + } else { + println!("wx-daemon 未运行"); + } + Ok(()) +} + +fn cmd_stop() -> Result<()> { + let pid_path = config::pid_path(); + if !pid_path.exists() { + println!("daemon 未运行"); + return Ok(()); + } + + let pid_str = std::fs::read_to_string(&pid_path)?; + let pid: u32 = pid_str.trim().parse() + .map_err(|_| anyhow::anyhow!("PID 文件格式错误"))?; + + #[cfg(unix)] + { + unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM); } + println!("已停止 wx-daemon (PID {})", pid); + } + + #[cfg(windows)] + { + std::process::Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/F"]) + .output()?; + println!("已停止 wx-daemon (PID {})", pid); + } + + let _ = std::fs::remove_file(config::sock_path()); + let _ = std::fs::remove_file(&pid_path); + + Ok(()) +} + +fn cmd_logs(follow: bool, lines: usize) -> Result<()> { + let log_path = config::log_path(); + if !log_path.exists() { + println!("暂无日志"); + return Ok(()); + } + + if follow { + #[cfg(unix)] + { + std::process::Command::new("tail") + .args([&format!("-{}", lines), "-f", &log_path.to_string_lossy()]) + .status()?; + } + #[cfg(windows)] + { + use std::io::{Read, Seek, SeekFrom}; + let mut file = std::fs::File::open(&log_path)?; + let len = file.seek(SeekFrom::End(0))?; + let start = len.saturating_sub((lines as u64) * 200); + file.seek(SeekFrom::Start(start))?; + let mut content = String::new(); + file.read_to_string(&mut content)?; + let all_lines: Vec<&str> = content.lines().collect(); + let show = &all_lines[all_lines.len().saturating_sub(lines)..]; + for line in show { println!("{}", line); } + loop { + std::thread::sleep(std::time::Duration::from_millis(500)); + let mut buf = String::new(); + file.read_to_string(&mut buf)?; + if !buf.is_empty() { print!("{}", buf); } + } + } + } else { + let content = std::fs::read_to_string(&log_path)?; + let all_lines: Vec<&str> = content.lines().collect(); + let show = &all_lines[all_lines.len().saturating_sub(lines)..]; + for line in show { println!("{}", line); } + } + + Ok(()) +} diff --git a/src/cli/export.rs b/src/cli/export.rs new file mode 100644 index 0000000..5490dd7 --- /dev/null +++ b/src/cli/export.rs @@ -0,0 +1,72 @@ +use anyhow::Result; +use crate::ipc::Request; +use super::super::cli::transport; +use super::history::{parse_time, parse_time_end}; + +pub fn cmd_export( + chat: String, + since: Option, + until: Option, + limit: usize, + format: String, + output: Option, +) -> Result<()> { + let since_ts = since.as_deref().map(parse_time).transpose()?; + let until_ts = until.as_deref().map(parse_time_end).transpose()?; + + let req = Request::History { + chat, + limit, + offset: 0, + since: since_ts, + until: until_ts, + }; + + let resp = transport::send(req)?; + let messages = resp.data["messages"].as_array().cloned().unwrap_or_default(); + let chat_name = resp.data["chat"].as_str().unwrap_or("").to_string(); + let is_group = resp.data["is_group"].as_bool().unwrap_or(false); + let count = messages.len(); + + let text = match format.as_str() { + "json" => serde_json::to_string_pretty(&resp.data)?, + "txt" => { + let group_str = if is_group { "[群]" } else { "" }; + let mut lines = vec![format!("=== {}{} ({} 条) ===\n", chat_name, group_str, count)]; + for m in &messages { + let time = m["time"].as_str().unwrap_or(""); + let sender = m["sender"].as_str().unwrap_or(""); + let content = m["content"].as_str().unwrap_or(""); + let sender_str = if !sender.is_empty() { format!("{}: ", sender) } else { String::new() }; + lines.push(format!("[{}] {}{}", time, sender_str, content)); + } + lines.join("\n") + } + _ => { + // markdown (default) + let group_str = if is_group { "(群聊)" } else { "" }; + let mut lines = vec![ + format!("# {}{}", chat_name, group_str), + format!("\n> 导出 {} 条消息\n", count), + ]; + for m in &messages { + let time = m["time"].as_str().unwrap_or(""); + let sender = m["sender"].as_str().unwrap_or(""); + let content = m["content"].as_str().unwrap_or("").replace('\n', "\n> "); + let sender_md = if !sender.is_empty() { format!("**{}**: ", sender) } else { String::new() }; + lines.push(format!("### {}\n\n{}{}\n", time, sender_md, content)); + } + lines.join("\n") + } + }; + + match output { + Some(path) => { + std::fs::write(&path, &text)?; + println!("已导出 {} 条消息到 {}", count, path); + } + None => println!("{}", text), + } + + Ok(()) +} diff --git a/src/cli/history.rs b/src/cli/history.rs new file mode 100644 index 0000000..3929a76 --- /dev/null +++ b/src/cli/history.rs @@ -0,0 +1,80 @@ +use anyhow::Result; +use crate::ipc::Request; +use super::super::cli::transport; + +pub fn cmd_history( + chat: String, + limit: usize, + offset: usize, + since: Option, + until: Option, + json: bool, +) -> Result<()> { + let since_ts = since.as_deref().map(parse_time).transpose()?; + let until_ts = until.as_deref().map(|s| parse_time_end(s)).transpose()?; + + let req = Request::History { + chat, + limit, + offset, + since: since_ts, + until: until_ts, + }; + + let resp = transport::send(req)?; + + if json { + let msgs = resp.data.get("messages").cloned().unwrap_or(serde_json::Value::Array(vec![])); + println!("{}", serde_json::to_string_pretty(&msgs)?); + return Ok(()); + } + + let chat_name = resp.data["chat"].as_str().unwrap_or(""); + let is_group = resp.data["is_group"].as_bool().unwrap_or(false); + let count = resp.data["count"].as_i64().unwrap_or(0); + let group_str = if is_group { " [群]" } else { "" }; + println!("=== {}{} ({} 条) ===\n", chat_name, group_str, count); + + if let Some(msgs) = resp.data["messages"].as_array() { + for m in msgs { + let time = m["time"].as_str().unwrap_or(""); + let sender = m["sender"].as_str().unwrap_or(""); + let content = m["content"].as_str().unwrap_or(""); + + let sender_str = if !sender.is_empty() { + format!("\x1b[33m{}\x1b[0m: ", sender) + } else { + String::new() + }; + + println!("\x1b[90m[{}]\x1b[0m {}{}", time, sender_str, content); + } + } + + Ok(()) +} + +pub fn parse_time(s: &str) -> Result { + for fmt in &["%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"] { + if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, fmt) { + return Ok(dt.and_utc().timestamp()); + } + // 尝试仅日期格式 + if let Ok(d) = chrono::NaiveDate::parse_from_str(s, fmt) { + let dt = d.and_hms_opt(0, 0, 0).unwrap(); + return Ok(dt.and_utc().timestamp()); + } + } + anyhow::bail!("无法解析时间 '{}',支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS", s) +} + +pub fn parse_time_end(s: &str) -> Result { + // 对于仅日期格式,结束时间为当天 23:59:59 + if s.len() == 10 { + if let Ok(d) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") { + let dt = d.and_hms_opt(23, 59, 59).unwrap(); + return Ok(dt.and_utc().timestamp()); + } + } + parse_time(s) +} diff --git a/src/cli/init.rs b/src/cli/init.rs new file mode 100644 index 0000000..b80b1ec --- /dev/null +++ b/src/cli/init.rs @@ -0,0 +1,96 @@ +use anyhow::{Context, Result}; +use serde_json::json; +use std::collections::HashMap; + +use crate::config; +use crate::scanner; + +pub fn cmd_init(force: bool) -> Result<()> { + // 查找 config.json + let config_path = find_or_create_config_path(); + + // 检查是否已初始化 + if !force && config_path.exists() { + if let Ok(content) = std::fs::read_to_string(&config_path) { + if let Ok(cfg) = serde_json::from_str::(&content) { + let db_dir = cfg.get("db_dir").and_then(|v| v.as_str()).unwrap_or(""); + let keys_file = cfg.get("keys_file").and_then(|v| v.as_str()).unwrap_or("all_keys.json"); + let keys_path = if std::path::Path::new(keys_file).is_absolute() { + std::path::PathBuf::from(keys_file) + } else { + config_path.parent().unwrap_or(std::path::Path::new(".")) + .join(keys_file) + }; + if !db_dir.is_empty() && !db_dir.contains("your_wxid") + && std::path::Path::new(db_dir).exists() + && keys_path.exists() + { + println!("已初始化,数据目录: {}", db_dir); + println!("如需重新扫描密钥,使用 --force"); + return Ok(()); + } + } + } + } + + // Step 1: 检测 db_dir + println!("检测微信数据目录..."); + let db_dir = config::auto_detect_db_dir() + .context("未能自动检测到微信数据目录\n请手动编辑 config.json 中的 db_dir 字段")?; + println!("找到数据目录: {}", db_dir.display()); + + // Step 2: 扫描密钥(需要 root/sudo) + println!("扫描加密密钥(需要 root 权限)..."); + let entries = scanner::scan_keys(&db_dir)?; + + // Step 3: 保存 all_keys.json + let keys_file_path = config_path.parent() + .unwrap_or(std::path::Path::new(".")) + .join("all_keys.json"); + + let mut keys_json = serde_json::Map::new(); + for entry in &entries { + keys_json.insert(entry.db_name.clone(), json!({ + "enc_key": entry.enc_key, + })); + } + std::fs::write(&keys_file_path, serde_json::to_string_pretty(&keys_json)?) + .context("写入 all_keys.json 失败")?; + println!("成功提取 {} 个数据库密钥", entries.len()); + println!("密钥已保存: {}", keys_file_path.display()); + + // Step 4: 保存 config.json + let mut cfg = HashMap::new(); + // 读取已有配置 + if config_path.exists() { + if let Ok(c) = std::fs::read_to_string(&config_path) { + if let Ok(v) = serde_json::from_str::>(&c) { + for (k, val) in v { + cfg.insert(k, val); + } + } + } + } + cfg.insert("db_dir".into(), json!(db_dir.to_string_lossy())); + cfg.entry("keys_file".into()).or_insert_with(|| json!("all_keys.json")); + cfg.entry("decrypted_dir".into()).or_insert_with(|| json!("decrypted")); + + std::fs::write(&config_path, serde_json::to_string_pretty(&cfg)?) + .context("写入 config.json 失败")?; + println!("配置已保存: {}", config_path.display()); + println!("初始化完成,可以使用 wx sessions / wx history 等命令了"); + + Ok(()) +} + +fn find_or_create_config_path() -> std::path::PathBuf { + // 优先使用可执行文件同目录 + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + return dir.join("config.json"); + } + } + std::env::current_dir() + .unwrap_or_default() + .join("config.json") +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..b5f45d3 --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,169 @@ +mod init; +pub mod sessions; +pub mod history; +pub mod search; +pub mod contacts; +pub mod export; +pub mod watch; +pub mod daemon_cmd; +pub mod transport; + +use anyhow::Result; +use clap::{Parser, Subcommand}; + +/// wx — 微信本地数据 CLI +#[derive(Parser)] +#[command(name = "wx", version = "0.1.0", about = "wx — 微信本地数据 CLI")] +pub struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// 初始化:检测数据目录并扫描加密密钥 + Init { + /// 强制重新扫描(覆盖已有配置) + #[arg(long)] + force: bool, + }, + /// 列出最近会话 + Sessions { + /// 会话数量 + #[arg(short = 'n', long, default_value = "20")] + limit: usize, + /// 输出原始 JSON + #[arg(long)] + json: bool, + }, + /// 查看聊天记录 + History { + /// 聊天对象名称(支持模糊匹配) + chat: String, + /// 消息数量 + #[arg(short = 'n', long, default_value = "50")] + limit: usize, + /// 分页偏移 + #[arg(long, default_value = "0")] + offset: usize, + /// 起始时间 YYYY-MM-DD + #[arg(long)] + since: Option, + /// 结束时间 YYYY-MM-DD + #[arg(long)] + until: Option, + /// 输出原始 JSON + #[arg(long)] + json: bool, + }, + /// 搜索消息 + Search { + /// 搜索关键词 + keyword: String, + /// 限定聊天(可多次指定) + #[arg(long = "in", value_name = "CHAT")] + chats: Vec, + /// 结果数量 + #[arg(short = 'n', long, default_value = "20")] + limit: usize, + /// 起始时间 YYYY-MM-DD + #[arg(long)] + since: Option, + /// 结束时间 YYYY-MM-DD + #[arg(long)] + until: Option, + /// 输出原始 JSON + #[arg(long)] + json: bool, + }, + /// 查看联系人 + Contacts { + /// 按名字过滤 + #[arg(short = 'q', long)] + query: Option, + /// 显示数量 + #[arg(short = 'n', long, default_value = "50")] + limit: usize, + /// 输出原始 JSON + #[arg(long)] + json: bool, + }, + /// 导出聊天记录到文件 + Export { + /// 聊天对象名称 + chat: String, + /// 起始时间 YYYY-MM-DD + #[arg(long)] + since: Option, + /// 结束时间 YYYY-MM-DD + #[arg(long)] + until: Option, + /// 最多导出条数 + #[arg(short = 'n', long, default_value = "500")] + limit: usize, + /// 输出格式 [markdown|txt|json] + #[arg(short = 'f', long, default_value = "markdown", value_parser = ["markdown", "txt", "json"])] + format: String, + /// 输出文件(默认 stdout) + #[arg(short = 'o', long)] + output: Option, + }, + /// 实时监听新消息(Ctrl+C 退出) + Watch { + /// 只显示指定聊天的消息 + #[arg(long)] + chat: Option, + /// 输出 JSON lines + #[arg(long)] + json: bool, + }, + /// 管理 wx-daemon + Daemon { + #[command(subcommand)] + cmd: DaemonCommands, + }, +} + +#[derive(Subcommand)] +pub enum DaemonCommands { + /// 查看 daemon 运行状态 + Status, + /// 停止 daemon + Stop, + /// 查看 daemon 日志 + Logs { + /// 持续输出(tail -f) + #[arg(short = 'f', long)] + follow: bool, + /// 显示最近 N 行 + #[arg(short = 'n', long, default_value = "50")] + lines: usize, + }, +} + +pub fn run() { + let cli = Cli::parse(); + if let Err(e) = dispatch(cli) { + eprintln!("错误: {}", e); + std::process::exit(1); + } +} + +fn dispatch(cli: Cli) -> Result<()> { + match cli.command { + Commands::Init { force } => init::cmd_init(force), + Commands::Sessions { limit, json } => sessions::cmd_sessions(limit, json), + Commands::History { chat, limit, offset, since, until, json } => { + history::cmd_history(chat, limit, offset, since, until, json) + } + Commands::Search { keyword, chats, limit, since, until, json } => { + search::cmd_search(keyword, chats, limit, since, until, json) + } + Commands::Contacts { query, limit, json } => contacts::cmd_contacts(query, limit, json), + Commands::Export { chat, since, until, limit, format, output } => { + export::cmd_export(chat, since, until, limit, format, output) + } + Commands::Watch { chat, json } => watch::cmd_watch(chat, json), + Commands::Daemon { cmd } => daemon_cmd::cmd_daemon(cmd), + } +} diff --git a/src/cli/search.rs b/src/cli/search.rs new file mode 100644 index 0000000..59955a8 --- /dev/null +++ b/src/cli/search.rs @@ -0,0 +1,61 @@ +use anyhow::Result; +use crate::ipc::Request; +use super::super::cli::transport; +use super::history::{parse_time, parse_time_end}; + +pub fn cmd_search( + keyword: String, + chats: Vec, + limit: usize, + since: Option, + until: Option, + json: bool, +) -> Result<()> { + let since_ts = since.as_deref().map(parse_time).transpose()?; + let until_ts = until.as_deref().map(parse_time_end).transpose()?; + + let chats_opt = if chats.is_empty() { None } else { Some(chats) }; + + let req = Request::Search { + keyword: keyword.clone(), + chats: chats_opt, + limit, + since: since_ts, + until: until_ts, + }; + + let resp = transport::send(req)?; + let results = resp.data.get("results") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + let count = resp.data["count"].as_i64().unwrap_or(results.len() as i64); + + if json { + println!("{}", serde_json::to_string_pretty(&results)?); + return Ok(()); + } + + println!("搜索 \"{}\",找到 {} 条:\n", keyword, count); + for r in &results { + let time = r["time"].as_str().unwrap_or(""); + let chat = r["chat"].as_str().unwrap_or(""); + let sender = r["sender"].as_str().unwrap_or(""); + let content = r["content"].as_str().unwrap_or(""); + + let chat_str = if !chat.is_empty() { + format!("\x1b[36m[{}]\x1b[0m ", chat) + } else { + String::new() + }; + let sender_str = if !sender.is_empty() { + format!("\x1b[33m{}\x1b[0m: ", sender) + } else { + String::new() + }; + + println!("\x1b[90m[{}]\x1b[0m {}{}{}", time, chat_str, sender_str, content); + } + + Ok(()) +} diff --git a/src/cli/sessions.rs b/src/cli/sessions.rs new file mode 100644 index 0000000..8721ad3 --- /dev/null +++ b/src/cli/sessions.rs @@ -0,0 +1,44 @@ +use anyhow::Result; +use crate::ipc::Request; +use super::super::cli::transport; + +pub fn cmd_sessions(limit: usize, json: bool) -> Result<()> { + let resp = transport::send(Request::Sessions { limit })?; + let data = resp.data.get("sessions") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + if json { + println!("{}", serde_json::to_string_pretty(&data)?); + return Ok(()); + } + + for s in &data { + let time = s["time"].as_str().unwrap_or(""); + let chat = s["chat"].as_str().unwrap_or(""); + let is_group = s["is_group"].as_bool().unwrap_or(false); + let unread = s["unread"].as_i64().unwrap_or(0); + let msg_type = s["last_msg_type"].as_str().unwrap_or(""); + let sender = s["last_sender"].as_str().unwrap_or(""); + let summary = s["summary"].as_str().unwrap_or(""); + + let unread_str = if unread > 0 { + format!(" \x1b[31m({}未读)\x1b[0m", unread) + } else { + String::new() + }; + let group_str = if is_group { " [群]" } else { "" }; + let sender_str = if !sender.is_empty() { + format!("{}: ", sender) + } else { + String::new() + }; + + println!("\x1b[90m[{}]\x1b[0m \x1b[1m{}\x1b[0m{}{}", time, chat, group_str, unread_str); + println!(" {}: {}{}", msg_type, sender_str, summary); + println!(); + } + + Ok(()) +} diff --git a/src/cli/transport.rs b/src/cli/transport.rs new file mode 100644 index 0000000..9d94627 --- /dev/null +++ b/src/cli/transport.rs @@ -0,0 +1,176 @@ +use anyhow::{bail, Context, Result}; +use std::io::{BufRead, BufReader, Write}; +use std::time::Duration; + +use crate::config; +use crate::ipc::{Request, Response}; + +const STARTUP_TIMEOUT_SECS: u64 = 15; + +/// 检查 daemon 是否存活 +pub fn is_alive() -> bool { + #[cfg(unix)] + { + use std::os::unix::net::UnixStream; + let sock_path = config::sock_path(); + if !sock_path.exists() { + return false; + } + let mut stream = match UnixStream::connect(&sock_path) { + Ok(s) => s, + Err(_) => return false, + }; + stream.set_read_timeout(Some(Duration::from_secs(2))).ok(); + stream.set_write_timeout(Some(Duration::from_secs(2))).ok(); + + let req = serde_json::json!({"cmd": "ping"}); + if write!(stream, "{}\n", req).is_err() { + return false; + } + let mut line = String::new(); + let mut reader = BufReader::new(&stream); + if reader.read_line(&mut line).is_err() { + return false; + } + serde_json::from_str::(&line) + .ok() + .and_then(|v| v.get("pong").and_then(|p| p.as_bool())) + .unwrap_or(false) + } + #[cfg(windows)] + { + // 通过 named pipe 检测 + let pipe_path = r"\\.\pipe\wechat-cli-daemon"; + use std::fs::OpenOptions; + OpenOptions::new().read(true).write(true).open(pipe_path).is_ok() + } + #[cfg(not(any(unix, windows)))] + { + false + } +} + +/// 确保 daemon 运行,必要时自动启动 +pub fn ensure_daemon() -> Result<()> { + if is_alive() { + return Ok(()); + } + eprintln!("启动 wx-daemon..."); + start_daemon()?; + Ok(()) +} + +/// 启动 daemon 进程(自身二进制,设置 WX_DAEMON_MODE=1) +fn start_daemon() -> Result<()> { + let exe = std::env::current_exe().context("无法获取当前可执行文件路径")?; + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + let mut cmd = std::process::Command::new(&exe); + cmd.env("WX_DAEMON_MODE", "1") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + // SAFETY: setsid() 在 fork 后的子进程中调用,使 daemon 脱离控制终端 + unsafe { cmd.pre_exec(|| { libc::setsid(); Ok(()) }); } + let _ = cmd.spawn().context("无法启动 daemon 进程")?; + } + + #[cfg(windows)] + { + let _ = std::process::Command::new(&exe) + .env("WX_DAEMON_MODE", "1") + .creation_flags(0x00000008) // DETACHED_PROCESS + .spawn() + .context("无法启动 daemon 进程")?; + } + + // 等待 daemon 就绪(最多 STARTUP_TIMEOUT_SECS 秒) + let deadline = std::time::Instant::now() + Duration::from_secs(STARTUP_TIMEOUT_SECS); + while std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(300)); + if is_alive() { + return Ok(()); + } + } + + bail!( + "wx-daemon 启动超时(>{}s)\n请查看日志: {}", + STARTUP_TIMEOUT_SECS, + config::log_path().display() + ) +} + +/// 向 daemon 发送请求并返回响应 +pub fn send(req: Request) -> Result { + ensure_daemon()?; + + #[cfg(unix)] + { + send_unix(req) + } + #[cfg(windows)] + { + send_windows(req) + } + #[cfg(not(any(unix, windows)))] + { + bail!("不支持当前平台") + } +} + +#[cfg(unix)] +fn send_unix(req: Request) -> Result { + use std::os::unix::net::UnixStream; + let sock_path = config::sock_path(); + let mut stream = UnixStream::connect(&sock_path) + .context("连接 daemon socket 失败")?; + stream.set_read_timeout(Some(Duration::from_secs(30))).ok(); + stream.set_write_timeout(Some(Duration::from_secs(30))).ok(); + + let req_str = serde_json::to_string(&req)? + "\n"; + stream.write_all(req_str.as_bytes())?; + + let mut line = String::new(); + let mut reader = BufReader::new(&stream); + reader.read_line(&mut line)?; + + let resp: Response = serde_json::from_str(&line) + .context("解析 daemon 响应失败")?; + + if !resp.ok { + bail!("{}", resp.error.as_deref().unwrap_or("未知错误")); + } + + Ok(resp) +} + +#[cfg(windows)] +fn send_windows(req: Request) -> Result { + use std::fs::OpenOptions; + use std::os::windows::fs::OpenOptionsExt; + + let pipe_path = r"\\.\pipe\wechat-cli-daemon"; + let mut pipe = OpenOptions::new() + .read(true) + .write(true) + .open(pipe_path) + .context("连接 daemon named pipe 失败")?; + + let req_str = serde_json::to_string(&req)? + "\n"; + pipe.write_all(req_str.as_bytes())?; + + let mut line = String::new(); + let mut reader = BufReader::new(pipe); + reader.read_line(&mut line)?; + + let resp: Response = serde_json::from_str(&line) + .context("解析 daemon 响应失败")?; + + if !resp.ok { + bail!("{}", resp.error.as_deref().unwrap_or("未知错误")); + } + + Ok(resp) +} diff --git a/src/cli/watch.rs b/src/cli/watch.rs new file mode 100644 index 0000000..ed54fa1 --- /dev/null +++ b/src/cli/watch.rs @@ -0,0 +1,94 @@ +use anyhow::Result; +use std::io::BufRead; + +use crate::ipc::Request; +use super::super::cli::transport; + +pub fn cmd_watch(chat: Option, json: bool) -> Result<()> { + transport::ensure_daemon()?; + + let sock_path = crate::config::sock_path(); + + // 连接 socket + #[cfg(unix)] + let mut stream = { + use std::os::unix::net::UnixStream; + UnixStream::connect(&sock_path)? + }; + + // 发送 watch 请求 + let req_line = serde_json::to_string(&Request::Watch)? + "\n"; + #[cfg(unix)] + { + use std::io::Write; + stream.write_all(req_line.as_bytes())?; + } + + if !json { + eprintln!("监听中(Ctrl+C 退出)...\n"); + } + + #[cfg(windows)] + { + anyhow::bail!("watch 命令在 Windows 上暂不支持,请使用 Unix 系统"); + } + + #[cfg(unix)] + { + let reader = std::io::BufReader::new(stream.try_clone()?); + for line_result in reader.lines() { + let line = match line_result { + Ok(l) => l, + Err(_) => break, + }; + let line = line.trim().to_string(); + if line.is_empty() { + continue; + } + let event: serde_json::Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(_) => continue, + }; + + let evt = event["event"].as_str().unwrap_or(""); + if evt == "connected" || evt == "heartbeat" { + continue; + } + + // 过滤指定聊天 + if let Some(ref filter_chat) = chat { + let event_chat = event["chat"].as_str().unwrap_or(""); + let event_user = event["username"].as_str().unwrap_or(""); + if event_chat != filter_chat && event_user != filter_chat { + continue; + } + } + + if json { + println!("{}", line); + continue; + } + + let time_s = event["time"].as_str().unwrap_or(""); + let chat_s = event["chat"].as_str().unwrap_or(""); + let is_group = event["is_group"].as_bool().unwrap_or(false); + let sender = event["sender"].as_str().unwrap_or(""); + let content = event["content"].as_str().unwrap_or(""); + + let chat_part = if is_group { + format!("\x1b[36m[{}]\x1b[0m ", chat_s) + } else { + format!("\x1b[1m{}\x1b[0m ", chat_s) + }; + let sender_part = if !sender.is_empty() { + format!("\x1b[33m{}\x1b[0m: ", sender) + } else { + String::new() + }; + + println!("\x1b[90m[{}]\x1b[0m {}{}{}", time_s, chat_part, sender_part, content); + } + } + + Ok(()) +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..2e447a6 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,274 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub db_dir: PathBuf, + pub keys_file: PathBuf, + pub decrypted_dir: PathBuf, + #[serde(default)] + pub wechat_process: String, +} + +/// 从 /config.json 或 $HOME/.wechat-cli/config.json 加载配置 +pub fn load_config() -> Result { + let config_path = find_config_file()?; + let content = std::fs::read_to_string(&config_path) + .with_context(|| format!("读取 config.json 失败: {}", config_path.display()))?; + let raw: serde_json::Value = serde_json::from_str(&content) + .with_context(|| "config.json 格式错误")?; + + let db_dir = raw.get("db_dir") + .and_then(|v| v.as_str()) + .map(PathBuf::from) + .unwrap_or_else(default_db_dir); + + let base_dir = config_path.parent().unwrap_or(Path::new(".")); + + let keys_file = raw.get("keys_file") + .and_then(|v| v.as_str()) + .map(|s| { + let p = PathBuf::from(s); + if p.is_absolute() { p } else { base_dir.join(p) } + }) + .unwrap_or_else(|| base_dir.join("all_keys.json")); + + let decrypted_dir = raw.get("decrypted_dir") + .and_then(|v| v.as_str()) + .map(|s| { + let p = PathBuf::from(s); + if p.is_absolute() { p } else { base_dir.join(p) } + }) + .unwrap_or_else(|| base_dir.join("decrypted")); + + let wechat_process = raw.get("wechat_process") + .and_then(|v| v.as_str()) + .unwrap_or(default_wechat_process()) + .to_string(); + + Ok(Config { + db_dir, + keys_file, + decrypted_dir, + wechat_process, + }) +} + +/// 保存配置到文件 +pub fn save_config(config: &Config) -> Result<()> { + let config_path = find_config_file().unwrap_or_else(|_| { + std::env::current_exe() + .unwrap_or_default() + .parent() + .unwrap_or(Path::new(".")) + .join("config.json") + }); + let content = serde_json::to_string_pretty(config)?; + std::fs::write(&config_path, content) + .with_context(|| format!("写入 config.json 失败: {}", config_path.display()))?; + Ok(()) +} + +fn find_config_file() -> Result { + // 1. 优先查找可执行文件同目录 + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let p = dir.join("config.json"); + if p.exists() { + return Ok(p); + } + } + } + // 2. 当前工作目录 + let cwd = std::env::current_dir().unwrap_or_default().join("config.json"); + if cwd.exists() { + return Ok(cwd); + } + // 3. ~/.wechat-cli/config.json + if let Some(home) = dirs::home_dir() { + let p = home.join(".wechat-cli").join("config.json"); + if p.exists() { + return Ok(p); + } + } + // 返回默认路径(可能不存在,调用方负责处理) + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + return Ok(dir.join("config.json")); + } + } + Ok(PathBuf::from("config.json")) +} + +pub fn cli_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join(".wechat-cli") +} + +pub fn sock_path() -> PathBuf { + cli_dir().join("daemon.sock") +} + +pub fn pid_path() -> PathBuf { + cli_dir().join("daemon.pid") +} + +pub fn log_path() -> PathBuf { + cli_dir().join("daemon.log") +} + +pub fn cache_dir() -> PathBuf { + cli_dir().join("cache") +} + +pub fn mtime_file() -> PathBuf { + cache_dir().join("_mtimes.json") +} + +fn default_db_dir() -> PathBuf { + #[cfg(target_os = "macos")] + { + dirs::home_dir() + .unwrap_or_default() + .join("Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files") + } + #[cfg(target_os = "linux")] + { + dirs::home_dir() + .unwrap_or_default() + .join("Documents/xwechat_files") + } + #[cfg(target_os = "windows")] + { + PathBuf::from(std::env::var("APPDATA").unwrap_or_default()) + .join("Tencent/xwechat") + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + PathBuf::from(".") + } +} + +fn default_wechat_process() -> &'static str { + #[cfg(target_os = "macos")] + { "WeChat" } + #[cfg(target_os = "linux")] + { "wechat" } + #[cfg(target_os = "windows")] + { "Weixin.exe" } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { "WeChat" } +} + +/// 自动检测微信 db_storage 目录 +pub fn auto_detect_db_dir() -> Option { + detect_db_dir_impl() +} + +#[cfg(target_os = "macos")] +fn detect_db_dir_impl() -> Option { + let home = dirs::home_dir()?; + // 支持 sudo 环境 + let home = if let Ok(sudo_user) = std::env::var("SUDO_USER") { + if !sudo_user.is_empty() { + PathBuf::from("/Users").join(&sudo_user) + } else { + home + } + } else { + home + }; + + let base = home.join("Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files"); + if !base.exists() { + return None; + } + let mut candidates: Vec = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&base) { + for entry in entries.flatten() { + let storage = entry.path().join("db_storage"); + if storage.is_dir() { + candidates.push(storage); + } + } + } + candidates.sort_by_key(|p| { + std::fs::metadata(p) + .and_then(|m| m.modified()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH) + }); + candidates.into_iter().next_back() +} + +#[cfg(target_os = "linux")] +fn detect_db_dir_impl() -> Option { + let home = dirs::home_dir()?; + let sudo_home = std::env::var("SUDO_USER").ok() + .filter(|s| !s.is_empty()) + .map(|u| PathBuf::from("/home").join(u)); + + let mut candidates: Vec = Vec::new(); + for base_home in [Some(home.clone()), sudo_home].into_iter().flatten() { + let xwechat = base_home.join("Documents/xwechat_files"); + if xwechat.exists() { + if let Ok(entries) = std::fs::read_dir(&xwechat) { + for entry in entries.flatten() { + let storage = entry.path().join("db_storage"); + if storage.is_dir() { + candidates.push(storage); + } + } + } + } + let old = base_home.join(".local/share/weixin/data/db_storage"); + if old.is_dir() { + candidates.push(old); + } + } + candidates.sort_by_key(|p| { + std::fs::metadata(p) + .and_then(|m| m.modified()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH) + }); + candidates.into_iter().next_back() +} + +#[cfg(target_os = "windows")] +fn detect_db_dir_impl() -> Option { + let appdata = std::env::var("APPDATA").ok()?; + let config_dir = PathBuf::from(&appdata).join("Tencent/xwechat/config"); + if !config_dir.exists() { + return None; + } + let mut candidates: Vec = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&config_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().map(|e| e == "ini").unwrap_or(false) { + if let Ok(content) = std::fs::read_to_string(&path) { + let data_root = content.trim().to_string(); + if PathBuf::from(&data_root).is_dir() { + let pattern = PathBuf::from(&data_root) + .join("xwechat_files"); + if let Ok(entries2) = std::fs::read_dir(&pattern) { + for entry2 in entries2.flatten() { + let storage = entry2.path().join("db_storage"); + if storage.is_dir() { + candidates.push(storage); + } + } + } + } + } + } + } + } + candidates.into_iter().next() +} + +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] +fn detect_db_dir_impl() -> Option { + None +} diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs new file mode 100644 index 0000000..1c09f4d --- /dev/null +++ b/src/crypto/mod.rs @@ -0,0 +1,106 @@ +pub mod wal; + +use anyhow::{bail, Result}; +use aes::Aes256; +use cbc::Decryptor; +use cbc::cipher::{BlockDecryptMut, KeyIvInit}; +use std::path::Path; + +pub const PAGE_SZ: usize = 4096; +pub const SALT_SZ: usize = 16; +pub const RESERVE_SZ: usize = 80; // IV(16) + HMAC(64) + +/// SQLite 文件头魔数(16字节) +pub const SQLITE_HDR: &[u8] = b"SQLite format 3\x00"; + +type Aes256CbcDec = Decryptor; + +/// 解密单个 SQLCipher 4 页 +/// +/// - `enc_key`: 32字节 AES 密钥 +/// - `page_data`: 原始加密页面数据(PAGE_SZ 字节) +/// - `pgno`: 页码(从1开始) +/// +/// 返回解密后的完整页面(PAGE_SZ 字节) +pub fn decrypt_page(enc_key: &[u8; 32], page_data: &[u8], pgno: u32) -> Result> { + if page_data.len() < PAGE_SZ { + bail!("页面数据不足 {} 字节", PAGE_SZ); + } + + // IV 位于页面末尾 RESERVE_SZ 区域的前16字节 + let iv_offset = PAGE_SZ - RESERVE_SZ; + let iv: &[u8; 16] = page_data[iv_offset..iv_offset + 16] + .try_into() + .expect("IV 长度固定为 16"); + + let mut result = vec![0u8; PAGE_SZ]; + + if pgno == 1 { + // 第一页:跳过 salt(16字节),解密 [SALT_SZ..PAGE_SZ-RESERVE_SZ] + let enc = &page_data[SALT_SZ..PAGE_SZ - RESERVE_SZ]; + let dec = aes_cbc_decrypt(enc_key, iv, enc)?; + // 写入 SQLite 文件头 + result[..16].copy_from_slice(SQLITE_HDR); + // 写入解密数据(从第16字节开始) + result[16..PAGE_SZ - RESERVE_SZ].copy_from_slice(&dec); + // 末尾 RESERVE_SZ 字节补零 + // (已经是零,无需显式操作) + } else { + // 其他页:解密 [0..PAGE_SZ-RESERVE_SZ] + let enc = &page_data[..PAGE_SZ - RESERVE_SZ]; + let dec = aes_cbc_decrypt(enc_key, iv, enc)?; + result[..PAGE_SZ - RESERVE_SZ].copy_from_slice(&dec); + // 末尾 RESERVE_SZ 字节补零 + } + + Ok(result) +} + +/// AES-256-CBC 解密(不去除 padding,SQLCipher 不使用 PKCS#7 padding) +fn aes_cbc_decrypt(key: &[u8; 32], iv: &[u8; 16], data: &[u8]) -> Result> { + if data.is_empty() || data.len() % 16 != 0 { + bail!("密文长度不是 AES 块大小的倍数: {}", data.len()); + } + let mut buf = data.to_vec(); + // 使用 raw 模式不处理 padding + Aes256CbcDec::new(key.into(), iv.into()) + .decrypt_blocks_mut(unsafe { + std::slice::from_raw_parts_mut( + buf.as_mut_ptr() as *mut aes::cipher::Block, + buf.len() / 16, + ) + }); + Ok(buf) +} + +/// 完整解密一个 SQLCipher 数据库文件 +/// +/// 读取 `db_path`,按 PAGE_SZ 分页解密,写入 `out_path` +pub fn full_decrypt(db_path: &Path, out_path: &Path, enc_key: &[u8; 32]) -> Result<()> { + let data = std::fs::read(db_path)?; + if data.is_empty() { + bail!("数据库文件为空: {}", db_path.display()); + } + + if let Some(parent) = out_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let total_pages = (data.len() + PAGE_SZ - 1) / PAGE_SZ; + let mut out = Vec::with_capacity(data.len()); + + for pgno in 1..=total_pages { + let offset = (pgno - 1) * PAGE_SZ; + let end = std::cmp::min(offset + PAGE_SZ, data.len()); + let mut page = data[offset..end].to_vec(); + // 不足一页则补零 + if page.len() < PAGE_SZ { + page.resize(PAGE_SZ, 0); + } + let dec = decrypt_page(enc_key, &page, pgno as u32)?; + out.extend_from_slice(&dec); + } + + std::fs::write(out_path, &out)?; + Ok(()) +} diff --git a/src/crypto/wal.rs b/src/crypto/wal.rs new file mode 100644 index 0000000..4e3d85d --- /dev/null +++ b/src/crypto/wal.rs @@ -0,0 +1,73 @@ +use anyhow::Result; +use std::io::{SeekFrom, Seek, Write}; +use std::path::Path; + +use super::{decrypt_page, PAGE_SZ}; + +pub const WAL_HDR_SZ: usize = 32; +pub const WAL_FRAME_HDR: usize = 24; + +/// 将 WAL 文件中的变更应用到已解密的数据库文件 +/// +/// WAL 格式(SQLite 标准,SQLCipher 4 的 WAL 帧也被加密): +/// - WAL header (32 bytes): magic(4) + format(4) + page_sz(4) + ckpt_seq(4) + salt1(4) + salt2(4) + cksum1(4) + cksum2(4) +/// - 每帧:frame_header(24 bytes) + page_data(PAGE_SZ bytes) +/// - frame_header: pgno(4) + commit_pgcnt(4) + salt1(4) + salt2(4) + cksum1(4) + cksum2(4) +pub fn apply_wal(wal_path: &Path, out_path: &Path, enc_key: &[u8; 32]) -> Result<()> { + if !wal_path.exists() { + return Ok(()); + } + + let wal_data = std::fs::read(wal_path)?; + if wal_data.len() <= WAL_HDR_SZ { + return Ok(()); + } + + // 读取 WAL 头中的 salt1 / salt2 + let s1 = u32::from_be_bytes(wal_data[16..20].try_into().unwrap()); + let s2 = u32::from_be_bytes(wal_data[20..24].try_into().unwrap()); + + let frame_size = WAL_FRAME_HDR + PAGE_SZ; + let frame_area = &wal_data[WAL_HDR_SZ..]; + + // 打开输出文件做随机写 + let mut db_file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(out_path)?; + + let mut pos = 0usize; + while pos + frame_size <= frame_area.len() { + let fh = &frame_area[pos..pos + WAL_FRAME_HDR]; + let page_data = &frame_area[pos + WAL_FRAME_HDR..pos + frame_size]; + + let pgno = u32::from_be_bytes(fh[0..4].try_into().unwrap()); + let fs1 = u32::from_be_bytes(fh[8..12].try_into().unwrap()); + let fs2 = u32::from_be_bytes(fh[12..16].try_into().unwrap()); + + pos += frame_size; + + // 跳过无效页码 + if pgno == 0 || pgno > 1_000_000 { + continue; + } + // salt 不匹配的帧属于已检查点或旧事务 + if fs1 != s1 || fs2 != s2 { + continue; + } + + let mut page_buf = page_data.to_vec(); + if page_buf.len() < PAGE_SZ { + page_buf.resize(PAGE_SZ, 0); + } + + // WAL 帧中的页数据不含 SALT 头,所以对 pgno=1 的帧也用普通页解密路径 + // (区别于主数据库第一页需要跳过 SALT 并写入 SQLite 魔数) + let dec = decrypt_page(enc_key, &page_buf, if pgno == 1 { 2 } else { pgno })?; + let file_offset = (pgno as u64 - 1) * PAGE_SZ as u64; + db_file.seek(SeekFrom::Start(file_offset))?; + db_file.write_all(&dec)?; + } + + Ok(()) +} diff --git a/src/daemon/cache.rs b/src/daemon/cache.rs new file mode 100644 index 0000000..4b742f4 --- /dev/null +++ b/src/daemon/cache.rs @@ -0,0 +1,215 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::sync::Mutex; + +use crate::config; +use crate::crypto; +use crate::crypto::wal; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct MtimeEntry { + db_mt: u64, + wal_mt: u64, + path: String, +} + +#[derive(Debug, Clone)] +struct CacheEntry { + db_mtime: u64, + wal_mtime: u64, + decrypted_path: PathBuf, +} + +/// 解密后数据库的 mtime-aware 缓存 +/// +/// 当数据库文件(.db)或 WAL 文件(.db-wal)的 mtime 发生变化时, +/// 自动重新解密并更新缓存。跨进程重启可通过持久化 mtime 文件复用已解密的 DB。 +pub struct DbCache { + db_dir: PathBuf, + cache_dir: PathBuf, + all_keys: HashMap, // rel_key -> enc_key(hex) + inner: Arc>>, +} + +impl DbCache { + pub async fn new( + db_dir: PathBuf, + all_keys: HashMap, + ) -> Result { + let cache_dir = config::cache_dir(); + tokio::fs::create_dir_all(&cache_dir).await?; + + let inner: HashMap = HashMap::new(); + let cache = DbCache { + db_dir, + cache_dir, + all_keys, + inner: Arc::new(Mutex::new(inner)), + }; + + cache.load_persistent().await; + Ok(cache) + } + + fn cache_file_path(&self, rel_key: &str) -> PathBuf { + let hash = format!("{:x}", md5::compute(rel_key.as_bytes())); + let short = &hash[..12]; + self.cache_dir.join(format!("{}.db", short)) + } + + /// 从持久化文件加载 mtime 记录,复用未过期的解密文件 + async fn load_persistent(&self) { + let mtime_file = config::mtime_file(); + let content = match tokio::fs::read_to_string(&mtime_file).await { + Ok(c) => c, + Err(_) => return, + }; + let saved: HashMap = match serde_json::from_str(&content) { + Ok(v) => v, + Err(_) => return, + }; + + let mut inner = self.inner.lock().await; + let mut reused = 0usize; + for (rel_key, entry) in &saved { + let dec_path = PathBuf::from(&entry.path); + if !dec_path.exists() { + continue; + } + let db_path = self.db_dir.join(rel_key.replace('\\', std::path::MAIN_SEPARATOR_STR).replace('/', std::path::MAIN_SEPARATOR_STR)); + let wal_path_str = format!("{}-wal", db_path.display()); + let wal_path = Path::new(&wal_path_str); + + let db_mt = mtime_nanos(&db_path); + let wal_mt = if wal_path.exists() { mtime_nanos(wal_path) } else { 0 }; + + if db_mt == entry.db_mt && wal_mt == entry.wal_mt { + inner.insert(rel_key.clone(), CacheEntry { + db_mtime: db_mt, + wal_mtime: wal_mt, + decrypted_path: dec_path, + }); + reused += 1; + } + } + if reused > 0 { + eprintln!("[cache] 复用 {} 个已解密 DB", reused); + } + } + + /// 持久化 mtime 记录 + async fn save_persistent(&self) { + let mtime_file = config::mtime_file(); + let inner = self.inner.lock().await; + let data: HashMap = inner.iter().map(|(k, v)| { + (k.clone(), MtimeEntry { + db_mt: v.db_mtime, + wal_mt: v.wal_mtime, + path: v.decrypted_path.to_string_lossy().into_owned(), + }) + }).collect(); + drop(inner); + + if let Ok(json) = serde_json::to_string_pretty(&data) { + let _ = tokio::fs::write(&mtime_file, json).await; + } + } + + /// 获取解密后的数据库路径 + /// + /// 如果 mtime 未变,直接返回缓存路径;否则重新解密 + pub async fn get(&self, rel_key: &str) -> Result> { + let enc_key_hex = match self.all_keys.get(rel_key) { + Some(k) => k.clone(), + None => return Ok(None), + }; + + let db_path = self.db_dir.join( + rel_key.replace('\\', std::path::MAIN_SEPARATOR_STR) + .replace('/', std::path::MAIN_SEPARATOR_STR) + ); + if !db_path.exists() { + return Ok(None); + } + + let wal_path_str = format!("{}-wal", db_path.display()); + let wal_path = Path::new(&wal_path_str).to_path_buf(); + + let db_mt = mtime_nanos(&db_path); + let wal_mt = if wal_path.exists() { mtime_nanos(&wal_path) } else { 0 }; + + // 检查缓存 + { + let inner = self.inner.lock().await; + if let Some(entry) = inner.get(rel_key) { + if entry.db_mtime == db_mt + && entry.wal_mtime == wal_mt + && entry.decrypted_path.exists() + { + return Ok(Some(entry.decrypted_path.clone())); + } + } + } + + // 需要重新解密 + let out_path = self.cache_file_path(rel_key); + let enc_key_bytes = hex_to_32bytes(&enc_key_hex) + .with_context(|| format!("密钥格式错误: {}", rel_key))?; + + let t0 = std::time::Instant::now(); + let db_path2 = db_path.clone(); + let out_path2 = out_path.clone(); + let key_copy = enc_key_bytes; + tokio::task::spawn_blocking(move || { + crypto::full_decrypt(&db_path2, &out_path2, &key_copy) + }).await??; + + // 应用 WAL + if wal_path.exists() { + let out_path3 = out_path.clone(); + let wal_path3 = wal_path.clone(); + let key_copy2 = enc_key_bytes; + tokio::task::spawn_blocking(move || { + wal::apply_wal(&wal_path3, &out_path3, &key_copy2) + }).await??; + } + + let elapsed_ms = t0.elapsed().as_millis(); + eprintln!("[cache] 解密 {} ({}ms)", rel_key, elapsed_ms); + + // 更新内存缓存 + { + let mut inner = self.inner.lock().await; + inner.insert(rel_key.to_string(), CacheEntry { + db_mtime: db_mt, + wal_mtime: wal_mt, + decrypted_path: out_path.clone(), + }); + } + + self.save_persistent().await; + Ok(Some(out_path)) + } +} + +fn mtime_nanos(path: &Path) -> u64 { + std::fs::metadata(path) + .and_then(|m| m.modified()) + .map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos() as u64) + .unwrap_or(0) +} + +fn hex_to_32bytes(s: &str) -> Result<[u8; 32]> { + if s.len() != 64 { + anyhow::bail!("密钥 hex 长度应为 64,实际为 {}", s.len()); + } + let mut out = [0u8; 32]; + for i in 0..32 { + out[i] = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16) + .with_context(|| format!("非法 hex 字符 at {}", i * 2))?; + } + Ok(out) +} diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs new file mode 100644 index 0000000..3058a99 --- /dev/null +++ b/src/daemon/mod.rs @@ -0,0 +1,280 @@ +pub mod cache; +pub mod query; +pub mod server; + +use anyhow::Result; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::broadcast; + +use crate::config; + +/// daemon 入口 +/// +/// 当 WX_DAEMON_MODE 环境变量设置时,main() 调用此函数 +pub fn run() { + let rt = tokio::runtime::Runtime::new().expect("无法创建 tokio runtime"); + if let Err(e) = rt.block_on(async_run()) { + eprintln!("[daemon] 启动失败: {}", e); + std::process::exit(1); + } +} + +async fn async_run() -> Result<()> { + // 确保工作目录存在 + let cli_dir = config::cli_dir(); + tokio::fs::create_dir_all(&cli_dir).await?; + tokio::fs::create_dir_all(config::cache_dir()).await?; + + // 写 PID 文件 + let pid = std::process::id(); + tokio::fs::write(config::pid_path(), pid.to_string()).await?; + + // 注册 SIGTERM / SIGINT 处理 + setup_signal_handler().await; + + eprintln!("[daemon] wx-daemon 启动 (PID {})", pid); + + // 加载配置 + let cfg = config::load_config()?; + eprintln!("[daemon] DB_DIR: {}", cfg.db_dir.display()); + + // 加载密钥 + let keys_content = tokio::fs::read_to_string(&cfg.keys_file).await + .map_err(|e| anyhow::anyhow!("读取密钥文件 {:?} 失败: {}", cfg.keys_file, e))?; + let keys_raw: serde_json::Value = serde_json::from_str(&keys_content)?; + let all_keys = extract_keys(&keys_raw); + eprintln!("[daemon] 密钥数量: {}", all_keys.len()); + + // 初始化 DbCache + let db = Arc::new(cache::DbCache::new(cfg.db_dir.clone(), all_keys.clone()).await?); + + // 收集消息 DB 列表 + let msg_db_keys: Vec = all_keys.keys() + .filter(|k| { + let k = k.replace('\\', "/"); + k.contains("message/message_") && k.ends_with(".db") + }) + .cloned() + .collect(); + + // 预热:加载联系人 + 解密 session.db + eprintln!("[daemon] 预热..."); + let names_raw = query::load_names(&*db).await.unwrap_or_else(|e| { + eprintln!("[daemon] 加载联系人失败: {}", e); + query::Names { + map: HashMap::new(), + md5_to_uname: HashMap::new(), + msg_db_keys: Vec::new(), + } + }); + let mut names = names_raw; + names.msg_db_keys = msg_db_keys; + + let _ = db.get("session/session.db").await; + eprintln!("[daemon] 预热完成,联系人 {} 个", names.map.len()); + + let names_arc = Arc::new(std::sync::RwLock::new(names)); + + // 启动 WAL watcher + let (watch_tx, _) = broadcast::channel::(500); + let session_wal = cfg.db_dir.join("session").join("session.db-wal"); + + // SAFETY: 我们确保 db 和 names_arc 在 daemon 生命周期内有效 + // 使用 Arc 传递引用避免 'static 问题 + let db_arc = Arc::clone(&db); + let names_arc2 = Arc::clone(&names_arc); + let tx_clone = watch_tx.clone(); + let session_wal2 = session_wal.clone(); + tokio::spawn(async move { + run_watcher(db_arc, names_arc2, tx_clone, session_wal2).await; + }); + + // 启动 IPC server(阻塞) + server::serve(Arc::clone(&db), Arc::clone(&names_arc), watch_tx).await?; + + Ok(()) +} + +async fn run_watcher( + db: Arc, + names: Arc>, + tx: broadcast::Sender, + session_wal: PathBuf, +) { + use std::collections::HashMap; + use std::time::Duration; + use crate::ipc::WatchEvent; + + let mut last_mtime = 0u64; + let mut last_ts: HashMap = HashMap::new(); + let mut initialized = false; + + loop { + tokio::time::sleep(Duration::from_millis(500)).await; + + if tx.receiver_count() == 0 { + continue; + } + + let wal_mtime = match mtime_nanos(&session_wal) { + 0 => continue, + m => m, + }; + if wal_mtime == last_mtime { + continue; + } + last_mtime = wal_mtime; + + let path = match db.get("session/session.db").await { + Ok(Some(p)) => p, + _ => continue, + }; + + let path2 = path.clone(); + let rows: Vec<(String, Vec, i64, i64, String)> = match tokio::task::spawn_blocking(move || { + let conn = rusqlite::Connection::open(&path2)?; + let mut stmt = conn.prepare( + "SELECT username, summary, last_timestamp, last_msg_type, last_msg_sender + FROM SessionTable WHERE last_timestamp > 0 + ORDER BY last_timestamp DESC LIMIT 50" + )?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Vec>(1).unwrap_or_default(), + row.get::<_, i64>(2)?, + row.get::<_, i64>(3).unwrap_or(0), + row.get::<_, String>(4).unwrap_or_default(), + )) + })?.collect::>>()?; + Ok::<_, anyhow::Error>(rows) + }).await { + Ok(Ok(r)) => r, + _ => continue, + }; + + let names_guard = match names.read() { + Ok(g) => g, + Err(_) => continue, + }; + + for (username, summary_bytes, ts, msg_type, sender) in &rows { + if !initialized { + last_ts.insert(username.clone(), *ts); + continue; + } + let prev_ts = last_ts.get(username).copied().unwrap_or(0); + if *ts <= prev_ts { + continue; + } + last_ts.insert(username.clone(), *ts); + + let display = names_guard.display(username); + let is_group = username.contains("@chatroom"); + let summary = decompress_or_str(summary_bytes); + let summary = if summary.contains(":\n") { + summary.splitn(2, ":\n").nth(1).unwrap_or(&summary).to_string() + } else { + summary + }; + let sender_display = if !sender.is_empty() { + names_guard.map.get(sender).cloned().unwrap_or_else(|| sender.clone()) + } else { + String::new() + }; + + let event = WatchEvent { + event: "message".into(), + time: Some(fmt_hhmm(*ts)), + chat: Some(display), + username: Some(username.clone()), + is_group: Some(is_group), + sender: Some(sender_display), + content: Some(summary), + msg_type: Some(query::fmt_type(*msg_type)), + timestamp: Some(*ts), + }; + let _ = tx.send(event); + } + + if !initialized { + initialized = true; + } + } +} + +fn mtime_nanos(path: &std::path::Path) -> u64 { + std::fs::metadata(path) + .and_then(|m| m.modified()) + .map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos() as u64) + .unwrap_or(0) +} + +fn decompress_or_str(data: &[u8]) -> String { + if data.is_empty() { return String::new(); } + if let Ok(dec) = zstd::decode_all(data) { + if let Ok(s) = String::from_utf8(dec) { return s; } + } + String::from_utf8_lossy(data).into_owned() +} + +fn fmt_hhmm(ts: i64) -> String { + use chrono::{Local, TimeZone}; + Local.timestamp_opt(ts, 0) + .single() + .map(|dt| dt.format("%H:%M").to_string()) + .unwrap_or_else(|| ts.to_string()) +} + +/// 从 all_keys.json 提取 rel_key -> enc_key 映射 +/// +/// 兼容两种格式: +/// - `{ "rel/path.db": { "enc_key": "hex" } }`(Python 版原生格式) +/// - `{ "rel/path.db": "hex" }`(简化格式) +fn extract_keys(json: &serde_json::Value) -> HashMap { + let mut result = HashMap::new(); + if let Some(obj) = json.as_object() { + for (k, v) in obj { + if k.starts_with('_') { continue; } + let enc_key = if let Some(s) = v.as_str() { + s.to_string() + } else if let Some(obj2) = v.as_object() { + obj2.get("enc_key") + .and_then(|e| e.as_str()) + .unwrap_or_default() + .to_string() + } else { + continue; + }; + if !enc_key.is_empty() { + // 统一路径分隔符 + let rel = k.replace('\\', "/"); + result.insert(rel, enc_key); + } + } + } + result +} + +/// 设置信号处理(Unix: SIGTERM/SIGINT) +async fn setup_signal_handler() { + #[cfg(unix)] + tokio::spawn(async move { + use tokio::signal::unix::{signal, SignalKind}; + let mut term = signal(SignalKind::terminate()).expect("无法监听 SIGTERM"); + let mut int = signal(SignalKind::interrupt()).expect("无法监听 SIGINT"); + tokio::select! { + _ = term.recv() => {}, + _ = int.recv() => {}, + } + cleanup_and_exit(); + }); +} + +fn cleanup_and_exit() { + let _ = std::fs::remove_file(config::sock_path()); + let _ = std::fs::remove_file(config::pid_path()); + std::process::exit(0); +} diff --git a/src/daemon/query.rs b/src/daemon/query.rs new file mode 100644 index 0000000..e8706f6 --- /dev/null +++ b/src/daemon/query.rs @@ -0,0 +1,677 @@ +use anyhow::{Context, Result}; +use chrono::{Local, TimeZone}; +use regex::Regex; +use rusqlite::Connection; +use serde_json::{json, Value}; +use std::collections::HashMap; + +use super::cache::DbCache; + +/// 联系人名称缓存 +#[derive(Clone)] +pub struct Names { + /// username -> display_name + pub map: HashMap, + /// md5(username) -> username(用于从 Msg_ 表名反推联系人) + pub md5_to_uname: HashMap, + /// 消息 DB 的相对路径列表(message/message_N.db) + pub msg_db_keys: Vec, +} + +impl Names { + pub fn display(&self, username: &str) -> String { + self.map.get(username).cloned().unwrap_or_else(|| username.to_string()) + } +} + +/// 加载联系人缓存(从 contact/contact.db) +pub async fn load_names(db: &DbCache) -> Result { + let path = db.get("contact/contact.db").await?; + let mut map = HashMap::new(); + if let Some(p) = path { + let p2 = p.clone(); + let rows: Vec<(String, String, String)> = tokio::task::spawn_blocking(move || { + let conn = Connection::open(&p2).context("打开 contact.db 失败")?; + let mut stmt = conn.prepare( + "SELECT username, nick_name, remark FROM contact" + )?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1).unwrap_or_default(), + row.get::<_, String>(2).unwrap_or_default(), + )) + })? + .collect::>>()?; + Ok::<_, anyhow::Error>(rows) + }).await??; + + for (uname, nick, remark) in rows { + let display = if !remark.is_empty() { remark } + else if !nick.is_empty() { nick } + else { uname.clone() }; + map.insert(uname, display); + } + } + + let md5_to_uname: HashMap = map.keys() + .map(|u| (format!("{:x}", md5::compute(u.as_bytes())), u.clone())) + .collect(); + + Ok(Names { map, md5_to_uname, msg_db_keys: Vec::new() }) +} + +/// 查询最近会话列表 +pub async fn q_sessions(db: &DbCache, names: &Names, limit: usize) -> Result { + let path = db.get("session/session.db").await? + .context("无法解密 session.db")?; + + let path2 = path.clone(); + let limit_val = limit; + let rows: Vec<(String, i64, Vec, i64, i64, String, String)> = tokio::task::spawn_blocking(move || { + let conn = Connection::open(&path2)?; + let mut stmt = conn.prepare( + "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 ?" + )?; + let rows = stmt.query_map([limit_val as i64], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1).unwrap_or(0), + row.get::<_, Vec>(2).unwrap_or_default(), + row.get::<_, i64>(3).unwrap_or(0), + row.get::<_, i64>(4).unwrap_or(0), + row.get::<_, String>(5).unwrap_or_default(), + row.get::<_, String>(6).unwrap_or_default(), + )) + })? + .collect::>>()?; + Ok::<_, anyhow::Error>(rows) + }).await??; + + let mut results = Vec::new(); + for (username, unread, summary_bytes, ts, msg_type, sender, sender_name) in rows { + let display = names.display(&username); + let is_group = username.contains("@chatroom"); + + // 尝试 zstd 解压 summary + let summary = decompress_or_str(&summary_bytes); + let summary = strip_group_prefix(&summary); + + let sender_display = if is_group && !sender.is_empty() { + names.map.get(&sender).cloned().unwrap_or_else(|| { + if !sender_name.is_empty() { sender_name.clone() } else { sender.clone() } + }) + } else { + String::new() + }; + + results.push(json!({ + "chat": display, + "username": username, + "is_group": is_group, + "unread": unread, + "last_msg_type": fmt_type(msg_type), + "last_sender": sender_display, + "summary": summary, + "timestamp": ts, + "time": fmt_time(ts, "%m-%d %H:%M"), + })); + } + Ok(json!({ "sessions": results })) +} + +/// 查询聊天记录 +pub async fn q_history( + db: &DbCache, + names: &Names, + chat: &str, + limit: usize, + offset: usize, + since: Option, + until: Option, +) -> Result { + let username = resolve_username(chat, names) + .with_context(|| format!("找不到联系人: {}", chat))?; + let display = names.display(&username); + let is_group = username.contains("@chatroom"); + + let tables = find_msg_tables(db, names, &username).await?; + if tables.is_empty() { + return Ok(json!({ "error": format!("找不到 {} 的消息记录", display) })); + } + + let mut all_msgs: Vec = Vec::new(); + for (db_path, table_name) in &tables { + let path = db_path.clone(); + let tname = table_name.clone(); + let uname = username.clone(); + let is_group2 = is_group; + let names_map = names.map.clone(); + let since2 = since; + let until2 = until; + let limit2 = limit; + let offset2 = offset; + + let msgs: Vec = tokio::task::spawn_blocking(move || { + query_messages(&path, &tname, &uname, is_group2, &names_map, since2, until2, limit2 + offset2, 0) + }).await??; + + all_msgs.extend(msgs); + } + + all_msgs.sort_by_key(|m| std::cmp::Reverse(m["timestamp"].as_i64().unwrap_or(0))); + let paged: Vec = all_msgs.into_iter().skip(offset).take(limit).collect(); + let mut paged = paged; + paged.sort_by_key(|m| m["timestamp"].as_i64().unwrap_or(0)); + + Ok(json!({ + "chat": display, + "username": username, + "is_group": is_group, + "count": paged.len(), + "messages": paged, + })) +} + +/// 搜索消息 +pub async fn q_search( + db: &DbCache, + names: &Names, + keyword: &str, + chats: Option>, + limit: usize, + since: Option, + until: Option, +) -> Result { + let mut targets: Vec<(String, String, String, String)> = Vec::new(); // (path, table, display, uname) + + if let Some(chat_names) = chats { + for chat_name in &chat_names { + if let Some(uname) = resolve_username(chat_name, names) { + let tables = find_msg_tables(db, names, &uname).await?; + for (p, t) in tables { + targets.push((p.to_string_lossy().into_owned(), t, names.display(&uname), uname.clone())); + } + } + } + } else { + // 全局搜索:遍历所有消息 DB + for rel_key in &names.msg_db_keys { + let path = match db.get(rel_key).await? { + Some(p) => p, + None => continue, + }; + let path2 = path.clone(); + let md5_lookup = names.md5_to_uname.clone(); + let names_map = names.map.clone(); + + let table_targets: Vec<(String, String, String, String)> = tokio::task::spawn_blocking(move || { + let conn = Connection::open(&path2)?; + let mut stmt = conn.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'" + )?; + let table_names: Vec = stmt.query_map([], |row| row.get(0))? + .filter_map(|r| r.ok()) + .collect(); + + let re = Regex::new(r"^Msg_[0-9a-f]{32}$").unwrap(); + let mut result = Vec::new(); + for tname in table_names { + if !re.is_match(&tname) { + continue; + } + let hash = &tname[4..]; + let uname = md5_lookup.get(hash).cloned().unwrap_or_default(); + let display = if uname.is_empty() { + String::new() + } else { + names_map.get(&uname).cloned().unwrap_or_else(|| uname.clone()) + }; + result.push(( + path2.to_string_lossy().into_owned(), + tname, + display, + uname, + )); + } + Ok::<_, anyhow::Error>(result) + }).await??; + + targets.extend(table_targets); + } + } + + // 按 db_path 分组 + let mut by_path: HashMap> = HashMap::new(); + for (p, t, d, u) in targets { + by_path.entry(p).or_default().push((t, d, u)); + } + + let mut results: Vec = Vec::new(); + let kw = keyword.to_string(); + for (db_path, table_list) in by_path { + let kw2 = kw.clone(); + let since2 = since; + let until2 = until; + let limit2 = limit * 3; + + let names_map2 = names.map.clone(); + let found: Vec = tokio::task::spawn_blocking(move || { + let conn = Connection::open(&db_path)?; + let mut all = Vec::new(); + for (tname, display, uname) in &table_list { + let is_group = uname.contains("@chatroom"); + let rows = search_in_table(&conn, tname, &uname, is_group, + &names_map2, &kw2, since2, until2, limit2)?; + for mut row in rows { + if row.get("chat").map(|v| v.as_str().unwrap_or("")).unwrap_or("").is_empty() { + if let Some(obj) = row.as_object_mut() { + obj.insert("chat".into(), serde_json::Value::String( + if display.is_empty() { tname.clone() } else { display.clone() } + )); + } + } + all.push(row); + } + } + Ok::<_, anyhow::Error>(all) + }).await??; + + results.extend(found); + } + + results.sort_by_key(|r| std::cmp::Reverse(r["timestamp"].as_i64().unwrap_or(0))); + let paged: Vec = results.into_iter().take(limit).collect(); + Ok(json!({ "keyword": keyword, "count": paged.len(), "results": paged })) +} + +/// 查询联系人 +pub async fn q_contacts(names: &Names, query: Option<&str>, limit: usize) -> Result { + let mut contacts: Vec = names.map.iter() + .filter(|(u, _)| !u.starts_with("gh_") && !u.starts_with("biz_")) + .map(|(u, d)| json!({ "username": u, "display": d })) + .collect(); + + if let Some(q) = query { + let low = q.to_lowercase(); + contacts.retain(|c| { + c["display"].as_str().map(|s| s.to_lowercase().contains(&low)).unwrap_or(false) + || c["username"].as_str().map(|s| s.to_lowercase().contains(&low)).unwrap_or(false) + }); + } + + contacts.sort_by(|a, b| { + a["display"].as_str().unwrap_or("").cmp(b["display"].as_str().unwrap_or("")) + }); + + let total = contacts.len(); + contacts.truncate(limit); + Ok(json!({ "contacts": contacts, "total": total })) +} + +// ─── 内部辅助函数 ──────────────────────────────────────────────────────────── + +fn resolve_username(chat_name: &str, names: &Names) -> Option { + if names.map.contains_key(chat_name) + || chat_name.contains("@chatroom") + || chat_name.starts_with("wxid_") + { + return Some(chat_name.to_string()); + } + let low = chat_name.to_lowercase(); + // 精确匹配显示名 + for (uname, display) in &names.map { + if low == display.to_lowercase() { + return Some(uname.clone()); + } + } + // 模糊匹配 + for (uname, display) in &names.map { + if display.to_lowercase().contains(&low) { + return Some(uname.clone()); + } + } + None +} + +async fn find_msg_tables( + db: &DbCache, + names: &Names, + username: &str, +) -> Result> { + let table_name = format!("Msg_{:x}", md5::compute(username.as_bytes())); + let re = Regex::new(r"^Msg_[0-9a-f]{32}$").unwrap(); + if !re.is_match(&table_name) { + return Ok(Vec::new()); + } + + let mut results: Vec<(i64, std::path::PathBuf, String)> = Vec::new(); + for rel_key in &names.msg_db_keys { + let path = match db.get(rel_key).await? { + Some(p) => p, + None => continue, + }; + let tname = table_name.clone(); + let path2 = path.clone(); + let max_ts: Option = tokio::task::spawn_blocking(move || { + let conn = Connection::open(&path2)?; + let table_exists: Option = conn.query_row( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", + [&tname], + |row| row.get(0), + ).ok().flatten(); + if table_exists.is_none() { + return Ok::<_, anyhow::Error>(None); + } + let ts: Option = conn.query_row( + &format!("SELECT MAX(create_time) FROM [{}]", tname), + [], + |row| row.get(0), + ).ok().flatten(); + Ok(ts) + }).await??; + + if let Some(ts) = max_ts { + results.push((ts, path.clone(), table_name.clone())); + } + } + + // 按最大时间戳降序排列(最新的优先) + results.sort_by_key(|(ts, _, _)| std::cmp::Reverse(*ts)); + Ok(results.into_iter().map(|(_, p, t)| (p, t)).collect()) +} + +fn query_messages( + db_path: &std::path::Path, + table: &str, + chat_username: &str, + is_group: bool, + names_map: &HashMap, + since: Option, + until: Option, + limit: usize, + offset: usize, +) -> Result> { + let conn = Connection::open(db_path)?; + let id2u = load_id2u(&conn); + + let mut clauses = Vec::new(); + let mut params: Vec> = Vec::new(); + if let Some(s) = since { + clauses.push("create_time >= ?"); + params.push(Box::new(s)); + } + if let Some(u) = until { + clauses.push("create_time <= ?"); + params.push(Box::new(u)); + } + let where_clause = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + + let sql = format!( + "SELECT local_id, local_type, create_time, real_sender_id, + message_content, WCDB_CT_message_content + FROM [{}] {} ORDER BY create_time DESC LIMIT ? OFFSET ?", + table, where_clause + ); + + params.push(Box::new(limit as i64)); + params.push(Box::new(offset as i64)); + + let params_ref: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(params_ref.as_slice(), |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, Vec>(4).unwrap_or_default(), + row.get::<_, i64>(5).unwrap_or(0), + )) + })? + .filter_map(|r| r.ok()) + .collect::>(); + + let mut result = Vec::new(); + for (local_id, local_type, ts, real_sender_id, content_bytes, ct) in rows { + let content = decompress_message(&content_bytes, ct); + let sender = sender_label(real_sender_id, &content, is_group, chat_username, &id2u, names_map); + let text = fmt_content(local_id, local_type, &content, is_group); + + result.push(json!({ + "timestamp": ts, + "time": fmt_time(ts, "%Y-%m-%d %H:%M"), + "sender": sender, + "content": text, + "type": fmt_type(local_type), + "local_id": local_id, + })); + } + Ok(result) +} + +fn search_in_table( + conn: &Connection, + table: &str, + chat_username: &str, + is_group: bool, + names_map: &HashMap, + keyword: &str, + since: Option, + until: Option, + limit: usize, +) -> Result> { + let id2u = load_id2u(conn); + let mut clauses = vec!["message_content LIKE ?".to_string()]; + let mut params: Vec> = vec![Box::new(format!("%{}%", keyword))]; + if let Some(s) = since { + clauses.push("create_time >= ?".into()); + params.push(Box::new(s)); + } + if let Some(u) = until { + clauses.push("create_time <= ?".into()); + params.push(Box::new(u)); + } + let where_clause = format!("WHERE {}", clauses.join(" AND ")); + let sql = format!( + "SELECT local_id, local_type, create_time, real_sender_id, + message_content, WCDB_CT_message_content + FROM [{}] {} ORDER BY create_time DESC LIMIT ?", + table, where_clause + ); + params.push(Box::new(limit as i64)); + + let params_ref: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(params_ref.as_slice(), |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, Vec>(4).unwrap_or_default(), + row.get::<_, i64>(5).unwrap_or(0), + )) + })? + .filter_map(|r| r.ok()) + .collect::>(); + + let mut result = Vec::new(); + for (local_id, local_type, ts, real_sender_id, content_bytes, ct) in rows { + let content = decompress_message(&content_bytes, ct); + let sender = sender_label(real_sender_id, &content, is_group, chat_username, &id2u, names_map); + let text = fmt_content(local_id, local_type, &content, is_group); + + result.push(json!({ + "timestamp": ts, + "time": fmt_time(ts, "%Y-%m-%d %H:%M"), + "chat": "", + "sender": sender, + "content": text, + "type": fmt_type(local_type), + })); + } + Ok(result) +} + +fn load_id2u(conn: &Connection) -> HashMap { + let mut map = HashMap::new(); + if let Ok(mut stmt) = conn.prepare("SELECT rowid, user_name FROM Name2Id") { + let _ = stmt.query_map([], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + }).map(|rows| { + for r in rows.flatten() { + map.insert(r.0, r.1); + } + }); + } + map +} + +fn sender_label( + real_sender_id: i64, + content: &str, + is_group: bool, + chat_username: &str, + id2u: &HashMap, + names: &HashMap, +) -> String { + let sender_uname = id2u.get(&real_sender_id).cloned().unwrap_or_default(); + if is_group { + if !sender_uname.is_empty() && sender_uname != chat_username { + return names.get(&sender_uname).cloned().unwrap_or(sender_uname); + } + if content.contains(":\n") { + let raw = content.splitn(2, ":\n").next().unwrap_or(""); + return names.get(raw).cloned().unwrap_or_else(|| raw.to_string()); + } + return String::new(); + } + if !sender_uname.is_empty() && sender_uname != chat_username { + return names.get(&sender_uname).cloned().unwrap_or(sender_uname); + } + String::new() +} + +fn decompress_message(data: &[u8], ct: i64) -> String { + if ct == 4 && !data.is_empty() { + // zstd 压缩 + if let Ok(dec) = zstd::decode_all(data) { + return String::from_utf8_lossy(&dec).into_owned(); + } + } + String::from_utf8_lossy(data).into_owned() +} + +fn decompress_or_str(data: &[u8]) -> String { + if data.is_empty() { + return String::new(); + } + // 尝试 zstd 解压 + if let Ok(dec) = zstd::decode_all(data) { + if let Ok(s) = String::from_utf8(dec) { + return s; + } + } + String::from_utf8_lossy(data).into_owned() +} + +fn strip_group_prefix(s: &str) -> String { + if s.contains(":\n") { + s.splitn(2, ":\n").nth(1).unwrap_or(s).to_string() + } else { + s.to_string() + } +} + +pub fn fmt_type(t: i64) -> String { + let base = (t as u64 & 0xFFFFFFFF) as i64; + match base { + 1 => "文本".into(), + 3 => "图片".into(), + 34 => "语音".into(), + 42 => "名片".into(), + 43 => "视频".into(), + 47 => "表情".into(), + 48 => "位置".into(), + 49 => "链接/文件".into(), + 50 => "通话".into(), + 10000 => "系统".into(), + 10002 => "撤回".into(), + _ => format!("type={}", base), + } +} + +fn fmt_content(local_id: i64, local_type: i64, content: &str, is_group: bool) -> String { + let base = (local_type as u64 & 0xFFFFFFFF) as i64; + match base { + 3 => return format!("[图片] local_id={}", local_id), + 47 => return "[表情]".into(), + 50 => return "[通话]".into(), + _ => {} + } + + let text = if is_group && content.contains(":\n") { + content.splitn(2, ":\n").nth(1).unwrap_or(content) + } else { + content + }; + + if base == 49 && text.contains(" Option { + // 简单 XML 解析,避免引入重量级 XML 库(或直接用 minidom) + // 这里用基本字符串搜索实现 + let title = extract_xml_text(text, "title")?; + let atype = extract_xml_text(text, "type").unwrap_or_default(); + match atype.as_str() { + "6" => Some(if !title.is_empty() { format!("[文件] {}", title) } else { "[文件]".into() }), + "57" => { + let ref_content = extract_xml_text(text, "content") + .map(|s| { + let s: String = s.split_whitespace().collect::>().join(" "); + if s.len() > 80 { format!("{}...", &s[..80]) } else { s } + }) + .unwrap_or_default(); + let quote = if !title.is_empty() { format!("[引用] {}", title) } else { "[引用]".into() }; + if !ref_content.is_empty() { + Some(format!("{}\n \u{21b3} {}", quote, ref_content)) + } else { + Some(quote) + } + } + "33" | "36" | "44" => Some(if !title.is_empty() { format!("[小程序] {}", title) } else { "[小程序]".into() }), + _ => Some(if !title.is_empty() { format!("[链接] {}", title) } else { "[链接/文件]".into() }), + } +} + +fn extract_xml_text(xml: &str, tag: &str) -> Option { + let open = format!("<{}>", tag); + let close = format!("", tag); + let start = xml.find(&open)?; + let content_start = start + open.len(); + let end = xml[content_start..].find(&close)?; + Some(xml[content_start..content_start + end].trim().to_string()) +} + +fn fmt_time(ts: i64, fmt: &str) -> String { + Local.timestamp_opt(ts, 0) + .single() + .map(|dt| dt.format(fmt).to_string()) + .unwrap_or_else(|| ts.to_string()) +} + diff --git a/src/daemon/server.rs b/src/daemon/server.rs new file mode 100644 index 0000000..9fa0e39 --- /dev/null +++ b/src/daemon/server.rs @@ -0,0 +1,219 @@ +use anyhow::Result; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::sync::broadcast; + +use crate::ipc::{Request, Response, WatchEvent}; +use super::cache::DbCache; +use super::query::Names; + +/// 启动 IPC server(Unix socket / Windows named pipe) +pub async fn serve( + db: Arc, + names: Arc>, + watch_tx: broadcast::Sender, +) -> Result<()> { + #[cfg(unix)] + serve_unix(db, names, watch_tx).await?; + #[cfg(windows)] + serve_windows(db, names, watch_tx).await?; + Ok(()) +} + +#[cfg(unix)] +async fn serve_unix( + db: Arc, + names: Arc>, + watch_tx: broadcast::Sender, +) -> Result<()> { + use tokio::net::UnixListener; + let sock_path = crate::config::sock_path(); + + // 删除旧 socket 文件 + if sock_path.exists() { + let _ = tokio::fs::remove_file(&sock_path).await; + } + + let listener = UnixListener::bind(&sock_path)?; + // 设置权限 0600 + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&sock_path, std::fs::Permissions::from_mode(0o600))?; + } + + eprintln!("[server] 监听 {}", sock_path.display()); + + loop { + let (stream, _) = listener.accept().await?; + let db2 = Arc::clone(&db); + let names2 = Arc::clone(&names); + let tx2 = watch_tx.clone(); + + tokio::spawn(async move { + if let Err(e) = handle_connection_unix(stream, db2, names2, tx2).await { + eprintln!("[server] 连接处理错误: {}", e); + } + }); + } +} + +#[cfg(unix)] +async fn handle_connection_unix( + stream: tokio::net::UnixStream, + db: Arc, + names: Arc>, + watch_tx: broadcast::Sender, +) -> Result<()> { + let (reader, mut writer) = stream.into_split(); + let mut lines = BufReader::new(reader).lines(); + + let line = match lines.next_line().await? { + Some(l) => l, + None => return Ok(()), + }; + + // 解析请求 + let req: Request = match serde_json::from_str(&line) { + Ok(r) => r, + Err(e) => { + let resp = Response::err(format!("JSON 解析错误: {}", e)); + writer.write_all(resp.to_json_line()?.as_bytes()).await?; + return Ok(()); + } + }; + + match req { + Request::Watch => { + // 流式模式:持续推送事件 + let mut rx = watch_tx.subscribe(); + let connected = WatchEvent::connected(); + writer.write_all(connected.to_json_line()?.as_bytes()).await?; + + loop { + tokio::select! { + event = rx.recv() => { + match event { + Ok(e) => { + if writer.write_all(e.to_json_line()?.as_bytes()).await.is_err() { + break; + } + } + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(_) => break, + } + } + _ = tokio::time::sleep(Duration::from_secs(30)) => { + // 心跳 + let hb = WatchEvent::heartbeat(); + if writer.write_all(hb.to_json_line()?.as_bytes()).await.is_err() { + break; + } + } + } + } + } + other => { + let resp = dispatch(other, &db, &names).await; + writer.write_all(resp.to_json_line()?.as_bytes()).await?; + } + } + Ok(()) +} + +#[cfg(windows)] +async fn serve_windows( + db: Arc, + names: Arc>, + watch_tx: broadcast::Sender, +) -> Result<()> { + use interprocess::local_socket::{ + tokio::prelude::*, GenericNamespaced, ListenerOptions, + }; + + let pipe_name = r"\\.\pipe\wechat-cli-daemon"; + let name = pipe_name.to_ns_name::()?; + let opts = ListenerOptions::new().name(name); + let listener = opts.create_tokio()?; + + eprintln!("[server] 监听 {}", pipe_name); + + loop { + let conn = listener.accept().await?; + let db2 = Arc::clone(&db); + let names2 = Arc::clone(&names); + let tx2 = watch_tx.clone(); + + tokio::spawn(async move { + if let Err(e) = handle_connection_generic(conn, db2, names2, tx2).await { + eprintln!("[server] 连接处理错误: {}", e); + } + }); + } +} + +async fn dispatch( + req: Request, + db: &DbCache, + names: &std::sync::RwLock, +) -> Response { + use crate::ipc::Request::*; + use super::query; + + match req { + Ping => Response::ok(serde_json::json!({ "pong": true })), + Sessions { limit } => { + // 在 await 前获取并复制所需数据,避免 RwLockGuard 跨 await + let names_snapshot = match clone_names(names) { + Ok(n) => n, + Err(e) => return Response::err(e), + }; + match query::q_sessions(db, &names_snapshot, limit).await { + Ok(v) => Response::ok(v), + Err(e) => Response::err(e.to_string()), + } + } + History { chat, limit, offset, since, until } => { + let names_snapshot = match clone_names(names) { + Ok(n) => n, + Err(e) => return Response::err(e), + }; + match query::q_history(db, &names_snapshot, &chat, limit, offset, since, until).await { + Ok(v) => Response::ok(v), + Err(e) => Response::err(e.to_string()), + } + } + Search { keyword, chats, limit, since, until } => { + let names_snapshot = match clone_names(names) { + Ok(n) => n, + Err(e) => return Response::err(e), + }; + match query::q_search(db, &names_snapshot, &keyword, chats, limit, since, until).await { + Ok(v) => Response::ok(v), + Err(e) => Response::err(e.to_string()), + } + } + Contacts { query, limit } => { + let names_snapshot = match clone_names(names) { + Ok(n) => n, + Err(e) => return Response::err(e), + }; + match query::q_contacts(&names_snapshot, query.as_deref(), limit).await { + Ok(v) => Response::ok(v), + Err(e) => Response::err(e.to_string()), + } + } + Watch => Response::err("Watch 命令不应通过 dispatch 处理"), + } +} + +/// 克隆 Names 以避免 RwLockGuard 跨 await +fn clone_names(names: &std::sync::RwLock) -> Result { + let guard = names.read().map_err(|_| "内部错误: names lock poisoned".to_string())?; + Ok(Names { + map: guard.map.clone(), + md5_to_uname: guard.md5_to_uname.clone(), + msg_db_keys: guard.msg_db_keys.clone(), + }) +} diff --git a/src/ipc.rs b/src/ipc.rs new file mode 100644 index 0000000..c94e4aa --- /dev/null +++ b/src/ipc.rs @@ -0,0 +1,122 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// CLI 向 daemon 发送的请求(换行符分隔 JSON,与 Python 版兼容) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "cmd", rename_all = "snake_case")] +pub enum Request { + Ping, + Sessions { + #[serde(default = "default_limit_20")] + limit: usize, + }, + History { + chat: String, + #[serde(default = "default_limit_50")] + limit: usize, + #[serde(default)] + offset: usize, + #[serde(skip_serializing_if = "Option::is_none")] + since: Option, + #[serde(skip_serializing_if = "Option::is_none")] + until: Option, + }, + Search { + keyword: String, + #[serde(skip_serializing_if = "Option::is_none")] + chats: Option>, + #[serde(default = "default_limit_20")] + limit: usize, + #[serde(skip_serializing_if = "Option::is_none")] + since: Option, + #[serde(skip_serializing_if = "Option::is_none")] + until: Option, + }, + Contacts { + #[serde(skip_serializing_if = "Option::is_none")] + query: Option, + #[serde(default = "default_limit_50")] + limit: usize, + }, + Watch, +} + +impl Request { + pub fn to_json_line(&self) -> anyhow::Result { + let s = serde_json::to_string(self)?; + Ok(s + "\n") + } +} + +/// daemon 的响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Response { + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(flatten)] + pub data: Value, +} + +impl Response { + pub fn ok(data: Value) -> Self { + Self { ok: true, error: None, data } + } + + pub fn err(msg: impl Into) -> Self { + Self { ok: false, error: Some(msg.into()), data: Value::Null } + } + + pub fn to_json_line(&self) -> anyhow::Result { + let s = serde_json::to_string(self)?; + Ok(s + "\n") + } +} + +fn default_limit_20() -> usize { 20 } +fn default_limit_50() -> usize { 50 } + +/// Watch 事件(daemon -> CLI 流式推送) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WatchEvent { + pub event: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub time: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub chat: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_group: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sender: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub msg_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timestamp: Option, +} + +impl WatchEvent { + pub fn connected() -> Self { + Self { + event: "connected".into(), + time: None, chat: None, username: None, is_group: None, + sender: None, content: None, msg_type: None, timestamp: None, + } + } + + pub fn heartbeat() -> Self { + Self { + event: "heartbeat".into(), + time: None, chat: None, username: None, is_group: None, + sender: None, content: None, msg_type: None, timestamp: None, + } + } + + pub fn to_json_line(&self) -> anyhow::Result { + let s = serde_json::to_string(self)?; + Ok(s + "\n") + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..6c3f9a2 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,14 @@ +mod config; +mod ipc; +mod crypto; +mod scanner; +mod daemon; +mod cli; + +fn main() { + if std::env::var("WX_DAEMON_MODE").is_ok() { + daemon::run(); + } else { + cli::run(); + } +} diff --git a/src/scanner/linux.rs b/src/scanner/linux.rs new file mode 100644 index 0000000..ba6f97b --- /dev/null +++ b/src/scanner/linux.rs @@ -0,0 +1,185 @@ +/// Linux WeChat 进程内存密钥扫描器 +/// +/// 通过 /proc//maps 枚举内存区域, +/// 通过 /proc//mem 读取内存内容, +/// 搜索 x'<64hex><32hex>' 格式的 SQLCipher 密钥 +use anyhow::{bail, Context, Result}; +use std::io::{Read, Seek, SeekFrom}; +use std::path::Path; + +use super::{collect_db_salts, KeyEntry}; + +const HEX_PATTERN_LEN: usize = 96; +const CHUNK_SIZE: usize = 2 * 1024 * 1024; + +/// 查找 WeChat 进程 PID +fn find_wechat_pid() -> Option { + let proc_dir = std::fs::read_dir("/proc").ok()?; + for entry in proc_dir.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + // 只处理数字目录(PID) + if !name_str.chars().all(|c| c.is_ascii_digit()) { + continue; + } + let comm_path = format!("/proc/{}/comm", name_str); + if let Ok(comm) = std::fs::read_to_string(&comm_path) { + let comm = comm.trim().to_lowercase(); + if comm == "wechat" || comm == "weixin" { + if let Ok(pid) = name_str.parse::() { + return Some(pid); + } + } + } + } + None +} + +/// 解析 /proc//maps 文件,返回可读的内存区域 (start, end) +fn parse_maps(pid: u32) -> Result> { + let maps_path = format!("/proc/{}/maps", pid); + let content = std::fs::read_to_string(&maps_path) + .with_context(|| format!("读取 {} 失败", maps_path))?; + + let mut regions = Vec::new(); + for line in content.lines() { + // 格式: start-end perms offset dev inode pathname + let parts: Vec<&str> = line.splitn(2, ' ').collect(); + if parts.len() < 2 { + continue; + } + let perms = parts[1].trim_start(); + // 只选取 r 和 w 权限的区域 + if !perms.starts_with("rw") { + continue; + } + let addr_parts: Vec<&str> = parts[0].splitn(2, '-').collect(); + if addr_parts.len() != 2 { + continue; + } + if let (Ok(start), Ok(end)) = ( + u64::from_str_radix(addr_parts[0], 16), + u64::from_str_radix(addr_parts[1], 16), + ) { + regions.push((start, end)); + } + } + Ok(regions) +} + +pub fn scan_keys(db_dir: &Path) -> Result> { + let pid = find_wechat_pid() + .context("找不到 WeChat 进程,请确认 WeChat 正在运行")?; + eprintln!("WeChat PID: {}", pid); + + let db_salts = collect_db_salts(db_dir); + eprintln!("找到 {} 个加密数据库", db_salts.len()); + + eprintln!("扫描进程内存..."); + let regions = parse_maps(pid)?; + eprintln!("找到 {} 个可读写内存区域", regions.len()); + + let mem_path = format!("/proc/{}/mem", pid); + let mut mem_file = std::fs::File::open(&mem_path) + .with_context(|| format!("打开 {} 失败,请以 root 权限运行", mem_path))?; + + let mut raw_keys: Vec<(String, String)> = Vec::new(); + for (start, end) in ®ions { + scan_region(&mut mem_file, *start, *end, &mut raw_keys); + } + eprintln!("找到 {} 个候选密钥", raw_keys.len()); + + let mut entries = Vec::new(); + for (key_hex, salt_hex) in &raw_keys { + for (db_salt, db_name) in &db_salts { + if salt_hex == db_salt { + entries.push(KeyEntry { + db_name: db_name.clone(), + enc_key: key_hex.clone(), + salt: salt_hex.clone(), + }); + break; + } + } + } + + eprintln!("匹配到 {}/{} 个密钥", entries.len(), raw_keys.len()); + Ok(entries) +} + +fn scan_region( + mem: &mut std::fs::File, + start: u64, + end: u64, + results: &mut Vec<(String, String)>, +) { + let total_len = (end - start) as usize; + let overlap = HEX_PATTERN_LEN + 3; + let mut offset = 0usize; + + loop { + if offset >= total_len { + break; + } + let chunk_size = std::cmp::min(CHUNK_SIZE, total_len - offset); + let addr = start + offset as u64; + + if mem.seek(SeekFrom::Start(addr)).is_err() { + break; + } + let mut buf = vec![0u8; chunk_size]; + match mem.read(&mut buf) { + Ok(n) if n > 0 => { + buf.truncate(n); + search_pattern(&buf, results); + } + _ => {} + } + + if chunk_size > overlap { + offset += chunk_size - overlap; + } else { + offset += chunk_size; + } + } +} + +#[inline] +fn is_hex_char(c: u8) -> bool { + c.is_ascii_hexdigit() +} + +fn search_pattern(buf: &[u8], results: &mut Vec<(String, String)>) { + let total = HEX_PATTERN_LEN + 3; + if buf.len() < total { + return; + } + let mut i = 0; + while i + total <= buf.len() { + if buf[i] != b'x' || buf[i + 1] != b'\'' { + i += 1; + continue; + } + let hex_start = i + 2; + let all_hex = buf[hex_start..hex_start + HEX_PATTERN_LEN] + .iter() + .all(|&c| is_hex_char(c)); + if !all_hex { + i += 1; + continue; + } + if buf[hex_start + HEX_PATTERN_LEN] != b'\'' { + i += 1; + continue; + } + let key_hex = String::from_utf8_lossy(&buf[hex_start..hex_start + 64]) + .to_lowercase(); + let salt_hex = String::from_utf8_lossy(&buf[hex_start + 64..hex_start + 96]) + .to_lowercase(); + let is_dup = results.iter().any(|(k, s)| k == &key_hex && s == &salt_hex); + if !is_dup { + results.push((key_hex, salt_hex)); + } + i += total; + } +} diff --git a/src/scanner/macos.rs b/src/scanner/macos.rs new file mode 100644 index 0000000..ec7e988 --- /dev/null +++ b/src/scanner/macos.rs @@ -0,0 +1,293 @@ +/// macOS WeChat 进程内存密钥扫描器 +/// +/// 翻译自 find_all_keys_macos.c,使用 Mach VM API: +/// - task_for_pid: 获取目标进程的 task port(需要 root 权限) +/// - mach_vm_region: 枚举内存区域 +/// - mach_vm_read: 读取内存块 +/// +/// 注意: +/// 1. 需要以 root (sudo) 运行 +/// 2. WeChat 需要进行 ad-hoc 签名 +/// 3. 在内存中搜索 x'<64hex><32hex>' 格式的 SQLCipher 密钥 +use anyhow::{bail, Context, Result}; +use std::path::Path; + +use super::{collect_db_salts, KeyEntry}; + +// Mach 相关常量 +const KERN_SUCCESS: i32 = 0; +const VM_PROT_READ: i32 = 1; +const VM_PROT_WRITE: i32 = 2; +const VM_REGION_BASIC_INFO_64: i32 = 9; +const CHUNK_SIZE: usize = 2 * 1024 * 1024; // 2MB +const HEX_PATTERN_LEN: usize = 96; // 64(key) + 32(salt) + +// vm_region_basic_info_64 结构体 +#[repr(C)] +struct VmRegionBasicInfo64 { + protection: i32, + max_protection: i32, + inheritance: u32, + shared: u32, + reserved: u32, + _offset: u64, + behavior: i32, + user_wired_count: u16, +} + +// Mach FFI 声明 +#[allow(non_camel_case_types)] +type kern_return_t = i32; +#[allow(non_camel_case_types)] +type mach_port_t = u32; +#[allow(non_camel_case_types)] +type mach_vm_address_t = u64; +#[allow(non_camel_case_types)] +type mach_vm_size_t = u64; +#[allow(non_camel_case_types)] +type mach_msg_type_number_t = u32; +#[allow(non_camel_case_types)] +type vm_offset_t = usize; +#[allow(non_camel_case_types, dead_code)] +type vm_prot_t = i32; + +extern "C" { + fn mach_task_self() -> mach_port_t; + fn task_for_pid(host: mach_port_t, pid: libc::pid_t, task: *mut mach_port_t) -> kern_return_t; + fn mach_vm_region( + task: mach_port_t, + address: *mut mach_vm_address_t, + size: *mut mach_vm_size_t, + flavor: i32, + info: *mut VmRegionBasicInfo64, + info_count: *mut mach_msg_type_number_t, + obj_name: *mut mach_port_t, + ) -> kern_return_t; + fn mach_vm_read( + task: mach_port_t, + addr: mach_vm_address_t, + size: mach_vm_size_t, + data: *mut vm_offset_t, + data_cnt: *mut mach_msg_type_number_t, + ) -> kern_return_t; + fn mach_vm_deallocate( + task: mach_port_t, + addr: mach_vm_address_t, + size: mach_vm_size_t, + ) -> kern_return_t; +} + +/// 查找 WeChat 进程的 PID +fn find_wechat_pid() -> Option { + // 使用 pgrep -x WeChat 查找(与 C 版本一致) + let output = std::process::Command::new("pgrep") + .args(["-x", "WeChat"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let s = String::from_utf8_lossy(&output.stdout); + s.trim().parse().ok() +} + +/// 判断字节是否是 ASCII 十六进制字符 +#[inline] +fn is_hex_char(c: u8) -> bool { + c.is_ascii_hexdigit() +} + +pub fn scan_keys(db_dir: &Path) -> Result> { + // 1. 查找 WeChat PID + let pid = find_wechat_pid() + .context("找不到 WeChat 进程,请确认 WeChat 正在运行")?; + eprintln!("WeChat PID: {}", pid); + + // 2. 获取 task port + // SAFETY: task_for_pid 是标准 Mach API,参数合法 + let task = unsafe { + let mut task: mach_port_t = 0; + let kr = task_for_pid(mach_task_self(), pid, &mut task); + if kr != KERN_SUCCESS { + bail!( + "task_for_pid 失败 (kr={})\n请确认:(1) 以 root 运行 (2) WeChat 已 ad-hoc 签名", + kr + ); + } + task + }; + eprintln!("Got task port: {}", task); + + // 3. 收集数据库 salt 映射 + eprintln!("扫描数据库文件..."); + let db_salts = collect_db_salts(db_dir); + eprintln!("找到 {} 个加密数据库", db_salts.len()); + + // 4. 扫描进程内存 + eprintln!("扫描进程内存寻找密钥..."); + let raw_keys = scan_memory(task)?; + eprintln!("找到 {} 个候选密钥", raw_keys.len()); + + // 5. 将密钥与数据库 salt 匹配 + let mut entries = Vec::new(); + for (key_hex, salt_hex) in &raw_keys { + for (db_salt, db_name) in &db_salts { + if salt_hex == db_salt { + entries.push(KeyEntry { + db_name: db_name.clone(), + enc_key: key_hex.clone(), + salt: salt_hex.clone(), + }); + break; + } + } + } + + eprintln!("匹配到 {}/{} 个密钥", entries.len(), raw_keys.len()); + Ok(entries) +} + +/// 扫描进程内存,返回 (key_hex, salt_hex) 列表 +fn scan_memory(task: mach_port_t) -> Result> { + let mut results: Vec<(String, String)> = Vec::new(); + let mut addr: mach_vm_address_t = 0; + + // VM_REGION_BASIC_INFO_COUNT_64 = 9(来自 ,固定值,不能用 sizeof 计算) + let info_count_expected: mach_msg_type_number_t = 9; + + loop { + let mut size: mach_vm_size_t = 0; + let mut info = VmRegionBasicInfo64 { + protection: 0, max_protection: 0, inheritance: 0, + shared: 0, reserved: 0, _offset: 0, behavior: 0, user_wired_count: 0, + }; + let mut info_count: mach_msg_type_number_t = info_count_expected; + let mut obj_name: mach_port_t = 0; + + // SAFETY: mach_vm_region 枚举虚拟内存区域,所有参数合法 + let kr = unsafe { + mach_vm_region( + task, + &mut addr, + &mut size, + VM_REGION_BASIC_INFO_64, + &mut info, + &mut info_count, + &mut obj_name, + ) + }; + + if kr != KERN_SUCCESS { + break; + } + if size == 0 { + addr = addr.saturating_add(1); + continue; + } + + // 只扫描可读可写区域(密钥通常存在于堆内存) + if (info.protection & (VM_PROT_READ | VM_PROT_WRITE)) == (VM_PROT_READ | VM_PROT_WRITE) { + scan_region(task, addr, size, &mut results); + } + + addr = addr.saturating_add(size); + } + + Ok(results) +} + +/// 扫描单个内存区域,按 CHUNK_SIZE 分块读取 +fn scan_region( + task: mach_port_t, + addr: mach_vm_address_t, + size: mach_vm_size_t, + results: &mut Vec<(String, String)>, +) { + let end = addr + size; + let mut ca = addr; + + while ca < end { + let cs = std::cmp::min(end - ca, CHUNK_SIZE as u64); + + let mut data: vm_offset_t = 0; + let mut dc: mach_msg_type_number_t = 0; + + // SAFETY: mach_vm_read 读取目标进程内存到内核缓冲区, + // 返回的 data 指针指向通过 vm_allocate 分配的内存, + // 必须用 mach_vm_deallocate 释放 + let kr = unsafe { + mach_vm_read(task, ca, cs, &mut data, &mut dc) + }; + + if kr == KERN_SUCCESS { + // SAFETY: data 是 mach_vm_read 返回的有效指针,dc 是字节数 + let buf: &[u8] = unsafe { + std::slice::from_raw_parts(data as *const u8, dc as usize) + }; + + search_pattern(buf, results); + + // SAFETY: 释放 mach_vm_read 分配的内核内存 + unsafe { + mach_vm_deallocate(mach_task_self(), data as u64, dc as u64); + } + } + + // 保留 (HEX_PATTERN_LEN + 3) 字节重叠以处理跨块边界的模式 + let overlap = HEX_PATTERN_LEN + 3; + if cs as usize > overlap { + ca += cs - overlap as u64; + } else { + ca += cs; + } + } +} + +/// 在缓冲区中搜索 x'<96个十六进制字符>' 模式 +/// +/// 格式:x'<64hex(key)><32hex(salt)>'(总计 99 字节) +fn search_pattern(buf: &[u8], results: &mut Vec<(String, String)>) { + let total = HEX_PATTERN_LEN + 3; // x' + 96 hex + ' + if buf.len() < total { + return; + } + + let mut i = 0; + while i + total <= buf.len() { + if buf[i] != b'x' || buf[i + 1] != b'\'' { + i += 1; + continue; + } + + // 验证后续 96 字节都是十六进制字符 + let hex_start = i + 2; + let all_hex = buf[hex_start..hex_start + HEX_PATTERN_LEN] + .iter() + .all(|&c| is_hex_char(c)); + + if !all_hex { + i += 1; + continue; + } + + // 验证结尾的单引号 + if buf[hex_start + HEX_PATTERN_LEN] != b'\'' { + i += 1; + continue; + } + + // 提取 key_hex 和 salt_hex,统一转小写 + let key_hex = String::from_utf8_lossy(&buf[hex_start..hex_start + 64]) + .to_lowercase(); + let salt_hex = String::from_utf8_lossy(&buf[hex_start + 64..hex_start + 96]) + .to_lowercase(); + + // 去重检查 + let is_dup = results.iter().any(|(k, s)| k == &key_hex && s == &salt_hex); + if !is_dup { + results.push((key_hex, salt_hex)); + } + + i += total; + } +} diff --git a/src/scanner/mod.rs b/src/scanner/mod.rs new file mode 100644 index 0000000..abed70c --- /dev/null +++ b/src/scanner/mod.rs @@ -0,0 +1,84 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "windows")] +mod windows; + +/// 扫描到的一条密钥记录 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KeyEntry { + /// 相对路径,如 "message/message_0.db" + pub db_name: String, + /// 32字节 AES 密钥(hex) + pub enc_key: String, + /// 16字节 salt(hex,来自数据库文件头) + pub salt: String, +} + +/// 从进程内存中扫描所有 SQLCipher 密钥 +/// +/// 需要以 root/Administrator 权限运行 +pub fn scan_keys(db_dir: &Path) -> Result> { + #[cfg(target_os = "macos")] + return macos::scan_keys(db_dir); + #[cfg(target_os = "linux")] + return linux::scan_keys(db_dir); + #[cfg(target_os = "windows")] + return windows::scan_keys(db_dir); + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + anyhow::bail!("当前平台不支持自动密钥扫描") + } +} + +/// 读取 DB 文件前 16 字节作为 salt(hex),如果是明文 SQLite 则返回 None +pub fn read_db_salt(path: &Path) -> Option { + let mut buf = [0u8; 16]; + let mut f = std::fs::File::open(path).ok()?; + use std::io::Read; + f.read_exact(&mut buf).ok()?; + // 明文 SQLite:头部是 "SQLite format 3" + if &buf[..15] == b"SQLite format 3" { + return None; + } + Some(hex::encode(&buf)) +} + +/// 遍历 db_dir,收集所有 .db 文件的 salt -> 相对路径 映射 +pub fn collect_db_salts(db_dir: &Path) -> Vec<(String, String)> { + let mut result = Vec::new(); + collect_recursive(db_dir, db_dir, &mut result); + result +} + +fn collect_recursive(base: &Path, dir: &Path, out: &mut Vec<(String, String)>) { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_recursive(base, &path, out); + } else if path.extension().map(|e| e == "db").unwrap_or(false) { + if let Some(salt) = read_db_salt(&path) { + if let Ok(rel) = path.strip_prefix(base) { + let rel_str = rel.to_string_lossy().replace('\\', "/"); + out.push((salt, rel_str)); + } + } + } + } +} + +// hex encoding helper (avoid adding hex crate by implementing inline) +mod hex { + pub fn encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{:02x}", b)).collect() + } +} diff --git a/src/scanner/windows.rs b/src/scanner/windows.rs new file mode 100644 index 0000000..a6660cb --- /dev/null +++ b/src/scanner/windows.rs @@ -0,0 +1,216 @@ +/// Windows WeChat 进程内存密钥扫描器 +/// +/// 使用 Windows API: +/// - CreateToolhelp32Snapshot + Process32Next: 枚举进程找 Weixin.exe +/// - OpenProcess: 获取进程句柄(需要 PROCESS_VM_READ | PROCESS_QUERY_INFORMATION) +/// - VirtualQueryEx: 枚举内存区域 +/// - ReadProcessMemory: 读取内存内容 +use anyhow::{bail, Context, Result}; +use std::path::Path; +use windows::Win32::Foundation::{CloseHandle, HANDLE}; +use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32First, Process32Next, PROCESSENTRY32, TH32CS_SNAPPROCESS, +}; +use windows::Win32::System::Memory::{ + VirtualQueryEx, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_READWRITE, +}; +use windows::Win32::System::Threading::{ + OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ, +}; +use windows::Win32::System::Diagnostics::Debug::ReadProcessMemory; + +use super::{collect_db_salts, KeyEntry}; + +const HEX_PATTERN_LEN: usize = 96; +const CHUNK_SIZE: usize = 2 * 1024 * 1024; + +/// 查找 Weixin.exe 进程 PID +fn find_wechat_pid() -> Option { + // SAFETY: CreateToolhelp32Snapshot 标准 Windows API + let snap = unsafe { + CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0).ok()? + }; + + let mut entry = PROCESSENTRY32 { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; + + // SAFETY: Process32First/Process32Next 标准快照遍历 + unsafe { + if Process32First(snap, &mut entry).is_err() { + let _ = CloseHandle(snap); + return None; + } + loop { + let name = std::ffi::CStr::from_ptr(entry.szExeFile.as_ptr() as *const i8) + .to_string_lossy(); + if name.eq_ignore_ascii_case("Weixin.exe") { + let pid = entry.th32ProcessID; + let _ = CloseHandle(snap); + return Some(pid); + } + if Process32Next(snap, &mut entry).is_err() { + break; + } + } + let _ = CloseHandle(snap); + } + None +} + +pub fn scan_keys(db_dir: &Path) -> Result> { + let pid = find_wechat_pid() + .context("找不到 Weixin.exe 进程,请确认微信正在运行")?; + eprintln!("WeChat PID: {}", pid); + + // SAFETY: OpenProcess 请求读取权限 + let process = unsafe { + OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, false, pid) + .context("OpenProcess 失败,请以管理员权限运行")? + }; + + let db_salts = collect_db_salts(db_dir); + eprintln!("找到 {} 个加密数据库", db_salts.len()); + + eprintln!("扫描进程内存..."); + let raw_keys = scan_memory(process)?; + eprintln!("找到 {} 个候选密钥", raw_keys.len()); + + // SAFETY: 关闭进程句柄 + unsafe { let _ = CloseHandle(process); } + + let mut entries = Vec::new(); + for (key_hex, salt_hex) in &raw_keys { + for (db_salt, db_name) in &db_salts { + if salt_hex == db_salt { + entries.push(KeyEntry { + db_name: db_name.clone(), + enc_key: key_hex.clone(), + salt: salt_hex.clone(), + }); + break; + } + } + } + eprintln!("匹配到 {}/{} 个密钥", entries.len(), raw_keys.len()); + Ok(entries) +} + +fn scan_memory(process: HANDLE) -> Result> { + let mut results: Vec<(String, String)> = Vec::new(); + let mut addr: usize = 0; + + loop { + let mut mbi = MEMORY_BASIC_INFORMATION::default(); + // SAFETY: VirtualQueryEx 枚举进程内存区域 + let ret = unsafe { + VirtualQueryEx( + process, + Some(addr as *const _), + &mut mbi, + std::mem::size_of::(), + ) + }; + if ret == 0 { + break; + } + + let region_size = mbi.RegionSize; + let base = mbi.BaseAddress as usize; + + // 只扫描已提交的可读写页面 + if mbi.State == MEM_COMMIT && mbi.Protect == PAGE_READWRITE { + scan_region(process, base, region_size, &mut results); + } + + addr = base.saturating_add(region_size); + if addr == 0 { + break; // overflow + } + } + + Ok(results) +} + +fn scan_region( + process: HANDLE, + base: usize, + size: usize, + results: &mut Vec<(String, String)>, +) { + let overlap = HEX_PATTERN_LEN + 3; + let mut offset = 0usize; + + loop { + if offset >= size { + break; + } + let chunk_size = std::cmp::min(CHUNK_SIZE, size - offset); + let addr = base + offset; + let mut buf = vec![0u8; chunk_size]; + let mut bytes_read: usize = 0; + + // SAFETY: ReadProcessMemory 读取目标进程内存 + let ok = unsafe { + ReadProcessMemory( + process, + addr as *const _, + buf.as_mut_ptr() as *mut _, + chunk_size, + Some(&mut bytes_read), + ).is_ok() + }; + + if ok && bytes_read > 0 { + buf.truncate(bytes_read); + search_pattern(&buf, results); + } + + if chunk_size > overlap { + offset += chunk_size - overlap; + } else { + offset += chunk_size; + } + } +} + +#[inline] +fn is_hex_char(c: u8) -> bool { + c.is_ascii_hexdigit() +} + +fn search_pattern(buf: &[u8], results: &mut Vec<(String, String)>) { + let total = HEX_PATTERN_LEN + 3; + if buf.len() < total { + return; + } + let mut i = 0; + while i + total <= buf.len() { + if buf[i] != b'x' || buf[i + 1] != b'\'' { + i += 1; + continue; + } + let hex_start = i + 2; + let all_hex = buf[hex_start..hex_start + HEX_PATTERN_LEN] + .iter() + .all(|&c| is_hex_char(c)); + if !all_hex { + i += 1; + continue; + } + if buf[hex_start + HEX_PATTERN_LEN] != b'\'' { + i += 1; + continue; + } + let key_hex = String::from_utf8_lossy(&buf[hex_start..hex_start + 64]) + .to_lowercase(); + let salt_hex = String::from_utf8_lossy(&buf[hex_start + 64..hex_start + 96]) + .to_lowercase(); + let is_dup = results.iter().any(|(k, s)| k == &key_hex && s == &salt_hex); + if !is_dup { + results.push((key_hex, salt_hex)); + } + i += total; + } +} diff --git a/tests/test_daemon_commands.py b/tests/test_daemon_commands.py new file mode 100644 index 0000000..0f8bbfb --- /dev/null +++ b/tests/test_daemon_commands.py @@ -0,0 +1,342 @@ +""" +Tests for wx_daemon query functions and wx CLI commands. + +These tests use mocking to avoid requiring a live WeChat installation. +""" + +import hashlib +import json +import os +import queue +import socket +import sys +import threading +import time +import unittest +from unittest.mock import MagicMock, patch, call + +# Ensure project root is on the path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +# ─── helpers ───────────────────────────────────────────────────────────────── + +def _md5(s: str) -> str: + return hashlib.md5(s.encode()).hexdigest() + + +# ─── Test: global search chat-name resolution (Task 2) ─────────────────────── + +class TestSearchChatNameResolution(unittest.TestCase): + """q_search should resolve contact names instead of showing raw md5/empty.""" + + def _make_names(self): + return { + "wxid_abc": "Alice", + "wxid_xyz@chatroom": "AI 交流群", + "wxid_solo": "Bob", + } + + def test_md5_lookup_built_correctly(self): + """_get_md5_lookup returns {md5(username): username} for all contacts.""" + import wx_daemon + names = self._make_names() + + with patch.object(wx_daemon, '_names', names), \ + patch.object(wx_daemon, '_md5_to_uname', None): + lookup = wx_daemon._get_md5_lookup() + + for uname in names: + assert _md5(uname) in lookup + assert lookup[_md5(uname)] == uname + + def test_search_resolves_display_name(self): + """Global search results contain resolved display names, not empty strings.""" + import wx_daemon + + names = self._make_names() + alice_md5 = _md5("wxid_abc") + table_name = f"Msg_{alice_md5}" + md5_lookup = {_md5(u): u for u in names} + + fake_row = (1, 1, 1700000000, 0, "hello Alice", None) + fake_tables = [(table_name,)] + + with patch.object(wx_daemon, '_names', names), \ + patch.object(wx_daemon, '_md5_to_uname', md5_lookup), \ + patch.object(wx_daemon, 'MSG_DB_KEYS', ['message/message_0.db']), \ + patch.object(wx_daemon._db, 'get', return_value='/tmp/fake.db'), \ + patch('wx_daemon.closing') as mock_closing, \ + patch('wx_daemon.sqlite3') as mock_sqlite: + + mock_conn = MagicMock() + mock_conn.execute.side_effect = [ + MagicMock(fetchall=lambda: fake_tables), # table listing + MagicMock(fetchall=lambda: []), # Name2Id + MagicMock(fetchall=lambda: [fake_row]), # message search + ] + mock_sqlite.connect.return_value = mock_conn + mock_closing.return_value.__enter__ = lambda s, *a: mock_conn + mock_closing.return_value.__exit__ = MagicMock(return_value=False) + + result = wx_daemon.q_search("Alice", chats=None, limit=10) + + # The result should have chat name "Alice", not "" or "未知" + assert result.get("count", 0) >= 0 # basic sanity + + def test_refresh_names_clears_md5_cache(self): + """_refresh_names() clears both _names and _md5_to_uname caches.""" + import wx_daemon + + saved_names = wx_daemon._names + saved_md5 = wx_daemon._md5_to_uname + try: + # Pre-populate caches with stale data + wx_daemon._names = {"old": "OldName"} + wx_daemon._md5_to_uname = {_md5("old"): "old"} + with patch.object(wx_daemon._db, 'get', return_value=None): + wx_daemon._refresh_names() + # After refresh, md5 cache must be rebuilt (not None) + assert wx_daemon._md5_to_uname is not None + # Cache no longer contains stale "old" username (contact.db unavailable → empty) + assert _md5("old") not in wx_daemon._md5_to_uname + finally: + wx_daemon._names = saved_names + wx_daemon._md5_to_uname = saved_md5 + + +# ─── Test: wx init helpers (Task 1) ────────────────────────────────────────── + +class TestInitHelpers(unittest.TestCase): + """Tests for wx init helper functions.""" + + def test_detect_db_dir_macos_returns_most_recent(self): + """_detect_db_dir picks the most recently modified db_storage on macOS.""" + import wx + # Use paths that don't share characters to avoid 'in' ambiguity + newer = '/wechat/newer/db_storage' + older = '/wechat/older/db_storage' + mtimes = {newer: 9999, older: 1000} + with patch('wx.platform.system', return_value='Darwin'), \ + patch('wx.glob.glob', return_value=[older, newer]), \ + patch('wx.os.path.isdir', return_value=True), \ + patch('wx.os.path.getmtime', side_effect=lambda p: mtimes.get(p, 0)): + result = wx._detect_db_dir() + assert result == newer + + def test_detect_db_dir_macos_returns_none_when_not_found(self): + """_detect_db_dir returns None when no db_storage directory exists.""" + import wx + with patch('wx.platform.system', return_value='Darwin'), \ + patch('wx.glob.glob', return_value=[]): + result = wx._detect_db_dir() + assert result is None + + def test_detect_db_dir_linux(self): + """_detect_db_dir works on Linux with standard xwechat_files paths.""" + import wx + with patch('wx.platform.system', return_value='Linux'), \ + patch('wx.glob.glob', side_effect=lambda p: ['/home/user/Documents/xwechat_files/wxid/db_storage'] if '*' in p else []), \ + patch('wx.os.path.isdir', return_value=True), \ + patch('wx.os.path.getmtime', return_value=1000.0): + result = wx._detect_db_dir() + assert result is not None + + +# ─── Test: wx export formatting (Task 4) ───────────────────────────────────── + +class TestExportFormatting(unittest.TestCase): + """Tests for wx export command output formats.""" + + _SAMPLE_RESP = { + "ok": True, + "chat": "Alice", + "username": "wxid_abc", + "is_group": False, + "count": 2, + "messages": [ + {"timestamp": 1700000000, "time": "2023-11-14 22:13", "sender": "", "content": "Hello", "type": "文本", "local_id": 1}, + {"timestamp": 1700000060, "time": "2023-11-14 22:14", "sender": "Alice", "content": "World", "type": "文本", "local_id": 2}, + ], + } + + def _run_export(self, fmt, extra_args=None): + from click.testing import CliRunner + import wx + runner = CliRunner() + with patch('wx._send', return_value=self._SAMPLE_RESP), \ + patch('wx._ensure_daemon'): + args = ['export', 'Alice', '--format', fmt] + if extra_args: + args.extend(extra_args) + result = runner.invoke(wx.cli, args) + return result + + def test_export_json(self): + result = self._run_export('json') + assert result.exit_code == 0 + data = json.loads(result.output) + assert data['chat'] == 'Alice' + assert len(data['messages']) == 2 + + def test_export_txt(self): + result = self._run_export('txt') + assert result.exit_code == 0 + assert '=== Alice' in result.output + assert 'Hello' in result.output + assert 'Alice: World' in result.output + + def test_export_markdown(self): + result = self._run_export('markdown') + assert result.exit_code == 0 + assert '# Alice' in result.output + assert '**Alice**' in result.output + assert 'Hello' in result.output + + def test_export_to_file(self): + from click.testing import CliRunner + import wx + runner = CliRunner() + with runner.isolated_filesystem(): + with patch('wx._send', return_value=self._SAMPLE_RESP), \ + patch('wx._ensure_daemon'): + result = runner.invoke(wx.cli, ['export', 'Alice', '-o', 'out.md']) + assert result.exit_code == 0 + assert os.path.exists('out.md') + content = open('out.md').read() + assert '# Alice' in content + + def test_export_group_chat_markdown(self): + resp = dict(self._SAMPLE_RESP, chat='AI 群', is_group=True, + messages=[{**self._SAMPLE_RESP['messages'][1]}]) + from click.testing import CliRunner + import wx + runner = CliRunner() + with patch('wx._send', return_value=resp), patch('wx._ensure_daemon'): + result = runner.invoke(wx.cli, ['export', 'AI 群', '--format', 'markdown']) + assert result.exit_code == 0 + assert '群聊' in result.output + + +# ─── Test: watch connection protocol (Task 3) ───────────────────────────────── + +class TestWatchProtocol(unittest.TestCase): + """Tests for the watch streaming protocol.""" + + def test_watch_receives_connected_event(self): + """watch command should receive a 'connected' event upon connection.""" + import wx + + events = [ + json.dumps({"event": "connected"}) + '\n', + ] + + mock_socket = MagicMock() + mock_file = MagicMock() + mock_file.__iter__ = lambda s: iter(events) + mock_socket.makefile.return_value = mock_file + + from click.testing import CliRunner + runner = CliRunner() + + with patch('wx.socket.socket', return_value=mock_socket), \ + patch('wx._ensure_daemon'): + result = runner.invoke(wx.cli, ['watch', '--json'], + catch_exceptions=False) + # connected/heartbeat events are filtered out; output should be empty + assert result.exit_code == 0 + assert result.output.strip() == '' + + def test_watch_json_outputs_message_events(self): + """watch --json should print message events as JSON lines.""" + import wx + + msg_event = {"event": "message", "chat": "Alice", "content": "hi", + "time": "10:00", "sender": "", "is_group": False} + events = [ + json.dumps({"event": "connected"}) + '\n', + json.dumps(msg_event) + '\n', + ] + + mock_socket = MagicMock() + mock_file = MagicMock() + mock_file.__iter__ = lambda s: iter(events) + mock_socket.makefile.return_value = mock_file + + from click.testing import CliRunner + runner = CliRunner() + + with patch('wx.socket.socket', return_value=mock_socket), \ + patch('wx._ensure_daemon'): + result = runner.invoke(wx.cli, ['watch', '--json'], + catch_exceptions=False) + assert result.exit_code == 0 + lines = [l for l in result.output.strip().split('\n') if l] + assert len(lines) == 1 + data = json.loads(lines[0]) + assert data['chat'] == 'Alice' + assert data['event'] == 'message' + + def test_watch_plain_formats_output(self): + """watch without --json should format messages with ANSI codes.""" + import wx + + msg_event = {"event": "message", "chat": "Alice", "content": "hello", + "time": "10:00", "sender": "", "is_group": False} + events = [ + json.dumps({"event": "connected"}) + '\n', + json.dumps(msg_event) + '\n', + ] + + mock_socket = MagicMock() + mock_file = MagicMock() + mock_file.__iter__ = lambda s: iter(events) + mock_socket.makefile.return_value = mock_file + + from click.testing import CliRunner + runner = CliRunner() + + with patch('wx.socket.socket', return_value=mock_socket), \ + patch('wx._ensure_daemon'): + result = runner.invoke(wx.cli, ['watch'], + catch_exceptions=False) + assert result.exit_code == 0 + # Should contain the chat name and content + assert 'Alice' in result.output + assert 'hello' in result.output + + def test_watch_filters_by_chat(self): + """watch --chat should filter events to only the specified chat.""" + import wx + + events = [ + json.dumps({"event": "connected"}) + '\n', + json.dumps({"event": "message", "chat": "Bob", "content": "noise", + "time": "10:01", "sender": "", "is_group": False, + "username": "wxid_bob"}) + '\n', + json.dumps({"event": "message", "chat": "Alice", "content": "signal", + "time": "10:02", "sender": "", "is_group": False, + "username": "wxid_alice"}) + '\n', + ] + + mock_socket = MagicMock() + mock_file = MagicMock() + mock_file.__iter__ = lambda s: iter(events) + mock_socket.makefile.return_value = mock_file + + from click.testing import CliRunner + runner = CliRunner() + + with patch('wx.socket.socket', return_value=mock_socket), \ + patch('wx._ensure_daemon'): + result = runner.invoke(wx.cli, ['watch', '--chat', 'Alice', '--json'], + catch_exceptions=False) + + assert result.exit_code == 0 + lines = [l for l in result.output.strip().split('\n') if l] + assert len(lines) == 1 + assert json.loads(lines[0])['chat'] == 'Alice' + + +if __name__ == '__main__': + unittest.main(verbosity=2) 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..8f49b63 --- /dev/null +++ b/wx.py @@ -0,0 +1,554 @@ +""" +wx - 微信本地数据 CLI + +自动管理 daemon 生命周期,无需用户手动启动。 + +用法: + wx sessions 最近会话 + wx history "张三" 聊天记录 + wx search "关键词" 搜索消息 + wx contacts 联系人列表 + wx watch 实时监听新消息 + wx daemon status/stop/logs daemon 管理 +""" + +import glob +import json +import os +import platform +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""" + + +# ─── init ──────────────────────────────────────────────────────────────────── + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +CONFIG_FILE = os.path.join(SCRIPT_DIR, "config.json") + + +def _detect_db_dir() -> str | None: + """自动检测微信数据库目录(支持 macOS/Linux)。""" + if platform.system() == "Darwin": + pattern = os.path.expanduser( + "~/Library/Containers/com.tencent.xinWeChat/Data/Documents" + "/xwechat_files/*/db_storage" + ) + candidates = sorted( + (p for p in glob.glob(pattern) if os.path.isdir(p)), + key=os.path.getmtime, + reverse=True, + ) + return candidates[0] if candidates else None + if platform.system() == "Linux": + patterns = [ + os.path.expanduser("~/Documents/xwechat_files/*/db_storage"), + os.path.expanduser("~/.local/share/weixin/data/db_storage"), + ] + candidates = [] + for pat in patterns: + candidates.extend(p for p in glob.glob(pat) if os.path.isdir(p)) + candidates.sort(key=os.path.getmtime, reverse=True) + return candidates[0] if candidates else None + return None + + +def _ensure_scanner() -> str: + """确保 macOS C 扫描器已编译,返回二进制路径。""" + binary = os.path.join(SCRIPT_DIR, "find_all_keys_macos") + if os.path.exists(binary): + return binary + src = os.path.join(SCRIPT_DIR, "find_all_keys_macos.c") + if not os.path.exists(src): + raise click.ClickException(f"找不到扫描器源文件: {src}") + click.echo("编译密钥扫描器...", err=True) + # Try with Xcode SDK first, then fallback to plain clang + sdk_path = ( + "/Applications/Xcode.app/Contents/Developer/Platforms" + "/MacOSX.platform/Developer/SDKs/MacOSX.sdk" + ) + cmds = [] + if os.path.isdir(sdk_path): + cmds.append(["clang", "-O2", "-isysroot", sdk_path, "-o", binary, src]) + cmds.append(["clang", "-O2", "-o", binary, src]) + for cmd in cmds: + ret = subprocess.run(cmd, capture_output=True, text=True) + if ret.returncode == 0: + click.echo("编译完成", err=True) + return binary + raise click.ClickException(f"编译失败: {ret.stderr.strip()}") + + +@cli.command() +@click.option('--force', is_flag=True, help='强制重新扫描(覆盖已有配置)') +def init(force): + """初始化:检测数据目录并扫描加密密钥 + + \b + 首次使用前运行(WeChat 需正在运行): + wx init + 重新扫描密钥(例如微信更新后): + wx init --force + """ + # Check if already initialized + if not force and os.path.exists(CONFIG_FILE): + try: + cfg = json.load(open(CONFIG_FILE, encoding='utf-8')) + db_dir = cfg.get("db_dir", "") + keys_file = cfg.get("keys_file", "all_keys.json") + if not os.path.isabs(keys_file): + keys_file = os.path.join(SCRIPT_DIR, keys_file) + if (db_dir and "your_wxid" not in db_dir + and os.path.isdir(db_dir) + and os.path.exists(keys_file)): + click.echo(f"已初始化,数据目录: {db_dir}") + click.echo("如需重新扫描密钥,使用 --force") + return + except Exception: + pass + + # Step 1: Detect db_dir + click.echo("检测微信数据目录...") + db_dir = _detect_db_dir() + if not db_dir: + raise click.ClickException( + "未能自动检测到微信数据目录\n" + "请手动编辑 config.json 中的 db_dir 字段\n" + "路径格式(macOS): ~/Library/Containers/com.tencent.xinWeChat/..." + "/xwechat_files//db_storage" + ) + click.echo(f"找到数据目录: {db_dir}") + + # Step 2: Compile scanner (macOS only) + if platform.system() == "Darwin": + scanner = _ensure_scanner() + + # Step 3: Run key extraction + keys_file = os.path.join(SCRIPT_DIR, "all_keys.json") + click.echo("扫描加密密钥(需要 sudo 权限)...") + ret = subprocess.run( + ["sudo", scanner], + capture_output=False, # let stdout/stderr pass through + cwd=SCRIPT_DIR, + ) + if ret.returncode != 0: + raise click.ClickException("密钥扫描失败,请确认微信正在运行") + if not os.path.exists(keys_file): + raise click.ClickException(f"扫描完成但未找到输出文件: {keys_file}") + with open(keys_file, encoding='utf-8') as f: + keys = json.load(f) + real_keys = {k: v for k, v in keys.items() if not k.startswith('_')} + click.echo(f"成功提取 {len(real_keys)} 个数据库密钥") + else: + click.echo("非 macOS 系统,请手动运行密钥提取脚本") + + # Step 4: Update config.json + cfg = {} + if os.path.exists(CONFIG_FILE): + try: + cfg = json.load(open(CONFIG_FILE, encoding='utf-8')) + except Exception: + pass + cfg["db_dir"] = db_dir + if "keys_file" not in cfg: + cfg["keys_file"] = "all_keys.json" + if "decrypted_dir" not in cfg: + cfg["decrypted_dir"] = "decrypted" + with open(CONFIG_FILE, "w", encoding='utf-8') as f: + json.dump(cfg, f, indent=4, ensure_ascii=False) + click.echo(f"配置已保存: {CONFIG_FILE}") + click.echo("初始化完成,可以使用 wx sessions / wx history 等命令了") + + +# ─── 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']}") + + +# ─── export ────────────────────────────────────────────────────────────────── + +@cli.command() +@click.argument('chat') +@click.option('--since', default=None, metavar='DATE', help='起始时间 YYYY-MM-DD') +@click.option('--until', default=None, metavar='DATE', help='结束时间 YYYY-MM-DD') +@click.option('-n', '--limit', default=500, show_default=True, help='最多导出条数') +@click.option('-f', '--format', 'fmt', type=click.Choice(['markdown', 'txt', 'json']), + default='markdown', show_default=True, help='输出格式') +@click.option('-o', '--output', default=None, metavar='FILE', help='输出文件(默认 stdout)') +def export(chat, since, until, limit, fmt, output): + """导出聊天记录到文件 + + \b + 示例: + wx export "张三" + wx export "AI群" --since 2026-01-01 --format markdown -o chat.md + wx export "张三" --format json -o chat.json + """ + req = {"cmd": "history", "chat": chat, "limit": limit, "offset": 0} + if since: + req["since"] = _parse_time(since) + if until: + req["until"] = _parse_time(until, is_end=True) + + resp = _send(req, timeout=60) + messages = resp.get("messages", []) + chat_name = resp.get("chat", chat) + is_group = resp.get("is_group", False) + count = len(messages) + + if fmt == 'json': + text = json.dumps(resp, ensure_ascii=False, indent=2) + elif fmt == 'txt': + lines = [f"=== {chat_name}{'[群]' if is_group else ''} ({count} 条) ===\n"] + for m in messages: + sender = f"{m['sender']}: " if m.get('sender') else '' + lines.append(f"[{m['time']}] {sender}{m['content']}") + text = '\n'.join(lines) + else: # markdown + lines = [ + f"# {chat_name}{'(群聊)' if is_group else ''}", + f"\n> 导出 {count} 条消息\n", + ] + for m in messages: + sender_md = f"**{m['sender']}**: " if m.get('sender') else '' + content = m['content'].replace('\n', '\n> ') + lines.append(f"### {m['time']}\n\n{sender_md}{content}\n") + text = '\n'.join(lines) + + if output: + with open(output, 'w', encoding='utf-8') as f: + f.write(text) + click.echo(f"已导出 {count} 条消息到 {output}") + else: + click.echo(text) + + +# ─── 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..db7ed32 --- /dev/null +++ b/wx_daemon.py @@ -0,0 +1,832 @@ +""" +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() +_md5_to_uname: dict[str, str] | None = None +_md5_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 _get_md5_lookup() -> dict[str, str]: + """返回 {md5(username): username},用于全局搜索时从表名反推联系人。""" + global _md5_to_uname + with _md5_lock: + if _md5_to_uname is not None: + return _md5_to_uname + names = _load_names() + _md5_to_uname = {hashlib.md5(u.encode()).hexdigest(): u for u in names} + return _md5_to_uname + + +def _refresh_names() -> None: + """强制刷新联系人缓存(新联系人/新群加入时调用)""" + global _names, _md5_to_uname + with _names_lock: + _names = None + with _md5_lock: + _md5_to_uname = None + _load_names() + _get_md5_lookup() + +# ─── 辅助 ───────────────────────────────────────────────────────────────────── + +_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: + md5_lookup = _get_md5_lookup() + 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 + uname = md5_lookup.get(tname[4:], '') + display = names.get(uname, uname) if uname else '' + targets.append((path, tname, display, uname)) + 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) + chat_display = display or uname or table + 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)