Compare commits

...

74 Commits

Author SHA1 Message Date
hicccc77
0cf8ea8166 chore: bump version to 4.2.0 2026-03-26 23:20:42 +08:00
hicccc77
74b830dd79 chore: update service files and xkey_helper
- xkey_helper: use mach exception port to intercept EXC_BREAKPOINT,
  fixes key capture failure on macOS 26.2
2026-03-26 23:19:08 +08:00
cc
8668c168a7 333 2026-03-26 22:43:59 +08:00
cc
8b8c5f33ce 333 2026-03-26 22:34:50 +08:00
cc
2fcbb026df 222 2026-03-26 22:32:33 +08:00
cc
66ee72380d 222 2026-03-26 22:30:21 +08:00
cc
4f16345351 111 2026-03-26 22:26:33 +08:00
cc
5110618996 再次修复 2026-03-26 22:19:30 +08:00
cc
bf51368cf4 修复密钥问题 2026-03-26 22:16:30 +08:00
cc
d6054745d6 修复macos打包错误 2026-03-26 22:00:42 +08:00
hicccc77
a4731f25f8 chore: update xkey_helper (macOS) with pure semantic scan mode
Always use pure semantic scan mode (KNOWN_RVA=0) regardless of
WeChat version, improving compatibility for versions < 4.1.8.
2026-03-26 21:16:14 +08:00
hicccc77
6c4507e495 fix(ci): remove invalid --no-fail-on-no-release flag from gh release edit 2026-03-26 20:33:18 +08:00
hicccc77
c8e0160d5c fix(ci): use bash shell for Windows packaging steps to avoid PowerShell variable expansion 2026-03-26 20:21:13 +08:00
hicccc77
ac40a81901 fix(ci): pre-release placeholder + fix latest.yml arm64 overwrite
- Add prepare-release job: immediately marks release as pre-release
  with "正在构建中,请勿下载" notice; all build jobs depend on it
- Fix arm64 job channel override: use -c.publish.channel=latest-arm64
  (correct syntax) instead of broken single-quoted CLI arg
- Fix artifactName quoting for both win x64 and arm64 jobs
- Add "Fix latest.yml" step in update-release-notes: downloads x64 exe,
  computes correct sha512/size, uploads latest.yml with --clobber
- Final step in update-release-notes: remove prerelease flag, mark as
  latest official release

Fixes #553
2026-03-26 19:57:45 +08:00
hicccc77
ca38a68a75 fix: 改善错误码 -3001 提示信息并增强 db_storage 路径解析兼容性\n\n- formatInitProtectionError 返回可读的中文错误说明,替代裸错误码\n- resolveDbStoragePath 新增向上查找(最多2级)兜底逻辑\n- 新增 findDbStorageRecursive 递归搜索(最多3层)兜底\n- 解决使用 wx_key 获取密钥后因路径层级不同导致 -3001 报错的问题\n\nFixes #552 2026-03-26 15:08:08 +08:00
hicccc77
64be2dd562 fix: 支持微信 4.0.5+ 新数据目录结构 (Application Support/com.tencent.xinWeChat/2.0b4.0.x)
- dbPathService.autoDetect: 自动枚举版本目录(如 2.0b4.0.9),优先检测新路径
- dbPathService.getDefaultPath: 同步返回新版本路径
- keyServiceMac.resolveXwechatRootFromPath: 兼容新路径标记
- keyServiceMac.getKvcommCandidates: 补充新路径下的 kvcomm 推导

Fixes #551
2026-03-26 12:08:47 +08:00
cc
ea2abb6c72 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-03-25 20:09:53 +08:00
cc
011e2ff37a 修复Action打包问题与渲染层过滤问题 2026-03-25 20:09:48 +08:00
cc
3d1493b0a6 Merge pull request #548 from Leoluis0705/perf/optimize-group-analytics
Perf/optimize group analytics
2026-03-25 19:51:08 +08:00
Leoluis0705
a46b52e603 fix(analytics): 完善群成员分析失败时的错误边界处理与UI展示 2026-03-25 19:15:12 +08:00
Leoluis0705
3c0683b9f8 perf(core): 为底层提取器引入 isSend 标识智能判断,解决大量本地消息及富文本消息引发的性能退化问题 2026-03-25 18:30:24 +08:00
Leoluis0705
3214c2804e feat(group-analytics): 新增并极致优化群成员详细分析与图表呈现功能 2026-03-25 18:24:05 +08:00
hicccc77
83f50cbaee fix: support configurable bind host for HTTP API and fix Windows sherpa-onnx PATH
- fix(#547): HTTP API server now supports configurable bind host (default 127.0.0.1)
  Docker/N8N users can set host to 0.0.0.0 in settings to allow container access.
  Adds httpApiHost config key, UI input in settings, and passes host through
  IPC chain (preload -> main -> httpService).

- fix(#546): Add Windows PATH injection for sherpa-onnx native module
  buildTranscribeWorkerEnv() now adds the sherpa-onnx-win-x64 directory to
  PATH on Windows, fixing 'Could not find sherpa-onnx-node' errors caused
  by missing DLL search path in forked worker processes.
2026-03-25 15:10:16 +08:00
H3CoF6
acec2e95a2 Merge pull request #540 from H3CoF6/main
Dev
2026-03-24 04:45:13 +08:00
H3CoF6
d26e7e78a1 支持appimage,添加安装脚本,更新文档 2026-03-24 04:33:17 +08:00
H3CoF6
77e5c44673 feat: 保存api服务的配置,实现随weflow静默启动 2026-03-24 04:11:34 +08:00
H3CoF6
619cc84d15 feat: api接口新增access_token校验 2026-03-24 03:55:37 +08:00
H3CoF6
22b85439d3 chore: 向下兼容低版本linux 2026-03-24 03:14:44 +08:00
cc
64995c25a8 支持联系人签名、标签分组、地区获取;优化导出效果 2026-03-23 21:46:15 +08:00
cc
1655b5ae78 Merge pull request #528 from BeiChen-CN/main
feat: 导出联系人标签和详细描述
2026-03-23 19:34:57 +08:00
cc
3d3f6d058e Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-03-22 23:34:57 +08:00
cc
104a04c5de 修复Windows hello部分情况下设置失败的问题 2026-03-22 23:34:48 +08:00
姜北尘
e12193aa40 feat: 导出联系人标签和详细描述
扩展联系人读取与导出链路,新增 labels 和 detailDescription 字段的兼容提取,并同步更新通讯录缓存、详情展示与
  JSON/CSV/VCF 导出。
  Close #402
2026-03-22 14:17:19 +08:00
hicccc77
51101387f7 资源文件同步 2026-03-22 11:31:27 +08:00
cc
641a3bf2ab 修复群导出时的错误昵称判定;修复引用样式的一些错误;修复打包问题 2026-03-22 11:25:59 +08:00
cc
58f22f4bb2 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-03-22 10:32:47 +08:00
cc
562eac4249 修复release问题 2026-03-22 10:32:42 +08:00
xuncha
2e1c0e6c54 Merge pull request #524 from hicccc77/dev
Dev
2026-03-22 09:38:40 +08:00
xuncha
7759868664 Merge pull request #523 from xunchahaha:dev
Dev
2026-03-22 09:37:56 +08:00
xuncha
e92df66bef 修复导出页头像缺失 2026-03-22 09:37:19 +08:00
xuncha
354f3fd8e2 修复图片解密失败 2026-03-22 09:18:57 +08:00
xuncha
1201ea33db Merge pull request #521 from BeiChen-CN/main
feat: 支持自定义引用消息样式
2026-03-21 23:00:23 +08:00
姜北尘
f8e99a34c7 feat: 支持自定义引用消息样式
允许用户在设置中切换引用消息与正文的上下顺序,并使聊天页中的引用回复即时按所选样式展示。
  Close#510
2026-03-21 22:26:09 +08:00
hicccc77
1cef17174b chore: 更新资源文件 2026-03-21 21:45:53 +08:00
cc
73cabf2acd 修复闪退问题 2026-03-21 21:41:32 +08:00
cc
49770f9e8d Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-03-21 19:49:50 +08:00
cc
e32261d274 修复闪退问题 2026-03-21 19:49:38 +08:00
hicccc77
3c7a63e616 chore: update wcdb_api related resources 2026-03-21 16:45:35 +08:00
hicccc77
ab7a487e78 fix: escape artifactName template vars in PowerShell for arm64 job 2026-03-21 16:31:09 +08:00
hicccc77
f01e2efd3f fix: arm64 Windows installer distinct filename, fix x64 exe asset filter 2026-03-21 16:18:38 +08:00
cc
3f4a4f7581 修复mac端打包 2026-03-21 16:03:58 +08:00
hicccc77
7f78925bd7 fix: correct module filename for linux/darwin in afterPack sign script 2026-03-21 15:57:33 +08:00
cc
d16423818d Merge pull request #518 from hicccc77/dev
Dev
2026-03-21 15:53:54 +08:00
cc
8cbd3b9625 Merge branch 'main' into dev 2026-03-21 15:53:45 +08:00
hicccc77
9fac12ce3c feat: add Windows arm64 support (wcdb_api + WCDB DLLs, getDllPath arch detection, release CI) 2026-03-21 15:49:44 +08:00
cc
ee050aa5fa 一些修复与优化 2026-03-21 15:39:35 +08:00
cc
a179f13031 更新弹窗自动过滤下载字段 2026-03-21 15:17:41 +08:00
cc
f3fc5760fc 修复一些打包问题 2026-03-21 15:04:48 +08:00
cc
d4e04a003c Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-03-21 14:50:43 +08:00
cc
2604be38f0 朋友圈支持定位解析;导出时表情包支持语义化补充导出 2026-03-21 14:50:40 +08:00
H3CoF6
06a10f77ae Merge pull request #514 from H3CoF6/dev
linux版本增加wayland说明
优化一点点页面显示
---

我发现appimage可以用,之前觉得FUSE导致难以操控微信进程的
重新支持appimage,放弃对deb的打包(等appimage的-1006报错修好后彻底放弃)
2026-03-21 03:45:12 +08:00
H3CoF6
73f1355011 feat: 更新action,放弃deb打包转为更方便和兼容的appimage 2026-03-21 03:15:31 +08:00
H3CoF6
659b9f9680 feat: 设置页面wayland说明和缓存目录展示 2026-03-21 03:05:18 +08:00
H3CoF6
539f854dbf feat: 添加wayland检查和消息弹窗位置失效说明 2026-03-21 02:53:03 +08:00
H3CoF6
45d4e74c98 fix: 修复linux打包后无法正常操作进程的问题 2026-03-21 02:14:38 +08:00
H3CoF6
1d0b101352 Merge pull request #511 from H3CoF6/main
fix:修复linux的一些问题
2026-03-21 00:45:50 +08:00
H3CoF6
ed96eeccee Merge remote-tracking branch 'upstream/dev' 2026-03-21 00:27:33 +08:00
H3CoF6
29d49360f5 feat: 新增语音转文字段错误修复提示 2026-03-21 00:17:43 +08:00
cc
849cac6a40 Merge pull request #509 from hicccc77/dev
Dev
2026-03-20 22:40:09 +08:00
cc
262b3622dd 更新文档描述 2026-03-20 22:39:39 +08:00
xuncha
2692ac2408 Merge pull request #507 from BeiChen-CN/main
fix: 修复 HTTP API 导出 Type 49 链接消息异常
2026-03-20 22:35:23 +08:00
cc
c2502a09a9 优化导出速度,提供可选项优化 2026-03-20 21:43:29 +08:00
姜北尘
2ea7c72fc6 fix: 修复 HTTP API 导出 Type 49 链接消息异常
为 HTTP API 导出重新解析 appmsg 子类型,修复公众号链接被误判为 OTHER 的问题,并补齐导出内容中的 `[链接]` 前缀。

Fixes #300
2026-03-20 21:13:25 +08:00
H3CoF6
6f3b60ef2c fix: 修复linux打包后无法拉起wechat的bug 2026-03-20 06:44:03 +08:00
55 changed files with 13707 additions and 581 deletions

View File

@@ -12,8 +12,27 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
prepare-release:
runs-on: ubuntu-latest
steps:
- name: Mark release as pre-release (building)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
TAG="$GITHUB_REF_NAME"
REPO="$GITHUB_REPOSITORY"
# Create or update the release as a pre-release with a placeholder note
if gh release view "$TAG" --repo "$REPO" > /dev/null 2>&1; then
gh release edit "$TAG" --repo "$REPO" --prerelease --notes $'## ⚠️ 正在自动构建中,请勿下载\n\n各平台安装包正在构建完成后将自动更新本页面并正式发布。\n\n**请勿在此期间下载任何文件。**'
else
gh release create "$TAG" --repo "$REPO" --prerelease --title "$TAG" --notes $'## ⚠️ 正在自动构建中,请勿下载\n\n各平台安装包正在构建完成后将自动更新本页面并正式发布。\n\n**请勿在此期间下载任何文件。**'
fi
release-mac-arm64:
runs-on: macos-14
needs: prepare-release
steps:
- name: Check out git repository
@@ -42,15 +61,16 @@ jobs:
npx tsc
npx vite build
- name: Package and Publish macOS arm64 (unsigned DMG)
- name: Package and Publish macOS arm64 (unsigned DMG + ZIP)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_IDENTITY_AUTO_DISCOVERY: "false"
run: |
npx electron-builder --mac dmg --arm64 --publish always
npx electron-builder --mac --arm64 --publish always
release-linux:
runs-on: ubuntu-latest
needs: prepare-release
steps:
- name: Check out git repository
@@ -87,6 +107,7 @@ jobs:
release:
runs-on: windows-latest
needs: prepare-release
steps:
- name: Check out git repository
@@ -118,8 +139,47 @@ jobs:
- name: Package and Publish
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
npx electron-builder --publish always
npx electron-builder --win nsis --x64 --publish always "-c.artifactName=\${productName}-\${version}-x64-Setup.\${ext}"
release-windows-arm64:
runs-on: windows-latest
needs: prepare-release
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Install Node.js
uses: actions/setup-node@v5
with:
node-version: 24
cache: 'npm'
- name: Install Dependencies
run: npm install
- name: Sync version with tag
shell: bash
run: |
VERSION=${GITHUB_REF_NAME#v}
echo "Syncing package.json version to $VERSION"
npm version $VERSION --no-git-tag-version --allow-same-version
- name: Build Frontend & Type Check
run: |
npx tsc
npx vite build
- name: Package and Publish Windows arm64
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
npx electron-builder --win nsis --arm64 --publish always -c.publish.channel=latest-arm64 "-c.artifactName=\${productName}-\${version}-arm64-Setup.\${ext}"
update-release-notes:
runs-on: ubuntu-latest
@@ -127,8 +187,56 @@ jobs:
- release-mac-arm64
- release-linux
- release
- release-windows-arm64
steps:
- name: Fix latest.yml to point to x64 installer
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
TAG="$GITHUB_REF_NAME"
VERSION="${TAG#v}"
REPO="$GITHUB_REPOSITORY"
# Find the x64 exe asset name
ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)"
X64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("x64.*\\.exe$"))][0] // ""')"
if [ -z "$X64_ASSET" ]; then
X64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("\\.exe$")) | select(test("arm64") | not)][0] // ""')"
fi
if [ -z "$X64_ASSET" ]; then
echo "ERROR: Could not find x64 exe asset"
exit 1
fi
echo "Downloading x64 installer: $X64_ASSET"
gh release download "$TAG" --repo "$REPO" --pattern "$X64_ASSET" --dir /tmp/weflow-x64
SHA512_B64="$(sha512sum "/tmp/weflow-x64/$X64_ASSET" | awk '{print $1}' | xxd -r -p | base64 -w 0)"
SIZE="$(stat -c%s "/tmp/weflow-x64/$X64_ASSET")"
RELEASE_DATE="$(gh release view "$TAG" --repo "$REPO" --json publishedAt -q .publishedAt)"
cat > /tmp/latest.yml <<YMLEOF
version: $VERSION
files:
- url: $X64_ASSET
sha512: $SHA512_B64
size: $SIZE
path: $X64_ASSET
sha512: $SHA512_B64
releaseDate: '$RELEASE_DATE'
YMLEOF
# Strip leading spaces (heredoc indentation)
sed -i 's/^ //' /tmp/latest.yml
cat /tmp/latest.yml
gh release upload "$TAG" --repo "$REPO" /tmp/latest.yml --clobber
echo "latest.yml updated successfully to point to $X64_ASSET"
- name: Generate release notes with platform download links
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -147,10 +255,14 @@ jobs:
echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""'
}
WINDOWS_ASSET="$(pick_asset "\\.exe$")"
WINDOWS_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("x64.*\\.exe$"))][0] // ""')"
if [ -z "$WINDOWS_ASSET" ]; then
WINDOWS_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("\\.exe$")) | select(test("arm64") | not)][0] // ""')"
fi
WINDOWS_ARM64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("arm64.*\\.exe$"))][0] // ""')"
MAC_ASSET="$(pick_asset "\\.dmg$")"
LINUX_DEB_ASSET="$(pick_asset "\\.deb$")"
LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")"
LINUX_APPIMAGE_ASSET="$(pick_asset "\\.AppImage$")"
build_link() {
local name="$1"
@@ -160,9 +272,10 @@ jobs:
}
WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
MAC_URL="$(build_link "$MAC_ASSET")"
LINUX_DEB_URL="$(build_link "$LINUX_DEB_ASSET")"
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
cat > release_notes.md <<EOF
## 更新日志
@@ -172,12 +285,27 @@ jobs:
[点击加入 Telegram 频道](https://t.me/weflow_cc)
## 下载
- Windows Win10+: ${WINDOWS_URL:-$RELEASE_PAGE}
- Windows x64Win10+: ${WINDOWS_URL:-$RELEASE_PAGE}
- Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE}
- macOSM系列芯片: ${MAC_URL:-$RELEASE_PAGE}
- Linux (.deb): ${LINUX_DEB_URL:-$RELEASE_PAGE}
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE}
- linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE}
## macOS 安装提示(未知来源)
- 若打开时提示“来自未知开发者”或“无法验证开发者”,请到「系统设置 -> 隐私与安全性」中允许打开该应用。
- 如果仍被系统拦截,请在终端执行以下命令去除隔离标记:
- xattr -rd com.apple.quarantine /Applications/WeFlow.app
- 执行后重新打开 WeFlow。
> 如果某个平台链接暂时未生成,可进入完整发布页查看全部资源:$RELEASE_PAGE
EOF
gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md
- name: Mark release as published (no longer pre-release)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --latest --draft=false --prerelease=false

1
.gitignore vendored
View File

@@ -70,3 +70,4 @@ resources/wx_send
概述.md
pnpm-lock.yaml
/pnpm-workspace.yaml
wechat-research-site

View File

@@ -43,9 +43,21 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
- HTTP API 接口(供开发者集成)
- 查看完整能力清单:[详细功能](#详细功能清单)
## 支持平台与设备
| 平台 | 设备/架构 | 安装包 |
|------|----------|--------|
| Windows | Windows10+、x64amd64 | `.exe` |
| macOS | Apple SiliconM 系列arm64 | `.dmg` |
| Linux | x64 设备amd64 | `.AppImage``.tar.gz` |
## 快速开始
若你只想使用成品版本,可前往 Release 下载并安装。
若你只想使用成品版本,可前往 [Releases](https://github.com/hicccc77/WeFlow/releases) 下载并安装。
> ArchLinux 用户可以选择 `yay -S weflow` 快速安装
## 详细功能清单
@@ -94,14 +106,8 @@ npm install
# 3. 运行应用(开发模式)
npm run dev
# 4. 打包可执行文件
npm run build
```
打包产物在 `release` 目录下。
## 致谢
- [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架

View File

@@ -1,6 +1,6 @@
# WeFlow HTTP API / Push 文档
WeFlow 提供本地 HTTP API便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件。
WeFlow 提供本地 HTTP API已支持GET 和 POST请求,便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件;也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件。
## 启用方式
@@ -11,17 +11,27 @@ WeFlow 提供本地 HTTP API便于外部脚本或工具读取聊天记录、
- 基础地址:`http://127.0.0.1:5031`
- 可选开启 `主动推送`,检测到新收到的消息后会通过 `GET /api/v1/push/messages` 推送给 SSE 订阅端
**状态记忆**API 服务和主动推送的状态及端口会自动保存,重启 WeFlow 后会自动恢复运行。
## 鉴权规范
**鉴权规范 (Access Token)** 除健康检查接口外,所有 `/api/v1/*` 接口均受 Token 保护。支持三种传参方式(任选其一):
1. **HTTP Header (推荐)**: `Authorization: Bearer <您的Token>`
2. **Query 参数**: `?access_token=<您的Token>`SSE 长连接推荐此方式)
3. **JSON Body**: `{"access_token": "<您的Token>"}`(仅限 POST 请求)
## 接口列表
- `GET /health`
- `GET /api/v1/health`
- `GET /api/v1/push/messages`
- `GET /api/v1/messages`
- `GET /api/v1/messages/new`
- `GET /api/v1/sessions`
- `GET /api/v1/contacts`
- `GET /api/v1/group-members`
- `GET /api/v1/media/*`
- `GET|POST /health`
- `GET|POST /api/v1/health`
- `GET|POST /api/v1/push/messages`
- `GET|POST /api/v1/messages`
- `GET|POST /api/v1/messages/new`
- `GET|POST /api/v1/sessions`
- `GET|POST /api/v1/contacts`
- `GET|POST /api/v1/group-members`
- `GET|POST /api/v1/media/*`
---
@@ -80,7 +90,7 @@ GET /api/v1/push/messages
### 示例
```bash
curl -N "http://127.0.0.1:5031/api/v1/push/messages"
curl -N "http://127.0.0.1:5031/api/v1/push/messages?access_token=YOUR_TOKEN
```
示例事件:
@@ -94,6 +104,8 @@ data: {"event":"message.new","sessionId":"xxx@chatroom","messageKey":"server:123
## 3. 获取消息
> 当使用 POST 时,请将参数放在 JSON Body 中Content-Type: application/json
读取指定会话的消息,支持原始 JSON 和 ChatLab 格式。
**请求**
@@ -231,6 +243,8 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&
## 4. 获取会话列表
> 当使用 POST 时,请将参数放在 JSON Body 中Content-Type: application/json
**请求**
```http
@@ -276,6 +290,8 @@ GET /api/v1/sessions
## 5. 获取联系人列表
> 当使用 POST 时,请将参数放在 JSON Body 中Content-Type: application/json
**请求**
```http
@@ -325,6 +341,8 @@ GET /api/v1/contacts
## 6. 获取群成员列表
> 当使用 POST 时,请将参数放在 JSON Body 中Content-Type: application/json
返回群成员的 `wxid`、群昵称、备注、微信号等信息。
**请求**
@@ -417,6 +435,8 @@ curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&include
## 7. 访问导出媒体
> 当使用 POST 时,请将参数放在 JSON Body 中Content-Type: application/json
通过消息接口启用 `media=1` 后,接口会先把图片、语音、视频、表情导出到本地缓存目录,再返回可访问的 HTTP 地址。
**请求**
@@ -461,19 +481,23 @@ curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif"
### PowerShell
```powershell
Invoke-RestMethod http://127.0.0.1:5031/health
Invoke-RestMethod http://127.0.0.1:5031/api/v1/sessions
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=10"
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1"
$headers = @{ "Authorization" = "Bearer YOUR_TOKEN" }
$body = @{ talker = "wxid_xxx"; limit = 10 } | ConvertTo-Json
Invoke-RestMethod -Uri "http://127.0.0.1:5031/api/v1/messages" -Method POST -Headers $headers -Body $body -ContentType "application/json"
```
### cURL
```bash
curl http://127.0.0.1:5031/health
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1"
curl "http://127.0.0.1:5031/api/v1/contacts?keyword=张三"
curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom"
# GET 带 Token Header
curl -H "Authorization: Bearer YOUR_TOKEN" "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx"
# POST 带 JSON Body
curl -X POST http://127.0.0.1:5031/api/v1/messages \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"talker": "xxx@chatroom", "chatlab": true}'
```
### Python
@@ -482,19 +506,21 @@ curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom"
import requests
BASE_URL = "http://127.0.0.1:5031"
headers = {"Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json"}
messages = requests.get(
f"{BASE_URL}/api/v1/messages",
params={"talker": "xxx@chatroom", "limit": 50}
# POST 方式获取消息
messages = requests.post(
f"{BASE_URL}/api/v1/messages",
json={"talker": "xxx@chatroom", "limit": 50},
headers=headers
).json()
# GET 方式获取群成员
members = requests.get(
f"{BASE_URL}/api/v1/group-members",
params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1}
params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1},
headers=headers
).json()
print(messages)
print(members)
```
---

View File

@@ -36,6 +36,10 @@ import { messagePushService } from './services/messagePushService'
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true
autoUpdater.disableDifferentialDownload = true // 禁用差分更新,强制全量下载
// Windows x64 与 arm64 使用不同更新通道,避免 latest.yml 互相覆盖导致下错架构安装包。
if (process.platform === 'win32' && process.arch === 'arm64') {
autoUpdater.channel = 'latest-arm64'
}
const AUTO_UPDATE_ENABLED =
process.env.AUTO_UPDATE_ENABLED === 'true' ||
process.env.AUTO_UPDATE_ENABLED === '1' ||
@@ -122,6 +126,123 @@ let isDownloadInProgress = false
let downloadProgressHandler: ((progress: any) => void) | null = null
let downloadedHandler: (() => void) | null = null
const normalizeReleaseNotes = (rawReleaseNotes: unknown): string => {
const merged = (() => {
if (typeof rawReleaseNotes === 'string') {
return rawReleaseNotes
}
if (Array.isArray(rawReleaseNotes)) {
return rawReleaseNotes
.map((item) => {
if (!item || typeof item !== 'object') return ''
const note = (item as { note?: unknown }).note
return typeof note === 'string' ? note : ''
})
.filter(Boolean)
.join('\n\n')
}
return ''
})()
if (!merged.trim()) return ''
const normalizeHeadingText = (raw: string): string => {
return raw
.replace(/<[^>]*>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, '\'')
.replace(/&#x27;/gi, '\'')
.toLowerCase()
.replace(/[:]/g, '')
.replace(/\s+/g, '')
.trim()
}
const shouldStripReleaseSection = (headingRaw: string): boolean => {
const heading = normalizeHeadingText(headingRaw)
if (!heading) return false
if (heading.startsWith('下载') || heading.startsWith('download')) return true
if ((heading.includes('macos') || heading.startsWith('mac')) && heading.includes('安装提示')) return true
return false
}
// 兼容 electron-updater 直接返回 HTML 的场景(含 dir/anchor 等标签嵌套)
const removeDownloadSectionFromHtml = (input: string): string => {
const headingPattern = /<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi
const headings: Array<{ start: number; end: number; headingText: string }> = []
let match: RegExpExecArray | null
while ((match = headingPattern.exec(input)) !== null) {
const full = match[0]
headings.push({
start: match.index,
end: match.index + full.length,
headingText: match[2] || ''
})
}
if (headings.length === 0) return input
const rangesToRemove: Array<{ start: number; end: number }> = []
for (let i = 0; i < headings.length; i += 1) {
const current = headings[i]
if (!shouldStripReleaseSection(current.headingText)) continue
const nextStart = i + 1 < headings.length ? headings[i + 1].start : input.length
rangesToRemove.push({ start: current.start, end: nextStart })
}
if (rangesToRemove.length === 0) return input
let output = ''
let cursor = 0
for (const range of rangesToRemove) {
output += input.slice(cursor, range.start)
cursor = range.end
}
output += input.slice(cursor)
return output
}
// 兼容 Markdown 场景Action 最终 release note 模板)
const removeDownloadSectionFromMarkdown = (input: string): string => {
const lines = input.split(/\r?\n/)
const output: string[] = []
let skipSection = false
for (const line of lines) {
const headingMatch = line.match(/^\s*#{1,6}\s*(.+?)\s*$/)
if (headingMatch) {
if (shouldStripReleaseSection(headingMatch[1])) {
skipSection = true
continue
}
if (skipSection) {
skipSection = false
}
}
if (!skipSection) {
output.push(line)
}
}
return output.join('\n')
}
const cleaned = removeDownloadSectionFromMarkdown(removeDownloadSectionFromHtml(merged))
// 兜底:即使没有匹配到标题,也不在弹窗展示 macOS 隔离标记清理命令
.replace(/^[ \t>*-]*`?\s*xattr\s+-[a-z]*d[a-z]*\s+com\.apple\.quarantine[^\n]*`?\s*$/gim, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
return cleaned
}
type AnnualReportYearsLoadStrategy = 'cache' | 'native' | 'hybrid'
type AnnualReportYearsLoadPhase = 'cache' | 'native' | 'scan' | 'done'
@@ -1043,6 +1164,13 @@ function registerIpcHandlers() {
return app.getVersion()
})
ipcMain.handle('app:checkWayland', async () => {
if (process.platform !== 'linux') return false;
const sessionType = process.env.XDG_SESSION_TYPE?.toLowerCase();
return Boolean(process.env.WAYLAND_DISPLAY || sessionType === 'wayland');
})
ipcMain.handle('log:getPath', async () => {
return join(app.getPath('userData'), 'logs', 'wcdb.log')
})
@@ -1114,7 +1242,7 @@ function registerIpcHandlers() {
return {
hasUpdate: true,
version: latestVersion,
releaseNotes: result.updateInfo.releaseNotes as string || ''
releaseNotes: normalizeReleaseNotes(result.updateInfo.releaseNotes)
}
}
}
@@ -1172,7 +1300,7 @@ function registerIpcHandlers() {
try {
console.log('[Update] 开始下载更新...')
await autoUpdater.downloadUpdate()
} catch (error) {
} catch (error: any) {
console.error('[Update] 下载更新失败:', error)
// 失败时清理状态和监听器
isDownloadInProgress = false
@@ -1184,7 +1312,10 @@ function registerIpcHandlers() {
autoUpdater.removeListener('update-downloaded', downloadedHandler)
downloadedHandler = null
}
throw error
// 统一错误提示格式,避免出现 [object Object] 的 JSON 字符串
const errorMessage = error.message || (typeof error === 'string' ? error : JSON.stringify(error))
throw new Error(errorMessage)
}
})
@@ -1459,8 +1590,8 @@ function registerIpcHandlers() {
return await chatService.resolveTransferDisplayNames(chatroomId, payerUsername, receiverUsername)
})
ipcMain.handle('chat:getContacts', async () => {
return await chatService.getContacts()
ipcMain.handle('chat:getContacts', async (_, options?: { lite?: boolean }) => {
return await chatService.getContacts(options)
})
ipcMain.handle('chat:getCachedMessages', async (_, sessionId: string) => {
@@ -2071,6 +2202,13 @@ function registerIpcHandlers() {
return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime)
})
ipcMain.handle(
'groupAnalytics:getGroupMemberAnalytics',
async (_, chatroomId: string, memberUsername: string, startTime?: number, endTime?: number) => {
return groupAnalyticsService.getGroupMemberAnalytics(chatroomId, memberUsername, startTime, endTime)
}
)
ipcMain.handle(
'groupAnalytics:getGroupMemberMessages',
async (
@@ -2501,26 +2639,27 @@ function registerIpcHandlers() {
// 密钥获取
ipcMain.handle('key:autoGetDbKey', async (event) => {
return keyService.autoGetDbKey(180_000, (message, level) => {
return keyService.autoGetDbKey(180_000, (message: string, level: number) => {
event.sender.send('key:dbKeyStatus', { message, level })
})
})
ipcMain.handle('key:autoGetImageKey', async (event, manualDir?: string, wxid?: string) => {
return keyService.autoGetImageKey(manualDir, (message) => {
return keyService.autoGetImageKey(manualDir, (message: string) => {
event.sender.send('key:imageKeyStatus', { message })
}, wxid)
})
ipcMain.handle('key:scanImageKeyFromMemory', async (event, userDir: string) => {
return keyService.autoGetImageKeyByMemoryScan(userDir, (message) => {
return keyService.autoGetImageKeyByMemoryScan(userDir, (message: string) => {
event.sender.send('key:imageKeyStatus', { message })
})
})
// HTTP API 服务
ipcMain.handle('http:start', async (_, port?: number) => {
return httpService.start(port || 5031)
ipcMain.handle('http:start', async (_, port?: number, host?: string) => {
const bindHost = typeof host === 'string' && host.trim() ? host.trim() : '127.0.0.1'
return httpService.start(port || 5031, bindHost)
})
ipcMain.handle('http:stop', async () => {
@@ -2567,7 +2706,7 @@ function checkForUpdatesOnStartup() {
// 通知渲染进程有新版本
mainWindow.webContents.send('app:updateAvailable', {
version: latestVersion,
releaseNotes: result.updateInfo.releaseNotes || ''
releaseNotes: normalizeReleaseNotes(result.updateInfo.releaseNotes)
})
}
}
@@ -2739,6 +2878,8 @@ app.whenReady().then(async () => {
// 启动时检测更新(不阻塞启动)
checkForUpdatesOnStartup()
await httpService.autoStart()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
mainWindow = createWindow()

View File

@@ -63,7 +63,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => {
ipcRenderer.on('app:updateAvailable', (_, info) => callback(info))
return () => ipcRenderer.removeAllListeners('app:updateAvailable')
}
},
checkWayland: () => ipcRenderer.invoke('app:checkWayland'),
},
// 日志
@@ -224,7 +225,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.on('chat:voiceTranscriptPartial', listener)
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
},
getContacts: () => ipcRenderer.invoke('chat:getContacts'),
getContacts: (options?: { lite?: boolean }) => ipcRenderer.invoke('chat:getContacts', options),
getMessage: (sessionId: string, localId: number) =>
ipcRenderer.invoke('chat:getMessage', sessionId, localId),
searchMessages: (keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) =>
@@ -296,6 +297,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
getGroupMemberAnalytics: (chatroomId: string, memberUsername: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMemberAnalytics', chatroomId, memberUsername, startTime, endTime),
getGroupMemberMessages: (
chatroomId: string,
memberUsername: string,
@@ -421,7 +423,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
// HTTP API 服务
http: {
start: (port?: number) => ipcRenderer.invoke('http:start', port),
start: (port?: number, host?: string) => ipcRenderer.invoke('http:start', port, host),
stop: () => ipcRenderer.invoke('http:stop'),
status: () => ipcRenderer.invoke('http:status')
}

View File

@@ -6,7 +6,7 @@ import * as https from 'https'
import * as http from 'http'
import * as fzstd from 'fzstd'
import * as crypto from 'crypto'
import { app, BrowserWindow } from 'electron'
import { app, BrowserWindow, dialog } from 'electron'
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
import { MessageCacheService } from './messageCacheService'
@@ -16,6 +16,7 @@ import { GroupMyMessageCountCacheService, GroupMyMessageCountCacheEntry } from '
import { exportCardDiagnosticsService } from './exportCardDiagnosticsService'
import { voiceTranscribeService } from './voiceTranscribeService'
import { ImageDecryptService } from './imageDecryptService'
import { CONTACT_REGION_LOOKUP_DATA } from './contactRegionLookupData'
import { LRUCache } from '../utils/LRUCache.js'
export interface ChatSession {
@@ -153,10 +154,17 @@ export interface ContactInfo {
remark?: string
nickname?: string
alias?: string
labels?: string[]
detailDescription?: string
region?: string
avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
}
interface GetContactsOptions {
lite?: boolean
}
interface ExportSessionStats {
totalMessages: number
voiceMessages: number
@@ -292,6 +300,22 @@ class ChatService {
private readonly allGroupSessionIdsCacheTtlMs = 5 * 60 * 1000
private groupMyMessageCountCacheScope = ''
private groupMyMessageCountMemoryCache = new Map<string, GroupMyMessageCountCacheEntry>()
private initFailureDialogShown = false
private readonly contactExtendedFieldCandidates = [
'label_list', 'labelList', 'labels', 'label_names', 'labelNames', 'tags', 'tag_list', 'tagList',
'detail_description', 'detailDescription', 'description', 'desc', 'contact_description', 'contactDescription', 'signature', 'sign',
'country', 'province', 'city', 'region',
'profile', 'introduction', 'phone', 'mobile', 'telephone', 'tel', 'vcard', 'card_info', 'cardInfo',
'extra_buffer', 'extraBuffer'
]
private readonly contactExtendedFieldCandidateSet = new Set(this.contactExtendedFieldCandidates.map((name) => name.toLowerCase()))
private contactExtendedSelectableColumns: string[] | null = null
private contactLabelNameMapCache: Map<number, string> | null = null
private contactLabelNameMapCacheAt = 0
private readonly contactLabelNameMapCacheTtlMs = 10 * 60 * 1000
private contactsLoadInFlight: { mode: 'lite' | 'full'; promise: Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> } | null = null
private readonly contactDisplayNameCollator = new Intl.Collator('zh-CN')
private readonly slowGetContactsLogThresholdMs = 1200
constructor() {
this.configService = new ConfigService()
@@ -338,6 +362,55 @@ class ChatService {
return true
}
private extractErrorCode(message?: string): number | null {
const text = String(message || '').trim()
if (!text) return null
const match = text.match(/(?:错误码\s*[:]\s*|\()(-?\d{2,6})(?:\)|\b)/)
if (!match) return null
const parsed = Number(match[1])
return Number.isFinite(parsed) ? parsed : null
}
private toCodeOnlyMessage(rawMessage?: string, fallbackCode = -3999): string {
const code = this.extractErrorCode(rawMessage) ?? fallbackCode
return `错误码: ${code}`
}
private async maybeShowInitFailureDialog(errorMessage: string): Promise<void> {
if (!app.isPackaged) return
if (this.initFailureDialogShown) return
const code = this.extractErrorCode(errorMessage)
if (code === null) return
const isSecurityCode =
code === -101 ||
code === -102 ||
code === -2299 ||
code === -2301 ||
code === -2302 ||
code === -1006 ||
(code <= -2201 && code >= -2212)
if (!isSecurityCode) return
this.initFailureDialogShown = true
const detail = [
`错误码: ${code}`
].join('\n')
try {
await dialog.showMessageBox({
type: 'error',
title: 'WeFlow 启动失败',
message: '启动失败,请反馈错误码。',
detail,
buttons: ['确定'],
noLink: true
})
} catch {
// 弹窗失败不阻断主流程
}
}
/**
* 连接数据库
*/
@@ -362,7 +435,9 @@ class ChatService {
const cleanedWxid = this.cleanAccountDirName(wxid)
const openOk = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
if (!openOk) {
return { success: false, error: 'WCDB 打开失败,请检查路径和密钥' }
const detailedError = this.toCodeOnlyMessage(await wcdbService.getLastInitError())
await this.maybeShowInitFailureDialog(detailedError)
return { success: false, error: detailedError }
}
this.connected = true
@@ -376,7 +451,7 @@ class ChatService {
return { success: true }
} catch (e) {
console.error('ChatService: 连接数据库失败:', e)
return { success: false, error: String(e) }
return { success: false, error: this.toCodeOnlyMessage(String(e), -3998) }
}
}
@@ -1215,25 +1290,61 @@ class ChatService {
/**
* 获取通讯录列表
*/
async getContacts(): Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> {
async getContacts(options?: GetContactsOptions): Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> {
const mode: 'lite' | 'full' = options?.lite ? 'lite' : 'full'
const inFlight = this.contactsLoadInFlight
if (inFlight && (inFlight.mode === mode || (mode === 'lite' && inFlight.mode === 'full'))) {
return await inFlight.promise
}
const promise = this.getContactsInternal(options)
this.contactsLoadInFlight = { mode, promise }
try {
return await promise
} finally {
if (this.contactsLoadInFlight?.promise === promise) {
this.contactsLoadInFlight = null
}
}
}
private async getContactsInternal(options?: GetContactsOptions): Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> {
const isLiteMode = options?.lite === true
const startedAt = Date.now()
const stageDurations: Array<{ stage: string; ms: number }> = []
const captureStage = (stage: string, stageStartedAt: number) => {
stageDurations.push({ stage, ms: Date.now() - stageStartedAt })
}
try {
const connectStartedAt = Date.now()
const connectResult = await this.ensureConnected()
captureStage('ensureConnected', connectStartedAt)
if (!connectResult.success) {
return { success: false, error: connectResult.error }
}
const contactsCompactStartedAt = Date.now()
const contactResult = await wcdbService.getContactsCompact()
captureStage('getContactsCompact', contactsCompactStartedAt)
if (!contactResult.success || !contactResult.contacts) {
console.error('查询联系人失败:', contactResult.error)
return { success: false, error: contactResult.error || '查询联系人失败' }
}
let rows = contactResult.contacts as Record<string, any>[]
if (!isLiteMode) {
const hydrateStartedAt = Date.now()
rows = await this.hydrateContactsWithExtendedFields(rows)
captureStage('hydrateContactsWithExtendedFields', hydrateStartedAt)
}
const rows = contactResult.contacts as Record<string, any>[]
// 获取会话表的最后联系时间用于排序
const sessionsStartedAt = Date.now()
const lastContactTimeMap = new Map<string, number>()
const sessionResult = await wcdbService.getSessions()
captureStage('getSessions', sessionsStartedAt)
if (sessionResult.success && sessionResult.sessions) {
for (const session of sessionResult.sessions as any[]) {
const username = session.username || session.user_name || session.userName || ''
@@ -1245,9 +1356,14 @@ class ChatService {
}
// 转换为ContactInfo
const transformStartedAt = Date.now()
const contacts: (ContactInfo & { lastContactTime: number })[] = []
const excludeNames = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage'])
let contactLabelNameMap = new Map<number, string>()
if (!isLiteMode) {
const labelMapStartedAt = Date.now()
contactLabelNameMap = await this.getContactLabelNameMap()
captureStage('getContactLabelNameMap', labelMapStartedAt)
}
for (const row of rows) {
const username = String(row.username || '').trim()
@@ -1261,7 +1377,7 @@ class ChatService {
type = 'group'
} else if (username.startsWith('gh_')) {
type = 'official'
} else if (localType === 1 && !excludeNames.has(username)) {
} else if (localType === 1 && !FRIEND_EXCLUDE_USERNAMES.has(username)) {
type = 'friend'
} else if (localType === 0 && quanPin) {
type = 'former_friend'
@@ -1270,6 +1386,9 @@ class ChatService {
}
const displayName = row.remark || row.nick_name || row.alias || username
const labels = isLiteMode ? [] : this.parseContactLabels(row, contactLabelNameMap)
const detailDescription = isLiteMode ? '' : this.getContactSignature(row)
const region = isLiteMode ? '' : this.getContactRegion(row)
contacts.push({
username,
@@ -1277,16 +1396,19 @@ class ChatService {
remark: row.remark || undefined,
nickname: row.nick_name || undefined,
alias: row.alias || undefined,
labels: labels.length > 0 ? labels : undefined,
detailDescription: detailDescription || undefined,
region: region || undefined,
avatarUrl: undefined,
type,
lastContactTime: lastContactTimeMap.get(username) || 0
})
}
captureStage('transformContacts', transformStartedAt)
// 按最近联系时间排序
const sortStartedAt = Date.now()
contacts.sort((a, b) => {
const timeA = a.lastContactTime || 0
const timeB = b.lastContactTime || 0
@@ -1295,13 +1417,22 @@ class ChatService {
}
if (timeA && !timeB) return -1
if (!timeA && timeB) return 1
return a.displayName.localeCompare(b.displayName, 'zh-CN')
return this.contactDisplayNameCollator.compare(a.displayName, b.displayName)
})
captureStage('sortContacts', sortStartedAt)
// 移除临时的lastContactTime字段
const finalizeStartedAt = Date.now()
const result = contacts.map(({ lastContactTime, ...rest }) => rest)
captureStage('finalizeResult', finalizeStartedAt)
const totalMs = Date.now() - startedAt
if (totalMs >= this.slowGetContactsLogThresholdMs) {
const stageSummary = stageDurations
.map((item) => `${item.stage}=${item.ms}ms`)
.join(', ')
console.warn(`[ChatService] getContacts(${isLiteMode ? 'lite' : 'full'}) 慢查询 total=${totalMs}ms, ${stageSummary}`)
}
return { success: true, contacts: result }
} catch (e) {
console.error('ChatService: 获取通讯录失败:', e)
@@ -1828,6 +1959,568 @@ class ChatService {
return Number.isFinite(parsed) ? parsed : fallback
}
private hasAnyContactExtendedFieldKey(row: Record<string, any>): boolean {
for (const key of Object.keys(row || {})) {
if (this.contactExtendedFieldCandidateSet.has(String(key || '').toLowerCase())) {
return true
}
}
return false
}
private async hydrateContactsWithExtendedFields(rows: Record<string, any>[]): Promise<Record<string, any>[]> {
if (!Array.isArray(rows) || rows.length === 0) return rows
const hasAnyExtendedFieldKey = rows.some((row) => this.hasAnyContactExtendedFieldKey(row || {}))
if (hasAnyExtendedFieldKey) {
// wcdb_get_contacts_compact 可能只给“部分联系人”返回 extra_buffer。
// 只有在每一行都能拿到可解析的 extra_buffer 时才跳过补偿查询。
const allRowsHaveUsableExtraBuffer = rows.every((row) => this.toExtraBufferBytes(row || {}) !== null)
if (allRowsHaveUsableExtraBuffer) return rows
}
try {
let selectableColumns = this.contactExtendedSelectableColumns
if (!selectableColumns) {
const tableInfoResult = await wcdbService.execQuery('contact', null, 'PRAGMA table_info(contact)')
if (!tableInfoResult.success || !Array.isArray(tableInfoResult.rows)) {
return rows
}
const availableColumns = new Map<string, string>()
for (const tableInfoRow of tableInfoResult.rows as Record<string, any>[]) {
const rawName = tableInfoRow.name ?? tableInfoRow.column_name ?? tableInfoRow.columnName
const name = String(rawName || '').trim()
if (!name) continue
availableColumns.set(name.toLowerCase(), name)
}
const resolvedColumns: string[] = []
const seenColumns = new Set<string>()
for (const candidate of this.contactExtendedFieldCandidates) {
const actual = availableColumns.get(candidate.toLowerCase())
if (!actual) continue
const normalized = actual.toLowerCase()
if (seenColumns.has(normalized)) continue
seenColumns.add(normalized)
resolvedColumns.push(actual)
}
this.contactExtendedSelectableColumns = resolvedColumns
selectableColumns = resolvedColumns
}
if (!selectableColumns || selectableColumns.length === 0) return rows
const selectColumns = ['username', ...selectableColumns]
const sql = `SELECT ${selectColumns.map((column) => this.quoteSqlIdentifier(column)).join(', ')} FROM contact WHERE username IS NOT NULL AND username != ''`
const extendedResult = await wcdbService.execQuery('contact', null, sql)
if (!extendedResult.success || !Array.isArray(extendedResult.rows) || extendedResult.rows.length === 0) {
return rows
}
const extendedByUsername = new Map<string, Record<string, any>>()
for (const extendedRow of extendedResult.rows as Record<string, any>[]) {
const username = String(extendedRow.username || '').trim()
if (!username) continue
extendedByUsername.set(username, extendedRow)
}
if (extendedByUsername.size === 0) return rows
return rows.map((row) => {
const username = String(row.username || row.user_name || row.userName || '').trim()
if (!username) return row
const extended = extendedByUsername.get(username)
if (!extended) return row
return {
...extended,
...row
}
})
} catch (error) {
console.warn('联系人扩展字段补偿查询失败:', error)
return rows
}
}
private async getContactLabelNameMap(): Promise<Map<number, string>> {
const now = Date.now()
if (this.contactLabelNameMapCache && now - this.contactLabelNameMapCacheAt <= this.contactLabelNameMapCacheTtlMs) {
return new Map(this.contactLabelNameMapCache)
}
const labelMap = new Map<number, string>()
try {
const tableInfoResult = await wcdbService.execQuery('contact', null, 'PRAGMA table_info(contact_label)')
if (!tableInfoResult.success || !Array.isArray(tableInfoResult.rows) || tableInfoResult.rows.length === 0) {
this.contactLabelNameMapCache = labelMap
this.contactLabelNameMapCacheAt = now
return labelMap
}
const availableColumns = new Map<string, string>()
for (const tableInfoRow of tableInfoResult.rows as Record<string, any>[]) {
const rawName = tableInfoRow.name ?? tableInfoRow.column_name ?? tableInfoRow.columnName
const name = String(rawName || '').trim()
if (!name) continue
availableColumns.set(name.toLowerCase(), name)
}
const pickColumn = (candidates: string[]): string | null => {
for (const candidate of candidates) {
const actual = availableColumns.get(candidate.toLowerCase())
if (actual) return actual
}
return null
}
const idColumn = pickColumn(['label_id_', 'label_id', 'labelId', 'labelid', 'id'])
const nameColumn = pickColumn(['label_name_', 'label_name', 'labelName', 'labelname', 'name'])
if (!idColumn || !nameColumn) {
this.contactLabelNameMapCache = labelMap
this.contactLabelNameMapCacheAt = now
return labelMap
}
const sql = `SELECT ${this.quoteSqlIdentifier(idColumn)} AS label_id, ${this.quoteSqlIdentifier(nameColumn)} AS label_name FROM contact_label`
const result = await wcdbService.execQuery('contact', null, sql)
if (result.success && Array.isArray(result.rows)) {
for (const row of result.rows as Record<string, any>[]) {
const id = Number(String(row.label_id ?? row.labelId ?? '').trim())
const name = String(row.label_name ?? row.labelName ?? '').trim()
if (Number.isFinite(id) && id > 0 && name) {
labelMap.set(Math.floor(id), name)
}
}
}
} catch (error) {
console.warn('读取 contact_label 失败:', error)
}
this.contactLabelNameMapCache = labelMap
this.contactLabelNameMapCacheAt = now
return new Map(labelMap)
}
private toExtraBufferBytes(row: Record<string, any>): Buffer | null {
const raw = this.getRowField(row, ['extra_buffer', 'extraBuffer'])
if (raw === undefined || raw === null) return null
if (Buffer.isBuffer(raw)) return raw.length > 0 ? raw : null
if (raw instanceof Uint8Array) return raw.length > 0 ? Buffer.from(raw) : null
if (Array.isArray(raw)) {
const bytes = Buffer.from(raw)
return bytes.length > 0 ? bytes : null
}
const text = String(raw || '').trim()
if (!text) return null
const compact = text.replace(/\s+/g, '')
if (compact.length >= 2 && compact.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(compact)) {
try {
const bytes = Buffer.from(compact, 'hex')
return bytes.length > 0 ? bytes : null
} catch {
return null
}
}
return null
}
private readProtoVarint(buffer: Buffer, offset: number): { value: number; nextOffset: number } | null {
if (!buffer || offset < 0 || offset >= buffer.length) return null
let value = 0
let shift = 0
let index = offset
while (index < buffer.length) {
const byte = buffer[index]
index += 1
value += (byte & 0x7f) * Math.pow(2, shift)
if ((byte & 0x80) === 0) {
return { value, nextOffset: index }
}
shift += 7
if (shift > 56) return null
}
return null
}
private extractExtraBufferTopLevelFieldStrings(row: Record<string, any>, targetField: number): string[] {
const bytes = this.toExtraBufferBytes(row)
if (!bytes || !Number.isFinite(targetField) || targetField <= 0) return []
const values: string[] = []
let offset = 0
while (offset < bytes.length) {
const tagResult = this.readProtoVarint(bytes, offset)
if (!tagResult) break
offset = tagResult.nextOffset
const fieldNumber = Math.floor(tagResult.value / 8)
const wireType = tagResult.value & 0x07
if (wireType === 0) {
const varint = this.readProtoVarint(bytes, offset)
if (!varint) break
offset = varint.nextOffset
continue
}
if (wireType === 1) {
if (offset + 8 > bytes.length) break
offset += 8
continue
}
if (wireType === 2) {
const lengthResult = this.readProtoVarint(bytes, offset)
if (!lengthResult) break
const payloadLength = Math.floor(lengthResult.value)
offset = lengthResult.nextOffset
if (payloadLength < 0 || offset + payloadLength > bytes.length) break
const payload = bytes.subarray(offset, offset + payloadLength)
offset += payloadLength
if (fieldNumber === targetField) {
const text = payload.toString('utf-8').replace(/\u0000/g, '').trim()
if (text) values.push(text)
}
continue
}
if (wireType === 5) {
if (offset + 4 > bytes.length) break
offset += 4
continue
}
break
}
return values
}
private parseContactLabelsFromExtraBuffer(row: Record<string, any>, labelNameMap?: Map<number, string>): string[] {
const labelNames: string[] = []
const seen = new Set<string>()
const texts = this.extractExtraBufferTopLevelFieldStrings(row, 30)
for (const text of texts) {
const matches = text.match(/\d+/g) || []
for (const match of matches) {
const id = Number(match)
if (!Number.isFinite(id) || id <= 0) continue
const labelName = labelNameMap?.get(Math.floor(id))
if (!labelName) continue
if (seen.has(labelName)) continue
seen.add(labelName)
labelNames.push(labelName)
}
}
return labelNames
}
private parseContactLabels(row: Record<string, any>, labelNameMap?: Map<number, string>): string[] {
const raw = this.getRowField(row, [
'label_list', 'labelList', 'labels', 'label_names', 'labelNames', 'tags', 'tag_list', 'tagList'
])
const normalizedFromValue = (value: unknown): string[] => {
if (Array.isArray(value)) {
return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean)))
}
const text = String(value || '').trim()
if (!text) return []
return Array.from(new Set(
text
.replace(/[;、|]+/g, ',')
.split(',')
.map((item) => item.trim())
.filter(Boolean)
))
}
const direct = normalizedFromValue(raw)
if (direct.length > 0) return direct
for (const [key, value] of Object.entries(row)) {
const normalizedKey = key.toLowerCase()
if (!normalizedKey.includes('label') && !normalizedKey.includes('tag')) continue
if (normalizedKey.includes('img') || normalizedKey.includes('head')) continue
const fallback = normalizedFromValue(value)
if (fallback.length > 0) return fallback
}
const extraBufferLabels = this.parseContactLabelsFromExtraBuffer(row, labelNameMap)
if (extraBufferLabels.length > 0) return extraBufferLabels
return []
}
private getContactSignature(row: Record<string, any>): string {
const normalize = (raw: unknown): string => {
const text = String(raw || '').replace(/\u0000/g, '').trim()
if (!text) return ''
const lower = text.toLowerCase()
if (lower === '-' || lower === '--' || lower === '—' || lower === 'null' || lower === 'undefined' || lower === 'none') {
return ''
}
return text
}
const value = this.getRowField(row, [
'signature', 'sign', 'personal_signature', 'personalSignature', 'profile', 'introduction',
'detail_description', 'detailDescription', 'description', 'desc', 'contact_description', 'contactDescription'
])
const direct = normalize(value)
if (direct) return direct
for (const [key, rawValue] of Object.entries(row)) {
const normalizedKey = key.toLowerCase()
const isCandidate =
normalizedKey.includes('sign') ||
normalizedKey.includes('signature') ||
normalizedKey.includes('profile') ||
normalizedKey.includes('intro') ||
normalizedKey.includes('description') ||
normalizedKey.includes('detail') ||
normalizedKey.includes('desc')
if (!isCandidate) continue
if (
normalizedKey.includes('avatar') ||
normalizedKey.includes('img') ||
normalizedKey.includes('head') ||
normalizedKey.includes('label') ||
normalizedKey.includes('tag')
) continue
const text = normalize(rawValue)
if (text) return text
}
// contact.extra_buffer field 4: 个性签名兜底
const signatures = this.extractExtraBufferTopLevelFieldStrings(row, 4)
for (const signature of signatures) {
const text = normalize(signature)
if (!text) continue
return text
}
return ''
}
private normalizeContactRegionPart(raw: unknown): string {
const text = String(raw || '').replace(/\u0000/g, '').trim()
if (!text) return ''
const lower = text.toLowerCase()
if (lower === '-' || lower === '--' || lower === '—' || lower === 'null' || lower === 'undefined' || lower === 'none') {
return ''
}
return text
}
private normalizeRegionLookupKey(raw: string): string {
return String(raw || '')
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '')
}
private buildRegionLookupCandidates(raw: string): string[] {
const normalized = this.normalizeRegionLookupKey(raw)
if (!normalized) return []
const candidates = new Set<string>([normalized])
const withoutTrailingDigits = normalized.replace(/\d+$/g, '')
if (withoutTrailingDigits) candidates.add(withoutTrailingDigits)
return Array.from(candidates)
}
private normalizeChineseProvinceName(raw: string): string {
const text = String(raw || '').trim()
if (!text) return ''
return text
.replace(/特别行政区$/g, '')
.replace(/维吾尔自治区$/g, '')
.replace(/壮族自治区$/g, '')
.replace(/回族自治区$/g, '')
.replace(/自治区$/g, '')
.replace(/省$/g, '')
.replace(/市$/g, '')
.trim()
}
private normalizeChineseCityName(raw: string): string {
const text = String(raw || '').trim()
if (!text) return ''
return text
.replace(/特别行政区$/g, '')
.replace(/自治州$/g, '')
.replace(/地区$/g, '')
.replace(/盟$/g, '')
.replace(/林区$/g, '')
.replace(/市$/g, '')
.trim()
}
private resolveProvinceLookupKey(raw: string): string {
const candidates = this.buildRegionLookupCandidates(raw)
if (candidates.length === 0) return ''
for (const candidate of candidates) {
const byName = CONTACT_REGION_LOOKUP_DATA.provinceKeyByName[candidate]
if (byName) return byName
if (CONTACT_REGION_LOOKUP_DATA.provinceNameByKey[candidate]) return candidate
}
return candidates[0]
}
private toChineseCountryName(raw: string): string {
const text = this.normalizeContactRegionPart(raw)
if (!text) return ''
const candidates = this.buildRegionLookupCandidates(text)
for (const candidate of candidates) {
const mapped = CONTACT_REGION_LOOKUP_DATA.countryNameByKey[candidate]
if (mapped) return mapped
}
return text
}
private toChineseProvinceName(raw: string): string {
const text = this.normalizeContactRegionPart(raw)
if (!text) return ''
const candidates = this.buildRegionLookupCandidates(text)
if (candidates.length === 0) return text
const provinceKey = this.resolveProvinceLookupKey(text)
const mappedFromCandidates = candidates
.map((candidate) => CONTACT_REGION_LOOKUP_DATA.provinceNameByKey[candidate])
.find(Boolean)
const mapped = CONTACT_REGION_LOOKUP_DATA.provinceNameByKey[provinceKey] || mappedFromCandidates
if (mapped) return mapped
if (/[\u4e00-\u9fa5]/.test(text)) {
return this.normalizeChineseProvinceName(text) || text
}
return text
}
private toChineseCityName(raw: string, provinceRaw?: string): string {
const text = this.normalizeContactRegionPart(raw)
if (!text) return ''
const candidates = this.buildRegionLookupCandidates(text)
if (candidates.length === 0) return text
const provinceKey = this.resolveProvinceLookupKey(String(provinceRaw || ''))
if (provinceKey) {
const byProvince = CONTACT_REGION_LOOKUP_DATA.cityNameByProvinceKey[provinceKey]
if (byProvince) {
for (const candidate of candidates) {
const mappedInProvince = byProvince[candidate]
if (mappedInProvince) return mappedInProvince
}
}
}
for (const candidate of candidates) {
const mapped = CONTACT_REGION_LOOKUP_DATA.cityNameByKey[candidate]
if (mapped) return mapped
}
if (/[\u4e00-\u9fa5]/.test(text)) {
return this.normalizeChineseCityName(text) || text
}
return text
}
private toChineseRegionText(raw: string): string {
const text = this.normalizeContactRegionPart(raw)
if (!text) return ''
const tokens = text
.split(/[\s,,、/|·]+/)
.map((item) => this.normalizeContactRegionPart(item))
.filter(Boolean)
if (tokens.length === 0) return text
let provinceContext = ''
const mapped = tokens.map((token) => {
const country = this.toChineseCountryName(token)
if (country !== token) return country
const province = this.toChineseProvinceName(token)
if (province !== token) {
provinceContext = province
return province
}
const city = this.toChineseCityName(token, provinceContext)
if (city !== token) return city
return token
})
return mapped.join(' ').trim()
}
private shouldHideCountryInRegion(country: string, hasProvinceOrCity: boolean): boolean {
if (!country) return true
const normalized = country.toLowerCase()
if (normalized === 'cn' || normalized === 'chn' || normalized === 'china' || normalized === '中国') {
return hasProvinceOrCity
}
return false
}
private getContactRegion(row: Record<string, any>): string {
const pickByTokens = (tokens: string[]): string => {
for (const [key, value] of Object.entries(row || {})) {
const normalizedKey = String(key || '').toLowerCase()
if (!normalizedKey) continue
if (normalizedKey.includes('avatar') || normalizedKey.includes('img') || normalizedKey.includes('head')) continue
if (!tokens.some((token) => normalizedKey.includes(token))) continue
const text = this.normalizeContactRegionPart(value)
if (text) return text
}
return ''
}
const directCountry = this.normalizeContactRegionPart(this.getRowField(row, ['country', 'Country'])) || pickByTokens(['country'])
const directProvince = this.normalizeContactRegionPart(this.getRowField(row, ['province', 'Province'])) || pickByTokens(['province'])
const directCity = this.normalizeContactRegionPart(this.getRowField(row, ['city', 'City'])) || pickByTokens(['city'])
const directRegion =
this.normalizeContactRegionPart(this.getRowField(row, ['region', 'Region', 'location', 'area'])) ||
pickByTokens(['region', 'location', 'area', 'addr', 'address'])
if (directRegion) {
const normalizedRegion = this.toChineseRegionText(directRegion)
const parts = normalizedRegion
.split(/\s+/)
.map((item) => this.normalizeContactRegionPart(item))
.filter(Boolean)
if (parts.length > 1 && this.shouldHideCountryInRegion(parts[0], true)) {
return parts.slice(1).join(' ').trim()
}
return normalizedRegion
}
const fallbackCountry = this.normalizeContactRegionPart(this.extractExtraBufferTopLevelFieldStrings(row, 5)[0] || '')
const fallbackProvince = this.normalizeContactRegionPart(this.extractExtraBufferTopLevelFieldStrings(row, 6)[0] || '')
const fallbackCity = this.normalizeContactRegionPart(this.extractExtraBufferTopLevelFieldStrings(row, 7)[0] || '')
const country = this.toChineseCountryName(directCountry || fallbackCountry)
const province = this.toChineseProvinceName(directProvince || fallbackProvince)
const city = this.toChineseCityName(directCity || fallbackCity, directProvince || fallbackProvince)
const hasProvinceOrCity = Boolean(province || city)
const parts: string[] = []
if (!this.shouldHideCountryInRegion(country, hasProvinceOrCity)) {
parts.push(country)
}
if (province) {
parts.push(province)
}
if (city && city !== province) {
parts.push(city)
}
return parts.join(' ').trim()
}
private normalizeUnsignedIntegerToken(raw: any): string | undefined {
if (raw === undefined || raw === null || raw === '') return undefined
@@ -5003,7 +5696,17 @@ class ChatService {
const contact = await this.getContact(username)
const avatarResult = await wcdbService.getAvatarUrls([username])
const avatarUrl = avatarResult.success && avatarResult.map ? avatarResult.map[username] : undefined
let avatarUrl = avatarResult.success && avatarResult.map ? avatarResult.map[username] : undefined
if (!this.isValidAvatarUrl(avatarUrl)) {
avatarUrl = undefined
}
if (!avatarUrl) {
const headImageAvatars = await this.getAvatarsFromHeadImageDb([username])
const fallbackAvatarUrl = headImageAvatars[username]
if (this.isValidAvatarUrl(fallbackAvatarUrl)) {
avatarUrl = fallbackAvatarUrl
}
}
const displayName = contact?.remark || contact?.nickName || contact?.alias || cached?.displayName || username
const cacheEntry: ContactCacheEntry = {
avatarUrl,
@@ -5034,14 +5737,35 @@ class ChatService {
}
// 如果是群聊,尝试获取群昵称
let groupNicknames: Record<string, string> = {}
const groupNicknames = new Map<string, string>()
if (chatroomId.endsWith('@chatroom')) {
const nickResult = await wcdbService.getGroupNicknames(chatroomId)
if (nickResult.success && nickResult.nicknames) {
groupNicknames = nickResult.nicknames
const nicknameBuckets = new Map<string, Set<string>>()
for (const [memberIdRaw, nicknameRaw] of Object.entries(nickResult.nicknames)) {
const memberId = String(memberIdRaw || '').trim().toLowerCase()
const nickname = String(nicknameRaw || '').trim()
if (!memberId || !nickname) continue
const slot = nicknameBuckets.get(memberId)
if (slot) {
slot.add(nickname)
} else {
nicknameBuckets.set(memberId, new Set([nickname]))
}
}
for (const [memberId, nicknameSet] of nicknameBuckets.entries()) {
if (nicknameSet.size !== 1) continue
groupNicknames.set(memberId, Array.from(nicknameSet)[0])
}
}
}
const lookupGroupNickname = (username?: string | null): string => {
const key = String(username || '').trim().toLowerCase()
if (!key) return ''
return groupNicknames.get(key) || ''
}
// 获取当前用户 wxid用于识别"自己"
const myWxid = this.configService.get('myWxid')
const cleanedMyWxid = myWxid ? this.cleanAccountDirName(myWxid) : ''
@@ -5051,7 +5775,7 @@ class ChatService {
// 特判如果是当前用户自己contact 表通常不包含自己)
if (myWxid && (username === myWxid || username === cleanedMyWxid)) {
// 先查群昵称中是否有自己
const myGroupNick = groupNicknames[username]
const myGroupNick = lookupGroupNickname(username) || lookupGroupNickname(myWxid)
if (myGroupNick) return myGroupNick
// 尝试从缓存获取自己的昵称
const cached = this.avatarCache.get(username) || this.avatarCache.get(myWxid)
@@ -5060,7 +5784,7 @@ class ChatService {
}
// 先查群昵称
const groupNick = groupNicknames[username]
const groupNick = lookupGroupNickname(username)
if (groupNick) return groupNick
// 再查联系人信息
@@ -5471,6 +6195,13 @@ class ChatService {
avatarUrl = avatarCandidate
}
}
if (!avatarUrl) {
const headImageAvatars = await this.getAvatarsFromHeadImageDb([normalizedSessionId])
const fallbackAvatarUrl = headImageAvatars[normalizedSessionId]
if (this.isValidAvatarUrl(fallbackAvatarUrl)) {
avatarUrl = fallbackAvatarUrl
}
}
if (!Number.isFinite(messageCount)) {
messageCount = messageCountResult.status === 'fulfilled' &&

View File

@@ -34,6 +34,7 @@ interface ConfigSchema {
autoTranscribeVoice: boolean
transcribeLanguages: string[]
exportDefaultConcurrency: number
exportDefaultImageDeepSearchOnMiss: boolean
analyticsExcludedUsernames: string[]
// 安全相关
@@ -51,12 +52,17 @@ interface ConfigSchema {
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
notificationFilterList: string[]
messagePushEnabled: boolean
httpApiEnabled: boolean
httpApiPort: number
httpApiHost: string
httpApiToken: string
windowCloseBehavior: 'ask' | 'tray' | 'quit'
quoteLayout: 'quote-top' | 'quote-bottom'
wordCloudExcludeWords: string[]
}
// 需要 safeStorage 加密的字段(普通模式)
const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword'])
const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword', 'httpApiToken'])
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
@@ -106,6 +112,7 @@ export class ConfigService {
autoTranscribeVoice: false,
transcribeLanguages: ['zh'],
exportDefaultConcurrency: 4,
exportDefaultImageDeepSearchOnMiss: true,
analyticsExcludedUsernames: [],
authEnabled: false,
authPassword: '',
@@ -116,8 +123,13 @@ export class ConfigService {
notificationPosition: 'top-right',
notificationFilterMode: 'all',
notificationFilterList: [],
httpApiToken: '',
httpApiEnabled: false,
httpApiPort: 5031,
httpApiHost: '127.0.0.1',
messagePushEnabled: false,
windowCloseBehavior: 'ask',
quoteLayout: 'quote-top',
wordCloudExcludeWords: []
}
@@ -658,11 +670,9 @@ export class ConfigService {
// 即使 authEnabled 被删除/篡改,如果密钥是 lock: 格式,说明曾开启过应用锁
const rawDecryptKey: any = this.store.get('decryptKey')
if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) {
return true
}
return typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX);
return false
}
// === 工具方法 ===

View File

@@ -93,6 +93,9 @@ class ContactExportService {
displayName: c.displayName,
remark: c.remark,
nickname: c.nickname,
alias: c.alias,
labels: Array.isArray(c.labels) ? c.labels : [],
detailDescription: c.detailDescription,
type: c.type
}))
}
@@ -103,12 +106,15 @@ class ContactExportService {
* 导出为CSV格式
*/
private async exportToCSV(contacts: any[], outputPath: string): Promise<void> {
const headers = ['用户名', '显示名称', '备注', '昵称', '类型']
const headers = ['用户名', '显示名称', '备注', '昵称', '微信号', '标签', '详细描述', '类型']
const rows = contacts.map(c => [
c.username || '',
c.displayName || '',
c.remark || '',
c.nickname || '',
c.alias || '',
Array.isArray(c.labels) ? c.labels.join(' | ') : '',
c.detailDescription || '',
this.getTypeLabel(c.type)
])
@@ -137,9 +143,13 @@ class ContactExportService {
lines.push(`NICKNAME:${c.nickname}`)
}
// 备注
if (c.remark) {
lines.push(`NOTE:${c.remark}`)
const noteParts = [
c.remark ? String(c.remark) : '',
Array.isArray(c.labels) && c.labels.length > 0 ? `标签: ${c.labels.join(', ')}` : '',
c.detailDescription ? `详细描述: ${c.detailDescription}` : ''
].filter(Boolean)
if (noteParts.length > 0) {
lines.push(`NOTE:${noteParts.join('\\n')}`)
}
// 微信ID

File diff suppressed because it is too large Load Diff

View File

@@ -93,27 +93,39 @@ export class DbPathService {
const possiblePaths: string[] = []
const home = homedir()
// macOS 微信路径(固定)
if (process.platform === 'darwin') {
// macOS 微信 4.0.5+ 新路径(优先检测)
const appSupportBase = join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Library', 'Application Support', 'com.tencent.xinWeChat')
if (existsSync(appSupportBase)) {
try {
const entries = readdirSync(appSupportBase)
for (const entry of entries) {
// 匹配形如 2.0b4.0.9 的版本目录
if (/^\d+\.\d+b\d+\.\d+/.test(entry) || /^\d+\.\d+\.\d+/.test(entry)) {
possiblePaths.push(join(appSupportBase, entry))
}
}
} catch { }
}
// macOS 旧路径兜底
possiblePaths.push(join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files'))
} else {
// Windows 微信4.x 数据目录
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
}
for (const path of possiblePaths) {
if (existsSync(path)) {
const rootName = path.split(/[/\\]/).pop()?.toLowerCase()
if (rootName !== 'xwechat_files' && rootName !== 'wechat files') {
continue
}
if (!existsSync(path)) continue
// 检查是否有有效的账号目录
const accounts = this.findAccountDirs(path)
if (accounts.length > 0) {
return { success: true, path }
}
// 检查是否有有效的账号目录,或本身就是账号目录
const accounts = this.findAccountDirs(path)
if (accounts.length > 0) {
return { success: true, path }
}
// 如果该目录本身就是账号目录(直接包含 db_storage 等)
if (this.isAccountDir(path)) {
return { success: true, path }
}
}
@@ -295,6 +307,20 @@ export class DbPathService {
getDefaultPath(): string {
const home = homedir()
if (process.platform === 'darwin') {
// 优先返回 4.0.5+ 新路径
const appSupportBase = join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Library', 'Application Support', 'com.tencent.xinWeChat')
if (existsSync(appSupportBase)) {
try {
const entries = readdirSync(appSupportBase)
for (const entry of entries) {
if (/^\d+\.\d+b\d+\.\d+/.test(entry) || /^\d+\.\d+\.\d+/.test(entry)) {
const candidate = join(appSupportBase, entry)
if (existsSync(candidate)) return candidate
}
}
} catch { }
}
// 旧版本路径兜底
return join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files')
}
return join(home, 'Documents', 'xwechat_files')

View File

@@ -105,6 +105,7 @@ export interface ExportOptions {
sessionNameWithTypePrefix?: boolean
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
exportConcurrency?: number
imageDeepSearchOnMiss?: boolean
}
const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [
@@ -259,12 +260,20 @@ class ExportService {
private mediaFileCacheReadyDirs = new Set<string>()
private mediaExportTelemetry: MediaExportTelemetry | null = null
private mediaRunSourceDedupMap = new Map<string, string>()
private mediaRunMissingImageKeys = new Set<string>()
private mediaFileCacheCleanupPending: Promise<void> | null = null
private mediaFileCacheLastCleanupAt = 0
private readonly mediaFileCacheCleanupIntervalMs = 30 * 60 * 1000
private readonly mediaFileCacheMaxBytes = 6 * 1024 * 1024 * 1024
private readonly mediaFileCacheMaxFiles = 120000
private readonly mediaFileCacheTtlMs = 45 * 24 * 60 * 60 * 1000
private emojiCaptionCache = new Map<string, string | null>()
private emojiCaptionPending = new Map<string, Promise<string | null>>()
private emojiMd5ByCdnCache = new Map<string, string | null>()
private emojiMd5ByCdnPending = new Map<string, Promise<string | null>>()
private emoticonDbPathCache: string | null = null
private emoticonDbPathCacheToken = ''
private readonly emojiCaptionLookupConcurrency = 8
constructor() {
this.configService = new ConfigService()
@@ -517,11 +526,13 @@ class ExportService {
private resetMediaRuntimeState(): void {
this.mediaExportTelemetry = this.createEmptyMediaTelemetry()
this.mediaRunSourceDedupMap.clear()
this.mediaRunMissingImageKeys.clear()
}
private clearMediaRuntimeState(): void {
this.mediaExportTelemetry = null
this.mediaRunSourceDedupMap.clear()
this.mediaRunMissingImageKeys.clear()
}
private getMediaTelemetrySnapshot(): Partial<ExportProgress> {
@@ -915,7 +926,7 @@ class ExportService {
private shouldDecodeMessageContentInFastMode(localType: number): boolean {
// 这些类型在文本导出里只需要占位符,无需解码完整 XML / 压缩内容
if (localType === 3 || localType === 34 || localType === 42 || localType === 43 || localType === 47) {
if (localType === 3 || localType === 34 || localType === 42 || localType === 43) {
return false
}
return true
@@ -989,6 +1000,312 @@ class ExportService {
return `${localType}_${this.getStableMessageKey(msg)}`
}
private getImageMissingRunCacheKey(
sessionId: string,
imageMd5?: unknown,
imageDatName?: unknown,
imageDeepSearchOnMiss = true
): string | null {
const normalizedSessionId = String(sessionId || '').trim()
const normalizedImageMd5 = String(imageMd5 || '').trim().toLowerCase()
const normalizedImageDatName = String(imageDatName || '').trim().toLowerCase()
if (!normalizedSessionId) return null
if (!normalizedImageMd5 && !normalizedImageDatName) return null
const primaryToken = normalizedImageMd5 || normalizedImageDatName
const secondaryToken = normalizedImageMd5 && normalizedImageDatName && normalizedImageDatName !== normalizedImageMd5
? normalizedImageDatName
: ''
const lookupMode = imageDeepSearchOnMiss ? 'deep' : 'hardlink'
return `${lookupMode}\u001f${normalizedSessionId}\u001f${primaryToken}\u001f${secondaryToken}`
}
private normalizeEmojiMd5(value: unknown): string | undefined {
const md5 = String(value || '').trim().toLowerCase()
if (!/^[a-f0-9]{32}$/.test(md5)) return undefined
return md5
}
private normalizeEmojiCaption(value: unknown): string | null {
const caption = String(value || '').trim()
if (!caption) return null
return caption
}
private formatEmojiSemanticText(caption?: string | null): string {
const normalizedCaption = this.normalizeEmojiCaption(caption)
if (!normalizedCaption) return '[表情包]'
return `[表情包:${normalizedCaption}]`
}
private extractLooseHexMd5(content: string): string | undefined {
if (!content) return undefined
const keyedMatch =
/(?:emoji|sticker|md5)[^a-fA-F0-9]{0,32}([a-fA-F0-9]{32})/i.exec(content) ||
/([a-fA-F0-9]{32})/i.exec(content)
return this.normalizeEmojiMd5(keyedMatch?.[1] || keyedMatch?.[0])
}
private normalizeEmojiCdnUrl(value: unknown): string | undefined {
let url = String(value || '').trim()
if (!url) return undefined
url = url.replace(/&amp;/g, '&')
try {
if (url.includes('%')) {
url = decodeURIComponent(url)
}
} catch {
// keep original URL if decoding fails
}
return url.trim() || undefined
}
private resolveStrictEmoticonDbPath(): string | null {
const dbPath = String(this.configService.get('dbPath') || '').trim()
const rawWxid = String(this.configService.get('myWxid') || '').trim()
const cleanedWxid = this.cleanAccountDirName(rawWxid)
const token = `${dbPath}::${rawWxid}::${cleanedWxid}`
if (token === this.emoticonDbPathCacheToken) {
return this.emoticonDbPathCache
}
this.emoticonDbPathCacheToken = token
this.emoticonDbPathCache = null
const dbStoragePath =
this.resolveDbStoragePathForExport(dbPath, cleanedWxid) ||
this.resolveDbStoragePathForExport(dbPath, rawWxid)
if (!dbStoragePath) return null
const strictPath = path.join(dbStoragePath, 'emoticon', 'emoticon.db')
if (fs.existsSync(strictPath)) {
this.emoticonDbPathCache = strictPath
return strictPath
}
return null
}
private resolveDbStoragePathForExport(basePath: string, wxid: string): string | null {
if (!basePath) return null
const normalized = basePath.replace(/[\\/]+$/, '')
if (normalized.toLowerCase().endsWith('db_storage') && fs.existsSync(normalized)) {
return normalized
}
const direct = path.join(normalized, 'db_storage')
if (fs.existsSync(direct)) {
return direct
}
if (!wxid) return null
const viaWxid = path.join(normalized, wxid, 'db_storage')
if (fs.existsSync(viaWxid)) {
return viaWxid
}
try {
const entries = fs.readdirSync(normalized)
const lowerWxid = wxid.toLowerCase()
const candidates = entries.filter((entry) => {
const entryPath = path.join(normalized, entry)
try {
if (!fs.statSync(entryPath).isDirectory()) return false
} catch {
return false
}
const lowerEntry = entry.toLowerCase()
return lowerEntry === lowerWxid || lowerEntry.startsWith(`${lowerWxid}_`)
})
for (const entry of candidates) {
const candidate = path.join(normalized, entry, 'db_storage')
if (fs.existsSync(candidate)) {
return candidate
}
}
} catch {
// keep null
}
return null
}
private async queryEmojiMd5ByCdnUrlFallback(cdnUrlRaw: string): Promise<string | null> {
const cdnUrl = this.normalizeEmojiCdnUrl(cdnUrlRaw)
if (!cdnUrl) return null
const emoticonDbPath = this.resolveStrictEmoticonDbPath()
if (!emoticonDbPath) return null
const candidates = Array.from(new Set([
cdnUrl,
cdnUrl.replace(/&/g, '&amp;')
]))
for (const candidate of candidates) {
const escaped = candidate.replace(/'/g, "''")
const result = await wcdbService.execQuery(
'message',
emoticonDbPath,
`SELECT md5, lower(hex(md5)) AS md5_hex FROM kNonStoreEmoticonTable WHERE cdn_url = '${escaped}' COLLATE NOCASE LIMIT 1`
)
const row = result.success && Array.isArray(result.rows) ? result.rows[0] : null
const md5 = this.normalizeEmojiMd5(this.getRowField(row || {}, ['md5', 'md5_hex']))
if (md5) return md5
}
return null
}
private async getEmojiMd5ByCdnUrl(cdnUrlRaw: string): Promise<string | null> {
const cdnUrl = this.normalizeEmojiCdnUrl(cdnUrlRaw)
if (!cdnUrl) return null
if (this.emojiMd5ByCdnCache.has(cdnUrl)) {
return this.emojiMd5ByCdnCache.get(cdnUrl) ?? null
}
const pending = this.emojiMd5ByCdnPending.get(cdnUrl)
if (pending) return pending
const task = (async (): Promise<string | null> => {
try {
return await this.queryEmojiMd5ByCdnUrlFallback(cdnUrl)
} catch {
return null
}
})()
this.emojiMd5ByCdnPending.set(cdnUrl, task)
try {
const md5 = await task
this.emojiMd5ByCdnCache.set(cdnUrl, md5)
return md5
} finally {
this.emojiMd5ByCdnPending.delete(cdnUrl)
}
}
private async getEmojiCaptionByMd5(md5Raw: string): Promise<string | null> {
const md5 = this.normalizeEmojiMd5(md5Raw)
if (!md5) return null
if (this.emojiCaptionCache.has(md5)) {
return this.emojiCaptionCache.get(md5) ?? null
}
const pending = this.emojiCaptionPending.get(md5)
if (pending) return pending
const task = (async (): Promise<string | null> => {
try {
const nativeResult = await wcdbService.getEmoticonCaptionStrict(md5)
if (nativeResult.success) {
const nativeCaption = this.normalizeEmojiCaption(nativeResult.caption)
if (nativeCaption) return nativeCaption
}
} catch {
// ignore and return null
}
return null
})()
this.emojiCaptionPending.set(md5, task)
try {
const caption = await task
if (caption) {
this.emojiCaptionCache.set(md5, caption)
} else {
this.emojiCaptionCache.delete(md5)
}
return caption
} finally {
this.emojiCaptionPending.delete(md5)
}
}
private async hydrateEmojiCaptionsForMessages(
sessionId: string,
messages: any[],
control?: ExportTaskControl
): Promise<void> {
if (!Array.isArray(messages) || messages.length === 0) return
// 某些环境下游标行缺失 47 的 md5先按 localId 回填详情再做 caption 查询。
await this.backfillMediaFieldsFromMessageDetail(sessionId, messages, new Set([47]), control)
const unresolvedByUrl = new Map<string, any[]>()
const uniqueMd5s = new Set<string>()
let scanIndex = 0
for (const msg of messages) {
if ((scanIndex++ & 0x7f) === 0) {
this.throwIfStopRequested(control)
}
if (Number(msg?.localType) !== 47) continue
const content = String(msg?.content || '')
const normalizedMd5 = this.normalizeEmojiMd5(msg?.emojiMd5)
|| this.extractEmojiMd5(content)
|| this.extractLooseHexMd5(content)
const normalizedCdnUrl = this.normalizeEmojiCdnUrl(msg?.emojiCdnUrl || this.extractEmojiUrl(content))
if (normalizedCdnUrl) {
msg.emojiCdnUrl = normalizedCdnUrl
}
if (!normalizedMd5) {
if (normalizedCdnUrl) {
const bucket = unresolvedByUrl.get(normalizedCdnUrl) || []
bucket.push(msg)
unresolvedByUrl.set(normalizedCdnUrl, bucket)
} else {
msg.emojiMd5 = undefined
msg.emojiCaption = undefined
}
continue
}
msg.emojiMd5 = normalizedMd5
uniqueMd5s.add(normalizedMd5)
}
const unresolvedUrls = Array.from(unresolvedByUrl.keys())
if (unresolvedUrls.length > 0) {
await parallelLimit(unresolvedUrls, this.emojiCaptionLookupConcurrency, async (url, index) => {
if ((index & 0x0f) === 0) {
this.throwIfStopRequested(control)
}
const resolvedMd5 = await this.getEmojiMd5ByCdnUrl(url)
if (!resolvedMd5) return
const attached = unresolvedByUrl.get(url) || []
for (const msg of attached) {
msg.emojiMd5 = resolvedMd5
uniqueMd5s.add(resolvedMd5)
}
})
}
const md5List = Array.from(uniqueMd5s)
if (md5List.length > 0) {
await parallelLimit(md5List, this.emojiCaptionLookupConcurrency, async (md5, index) => {
if ((index & 0x0f) === 0) {
this.throwIfStopRequested(control)
}
await this.getEmojiCaptionByMd5(md5)
})
}
let assignIndex = 0
for (const msg of messages) {
if ((assignIndex++ & 0x7f) === 0) {
this.throwIfStopRequested(control)
}
if (Number(msg?.localType) !== 47) continue
const md5 = this.normalizeEmojiMd5(msg?.emojiMd5)
if (!md5) {
msg.emojiCaption = undefined
continue
}
const caption = this.emojiCaptionCache.get(md5) ?? null
msg.emojiCaption = caption || undefined
}
}
private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> {
const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath')
@@ -1087,33 +1404,60 @@ class ExportService {
}
/**
* 获取群成员群昵称。优先使用 DLL必要时回退到 `contact.chat_room.ext_buffer` 解析
* 获取群成员群昵称。后端结果为唯一业务真值,前端仅做冲突净化防串号
*/
async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
const nicknameMap = new Map<string, string>()
try {
const dllResult = await wcdbService.getGroupNicknames(chatroomId)
if (dllResult.success && dllResult.nicknames) {
this.mergeGroupNicknameEntries(nicknameMap, Object.entries(dllResult.nicknames))
if (!dllResult.success || !dllResult.nicknames) {
return new Map<string, string>()
}
return this.buildTrustedGroupNicknameMap(Object.entries(dllResult.nicknames), candidates)
} catch (e) {
console.error('getGroupNicknamesForRoom dll error:', e)
return new Map<string, string>()
}
}
private normalizeGroupNicknameIdentity(value: string): string {
const raw = String(value || '').trim()
if (!raw) return ''
return raw.toLowerCase()
}
private buildTrustedGroupNicknameMap(
entries: Iterable<[string, string]>,
candidates: string[] = []
): Map<string, string> {
const candidateSet = new Set(
this.buildGroupNicknameIdCandidates(candidates)
.map((id) => this.normalizeGroupNicknameIdentity(id))
.filter(Boolean)
)
const buckets = new Map<string, Set<string>>()
for (const [memberIdRaw, nicknameRaw] of entries) {
const identity = this.normalizeGroupNicknameIdentity(memberIdRaw || '')
if (!identity) continue
if (candidateSet.size > 0 && !candidateSet.has(identity)) continue
const nickname = this.normalizeGroupNickname(nicknameRaw || '')
if (!nickname) continue
const slot = buckets.get(identity)
if (slot) {
slot.add(nickname)
} else {
buckets.set(identity, new Set([nickname]))
}
}
try {
const result = await wcdbService.getChatRoomExtBuffer(chatroomId)
if (!result.success || !result.extBuffer) {
return nicknameMap
}
const extBuffer = this.decodeExtBuffer(result.extBuffer)
if (!extBuffer) return nicknameMap
this.mergeGroupNicknameEntries(nicknameMap, this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates).entries())
return nicknameMap
} catch (e) {
console.error('getGroupNicknamesForRoom error:', e)
return nicknameMap
const trusted = new Map<string, string>()
for (const [identity, nicknameSet] of buckets.entries()) {
if (nicknameSet.size !== 1) continue
trusted.set(identity, Array.from(nicknameSet)[0])
}
return trusted
}
private mergeGroupNicknameEntries(
@@ -1363,8 +1707,6 @@ class ExportService {
const raw = String(rawValue || '').trim()
if (!raw) continue
set.add(raw)
const cleaned = this.cleanAccountDirName(raw)
if (cleaned && cleaned !== raw) set.add(cleaned)
}
return Array.from(set)
}
@@ -1373,29 +1715,20 @@ class ExportService {
const idCandidates = this.buildGroupNicknameIdCandidates(candidates)
if (idCandidates.length === 0) return ''
let resolved = ''
for (const id of idCandidates) {
const exact = this.normalizeGroupNickname(groupNicknamesMap.get(id) || '')
if (exact) return exact
const lower = this.normalizeGroupNickname(groupNicknamesMap.get(id.toLowerCase()) || '')
if (lower) return lower
}
for (const id of idCandidates) {
const lower = id.toLowerCase()
let found = ''
let matched = 0
for (const [key, value] of groupNicknamesMap.entries()) {
if (String(key || '').toLowerCase() !== lower) continue
const normalized = this.normalizeGroupNickname(value || '')
if (!normalized) continue
found = normalized
matched += 1
if (matched > 1) return ''
const normalizedId = this.normalizeGroupNicknameIdentity(id)
if (!normalizedId) continue
const candidateNickname = this.normalizeGroupNickname(groupNicknamesMap.get(normalizedId) || '')
if (!candidateNickname) continue
if (!resolved) {
resolved = candidateNickname
continue
}
if (matched === 1 && found) return found
if (resolved !== candidateNickname) return ''
}
return ''
return resolved
}
/**
@@ -1574,8 +1907,12 @@ class ExportService {
createTime?: number,
myWxid?: string,
senderWxid?: string,
isSend?: boolean
isSend?: boolean,
emojiCaption?: string
): string | null {
if (!content && localType === 47) {
return this.formatEmojiSemanticText(emojiCaption)
}
if (!content) return null
const normalizedContent = this.normalizeAppMessageContent(content)
@@ -1601,7 +1938,7 @@ class ExportService {
}
case 42: return '[名片]'
case 43: return '[视频]'
case 47: return '[动画表情]'
case 47: return this.formatEmojiSemanticText(emojiCaption)
case 48: {
const normalized48 = this.normalizeAppMessageContent(content)
const locPoiname = this.extractXmlAttribute(normalized48, 'location', 'poiname') || this.extractXmlValue(normalized48, 'poiname') || this.extractXmlValue(normalized48, 'poiName')
@@ -1711,7 +2048,8 @@ class ExportService {
voiceTranscript?: string,
myWxid?: string,
senderWxid?: string,
isSend?: boolean
isSend?: boolean,
emojiCaption?: string
): string {
const safeContent = content || ''
@@ -1741,6 +2079,9 @@ class ExportService {
const seconds = lengthValue ? this.parseDurationSeconds(lengthValue) : null
return seconds ? `[视频]${seconds}s` : '[视频]'
}
if (localType === 47) {
return this.formatEmojiSemanticText(emojiCaption)
}
if (localType === 48) {
const normalized = this.normalizeAppMessageContent(safeContent)
const locPoiname = this.extractXmlAttribute(normalized, 'location', 'poiname') || this.extractXmlValue(normalized, 'poiname') || this.extractXmlValue(normalized, 'poiName')
@@ -2481,7 +2822,7 @@ class ExportService {
case 3: return '[图片]'
case 34: return '[语音消息]'
case 43: return '[视频]'
case 47: return '[动画表情]'
case 47: return '[表情]'
case 49:
case 8: return title ? `[文件] ${title}` : '[文件]'
case 17: return item.chatRecordDesc || title || '[聊天记录]'
@@ -2622,7 +2963,7 @@ class ExportService {
displayContent = '[视频]'
break
case '47':
displayContent = '[动画表情]'
displayContent = '[表情]'
break
case '49':
displayContent = '[链接]'
@@ -2935,7 +3276,17 @@ class ExportService {
return rendered.join('')
}
private formatHtmlMessageText(content: string, localType: number, myWxid?: string, senderWxid?: string, isSend?: boolean): string {
private formatHtmlMessageText(
content: string,
localType: number,
myWxid?: string,
senderWxid?: string,
isSend?: boolean,
emojiCaption?: string
): string {
if (!content && localType === 47) {
return this.formatEmojiSemanticText(emojiCaption)
}
if (!content) return ''
if (localType === 1) {
@@ -2943,10 +3294,10 @@ class ExportService {
}
if (localType === 34) {
return this.parseMessageContent(content, localType, undefined, undefined, myWxid, senderWxid, isSend) || ''
return this.parseMessageContent(content, localType, undefined, undefined, myWxid, senderWxid, isSend, emojiCaption) || ''
}
return this.formatPlainExportContent(content, localType, { exportVoiceAsText: false }, undefined, myWxid, senderWxid, isSend)
return this.formatPlainExportContent(content, localType, { exportVoiceAsText: false }, undefined, myWxid, senderWxid, isSend, emojiCaption)
}
private extractHtmlLinkCard(content: string, localType: number): { title: string; url: string } | null {
@@ -3014,6 +3365,7 @@ class ExportService {
exportVoiceAsText?: boolean
includeVideoPoster?: boolean
includeVoiceWithTranscript?: boolean
imageDeepSearchOnMiss?: boolean
dirCache?: Set<string>
}
): Promise<MediaExportItem | null> {
@@ -3021,7 +3373,14 @@ class ExportService {
// 图片消息
if (localType === 3 && options.exportImages) {
const result = await this.exportImage(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache)
const result = await this.exportImage(
msg,
sessionId,
mediaRootDir,
mediaRelativePrefix,
options.dirCache,
options.imageDeepSearchOnMiss !== false
)
if (result) {
}
return result
@@ -3067,7 +3426,8 @@ class ExportService {
sessionId: string,
mediaRootDir: string,
mediaRelativePrefix: string,
dirCache?: Set<string>
dirCache?: Set<string>,
imageDeepSearchOnMiss = true
): Promise<MediaExportItem | null> {
try {
const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images')
@@ -3084,16 +3444,34 @@ class ExportService {
return null
}
const missingRunCacheKey = this.getImageMissingRunCacheKey(
sessionId,
imageMd5,
imageDatName,
imageDeepSearchOnMiss
)
if (missingRunCacheKey && this.mediaRunMissingImageKeys.has(missingRunCacheKey)) {
return null
}
const result = await imageDecryptService.decryptImage({
sessionId,
imageMd5,
imageDatName,
force: true, // 导出优先高清,失败再回退缩略图
preferFilePath: true
preferFilePath: true,
hardlinkOnly: !imageDeepSearchOnMiss
})
if (!result.success || !result.localPath) {
console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`)
if (!imageDeepSearchOnMiss) {
console.log(`[Export] 未命中 hardlink已关闭缺图深度搜索→ 将显示 [图片] 占位符`)
if (missingRunCacheKey) {
this.mediaRunMissingImageKeys.add(missingRunCacheKey)
}
return null
}
// 尝试获取缩略图
const thumbResult = await imageDecryptService.resolveCachedImage({
sessionId,
@@ -3114,6 +3492,9 @@ class ExportService {
result.localPath = cachedThumb
} else {
console.log(`[Export] 所有方式均失败 → 将显示 [图片] 占位符`)
if (missingRunCacheKey) {
this.mediaRunMissingImageKeys.add(missingRunCacheKey)
}
return null
}
}
@@ -3487,8 +3868,11 @@ class ExportService {
*/
private extractEmojiMd5(content: string): string | undefined {
if (!content) return undefined
const match = /md5="([^"]+)"/i.exec(content) || /<md5>([^<]+)<\/md5>/i.exec(content)
return match?.[1]
const match =
/md5\s*=\s*['"]([a-fA-F0-9]{32})['"]/i.exec(content) ||
/md5\s*=\s*([a-fA-F0-9]{32})/i.exec(content) ||
/<md5>([a-fA-F0-9]{32})<\/md5>/i.exec(content)
return this.normalizeEmojiMd5(match?.[1]) || this.extractLooseHexMd5(content)
}
private extractVideoMd5(content: string): string | undefined {
@@ -3777,6 +4161,7 @@ class ExportService {
let locationPoiname: string | undefined
let locationLabel: string | undefined
let chatRecordList: any[] | undefined
let emojiCaption: string | undefined
if (localType === 48 && content) {
const locationMeta = this.extractLocationMeta(content, localType)
@@ -3788,22 +4173,30 @@ class ExportService {
}
}
if (localType === 47) {
emojiCdnUrl = String(row.emoji_cdn_url || row.emojiCdnUrl || '').trim() || undefined
emojiMd5 = this.normalizeEmojiMd5(row.emoji_md5 || row.emojiMd5) || undefined
const packedInfoRaw = String(row.packed_info || row.packedInfo || row.PackedInfo || '')
const reserved0Raw = String(row.reserved0 || row.Reserved0 || '')
const supplementalPayload = `${this.decodeMaybeCompressed(packedInfoRaw)}\n${this.decodeMaybeCompressed(reserved0Raw)}`
if (content) {
emojiCdnUrl = emojiCdnUrl || this.extractEmojiUrl(content)
emojiMd5 = emojiMd5 || this.normalizeEmojiMd5(this.extractEmojiMd5(content))
}
emojiCdnUrl = emojiCdnUrl || this.extractEmojiUrl(supplementalPayload)
emojiMd5 = emojiMd5 || this.extractEmojiMd5(supplementalPayload) || this.extractLooseHexMd5(supplementalPayload)
}
if (collectMode === 'full' || collectMode === 'media-fast') {
// 优先复用游标返回的字段,缺失时再回退到 XML 解析。
imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined
imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || undefined
emojiCdnUrl = String(row.emoji_cdn_url || row.emojiCdnUrl || '').trim() || undefined
emojiMd5 = String(row.emoji_md5 || row.emojiMd5 || '').trim() || undefined
videoMd5 = String(row.video_md5 || row.videoMd5 || '').trim() || undefined
if (localType === 3 && content) {
// 图片消息
imageMd5 = imageMd5 || this.extractImageMd5(content)
imageDatName = imageDatName || this.extractImageDatName(content)
} else if (localType === 47 && content) {
// 动画表情
emojiCdnUrl = emojiCdnUrl || this.extractEmojiUrl(content)
emojiMd5 = emojiMd5 || this.extractEmojiMd5(content)
} else if (localType === 43 && content) {
// 视频消息
videoMd5 = videoMd5 || this.extractVideoMd5(content)
@@ -3830,6 +4223,7 @@ class ExportService {
imageDatName,
emojiCdnUrl,
emojiMd5,
emojiCaption,
videoMd5,
locationLat,
locationLng,
@@ -3898,7 +4292,7 @@ class ExportService {
const needsBackfill = rows.filter((msg) => {
if (!targetMediaTypes.has(msg.localType)) return false
if (msg.localType === 3) return !msg.imageMd5 && !msg.imageDatName
if (msg.localType === 47) return !msg.emojiMd5 && !msg.emojiCdnUrl
if (msg.localType === 47) return !msg.emojiMd5
if (msg.localType === 43) return !msg.videoMd5
return false
})
@@ -3915,9 +4309,16 @@ class ExportService {
if (!detail.success || !detail.message) return
const row = detail.message as any
const rawMessageContent = row.message_content ?? row.messageContent ?? row.msg_content ?? row.msgContent ?? ''
const rawCompressContent = row.compress_content ?? row.compressContent ?? row.msg_compress_content ?? row.msgCompressContent ?? ''
const rawMessageContent = this.getRowField(row, [
'message_content', 'messageContent', 'msg_content', 'msgContent', 'strContent', 'content', 'WCDB_CT_message_content'
]) ?? ''
const rawCompressContent = this.getRowField(row, [
'compress_content', 'compressContent', 'msg_compress_content', 'msgCompressContent', 'WCDB_CT_compress_content'
]) ?? ''
const content = this.decodeMessageContent(rawMessageContent, rawCompressContent)
const packedInfoRaw = this.getRowField(row, ['packed_info', 'packedInfo', 'PackedInfo', 'WCDB_CT_packed_info']) ?? ''
const reserved0Raw = this.getRowField(row, ['reserved0', 'Reserved0', 'WCDB_CT_Reserved0']) ?? ''
const supplementalPayload = `${this.decodeMaybeCompressed(String(packedInfoRaw || ''))}\n${this.decodeMaybeCompressed(String(reserved0Raw || ''))}`
if (msg.localType === 3) {
const imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || this.extractImageMd5(content)
@@ -3928,8 +4329,15 @@ class ExportService {
}
if (msg.localType === 47) {
const emojiMd5 = String(row.emoji_md5 || row.emojiMd5 || '').trim() || this.extractEmojiMd5(content)
const emojiCdnUrl = String(row.emoji_cdn_url || row.emojiCdnUrl || '').trim() || this.extractEmojiUrl(content)
const emojiMd5 =
this.normalizeEmojiMd5(row.emoji_md5 || row.emojiMd5) ||
this.extractEmojiMd5(content) ||
this.extractEmojiMd5(supplementalPayload) ||
this.extractLooseHexMd5(supplementalPayload)
const emojiCdnUrl =
String(row.emoji_cdn_url || row.emojiCdnUrl || '').trim() ||
this.extractEmojiUrl(content) ||
this.extractEmojiUrl(supplementalPayload)
if (emojiMd5) msg.emojiMd5 = emojiMd5
if (emojiCdnUrl) msg.emojiCdnUrl = emojiCdnUrl
return
@@ -4409,6 +4817,8 @@ class ExportService {
return { success: false, error: '该会话在指定时间范围内没有消息' }
}
await this.hydrateEmojiCaptionsForMessages(sessionId, allMessages, control)
const voiceMessages = options.exportVoiceAsText
? allMessages.filter(msg => msg.localType === 34)
: []
@@ -4511,6 +4921,7 @@ class ExportService {
exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache
})
mediaCache.set(mediaKey, mediaItem)
@@ -4634,7 +5045,8 @@ class ExportService {
msg.createTime,
cleanedMyWxid,
msg.senderUsername,
msg.isSend
msg.isSend,
msg.emojiCaption
)
}
@@ -4726,7 +5138,7 @@ class ExportService {
break
case 47:
recordType = 5 // EMOJI
recordContent = '[动画表情]'
recordContent = '[表情]'
break
default:
recordType = 0
@@ -4936,6 +5348,8 @@ class ExportService {
return { success: false, error: '该会话在指定时间范围内没有消息' }
}
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
const voiceMessages = options.exportVoiceAsText
? collected.rows.filter(msg => msg.localType === 34)
: []
@@ -5010,6 +5424,7 @@ class ExportService {
exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache
})
mediaCache.set(mediaKey, mediaItem)
@@ -5114,7 +5529,7 @@ class ExportService {
if (msg.localType === 34 && options.exportVoiceAsText) {
content = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]'
} else if (mediaItem) {
} else if (mediaItem && msg.localType !== 47) {
content = mediaItem.relativePath
} else {
content = this.parseMessageContent(
@@ -5124,7 +5539,8 @@ class ExportService {
undefined,
cleanedMyWxid,
msg.senderUsername,
msg.isSend
msg.isSend,
msg.emojiCaption
)
}
@@ -5185,6 +5601,12 @@ class ExportService {
senderAvatarKey: msg.senderUsername
}
if (msg.localType === 47) {
if (msg.emojiMd5) msgObj.emojiMd5 = msg.emojiMd5
if (msg.emojiCdnUrl) msgObj.emojiCdnUrl = msg.emojiCdnUrl
if (msg.emojiCaption) msgObj.emojiCaption = msg.emojiCaption
}
const platformMessageId = this.getExportPlatformMessageId(msg)
if (platformMessageId) msgObj.platformMessageId = platformMessageId
@@ -5420,6 +5842,9 @@ class ExportService {
if (message.linkTitle) compactMessage.linkTitle = message.linkTitle
if (message.linkUrl) compactMessage.linkUrl = message.linkUrl
if (message.linkThumb) compactMessage.linkThumb = message.linkThumb
if (message.emojiMd5) compactMessage.emojiMd5 = message.emojiMd5
if (message.emojiCdnUrl) compactMessage.emojiCdnUrl = message.emojiCdnUrl
if (message.emojiCaption) compactMessage.emojiCaption = message.emojiCaption
if (message.finderTitle) compactMessage.finderTitle = message.finderTitle
if (message.finderDesc) compactMessage.finderDesc = message.finderDesc
if (message.finderUsername) compactMessage.finderUsername = message.finderUsername
@@ -5650,6 +6075,8 @@ class ExportService {
return { success: false, error: '该会话在指定时间范围内没有消息' }
}
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
const voiceMessages = options.exportVoiceAsText
? collected.rows.filter(msg => msg.localType === 34)
: []
@@ -5850,6 +6277,7 @@ class ExportService {
exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache
})
mediaCache.set(mediaKey, mediaItem)
@@ -6007,9 +6435,10 @@ class ExportService {
voiceTranscriptMap.get(this.getStableMessageKey(msg)),
cleanedMyWxid,
msg.senderUsername,
msg.isSend
msg.isSend,
msg.emojiCaption
)
: (mediaItem?.relativePath
: ((msg.localType !== 47 ? mediaItem?.relativePath : undefined)
|| this.formatPlainExportContent(
msg.content,
msg.localType,
@@ -6017,7 +6446,8 @@ class ExportService {
voiceTranscriptMap.get(this.getStableMessageKey(msg)),
cleanedMyWxid,
msg.senderUsername,
msg.isSend
msg.isSend,
msg.emojiCaption
))
// 转账消息:追加 "谁转账给谁" 信息
@@ -6269,9 +6699,10 @@ class ExportService {
voiceTranscriptMap.get(this.getStableMessageKey(msg)),
cleanedMyWxid,
msg.senderUsername,
msg.isSend
msg.isSend,
msg.emojiCaption
)
: (mediaItem?.relativePath
: ((msg.localType !== 47 ? mediaItem?.relativePath : undefined)
|| this.formatPlainExportContent(
msg.content,
msg.localType,
@@ -6279,7 +6710,8 @@ class ExportService {
voiceTranscriptMap.get(this.getStableMessageKey(msg)),
cleanedMyWxid,
msg.senderUsername,
msg.isSend
msg.isSend,
msg.emojiCaption
))
let enrichedContentValue = contentValue
@@ -6468,6 +6900,8 @@ class ExportService {
return { success: false, error: '该会话在指定时间范围内没有消息' }
}
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
const voiceMessages = options.exportVoiceAsText
? collected.rows.filter(msg => msg.localType === 34)
: []
@@ -6551,6 +6985,7 @@ class ExportService {
exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache
})
mediaCache.set(mediaKey, mediaItem)
@@ -6635,9 +7070,10 @@ class ExportService {
voiceTranscriptMap.get(this.getStableMessageKey(msg)),
cleanedMyWxid,
msg.senderUsername,
msg.isSend
msg.isSend,
msg.emojiCaption
)
: (mediaItem?.relativePath
: ((msg.localType !== 47 ? mediaItem?.relativePath : undefined)
|| this.formatPlainExportContent(
msg.content,
msg.localType,
@@ -6645,7 +7081,8 @@ class ExportService {
voiceTranscriptMap.get(this.getStableMessageKey(msg)),
cleanedMyWxid,
msg.senderUsername,
msg.isSend
msg.isSend,
msg.emojiCaption
))
// 转账消息:追加 "谁转账给谁" 信息
@@ -6828,6 +7265,8 @@ class ExportService {
return { success: false, error: '该会话在指定时间范围内没有消息' }
}
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
const senderUsernames = new Set<string>()
let senderScanIndex = 0
for (const msg of collected.rows) {
@@ -6916,6 +7355,7 @@ class ExportService {
exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache
})
mediaCache.set(mediaKey, mediaItem)
@@ -7046,7 +7486,8 @@ class ExportService {
msg.createTime,
cleanedMyWxid,
msg.senderUsername,
msg.isSend
msg.isSend,
msg.emojiCaption
) || '')
const src = this.getWeCloneSource(msg, typeName, mediaItem)
const platformMessageId = this.getExportPlatformMessageId(msg) || ''
@@ -7255,6 +7696,8 @@ class ExportService {
}
const totalMessages = collected.rows.length
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
const senderUsernames = new Set<string>()
let senderScanIndex = 0
for (const msg of collected.rows) {
@@ -7334,6 +7777,7 @@ class ExportService {
includeVideoPoster: options.format === 'html',
includeVoiceWithTranscript: true,
exportVideos: options.exportVideos,
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache
})
mediaCache.set(mediaKey, mediaItem)
@@ -7545,12 +7989,13 @@ class ExportService {
msg.localType,
cleanedMyWxid,
msg.senderUsername,
msg.isSend
msg.isSend,
msg.emojiCaption
)
if (msg.localType === 34 && useVoiceTranscript) {
textContent = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]'
}
if (mediaItem && (msg.localType === 3 || msg.localType === 47)) {
if (mediaItem && msg.localType === 3) {
textContent = ''
}
if (this.isTransferExportContent(textContent) && msg.content) {

View File

@@ -5,6 +5,7 @@ import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
import { chatService } from './chatService'
import type { Message } from './chatService'
import type { ChatStatistics } from './analyticsService'
export interface GroupChatInfo {
username: string
@@ -49,6 +50,13 @@ export interface GroupMediaStats {
total: number
}
export interface GroupMemberAnalytics {
statistics: ChatStatistics
timeDistribution: Record<number, number>
commonPhrases?: Array<{ phrase: string; count: number }>
commonEmojis?: Array<{ emoji: string; count: number }>
}
export interface GroupMemberMessagesPage {
messages: Message[]
hasMore: boolean
@@ -257,34 +265,60 @@ class GroupAnalyticsService {
}
/**
* 从 DLL 获取群成员群昵称
* 从后端获取群成员群昵称,并在前端进行唯一性净化防串号。
*/
private async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
const nicknameMap = new Map<string, string>()
try {
const dllResult = await wcdbService.getGroupNicknames(chatroomId)
if (dllResult.success && dllResult.nicknames) {
this.mergeGroupNicknameEntries(nicknameMap, Object.entries(dllResult.nicknames))
if (!dllResult.success || !dllResult.nicknames) {
return new Map<string, string>()
}
return this.buildTrustedGroupNicknameMap(Object.entries(dllResult.nicknames), candidates)
} catch (e) {
console.error('getGroupNicknamesForRoom dll error:', e)
return new Map<string, string>()
}
}
try {
const result = await wcdbService.getChatRoomExtBuffer(chatroomId)
if (!result.success || !result.extBuffer) {
return nicknameMap
private normalizeGroupNicknameIdentity(value: string): string {
const raw = String(value || '').trim()
if (!raw) return ''
return raw.toLowerCase()
}
private buildTrustedGroupNicknameMap(
entries: Iterable<[string, string]>,
candidates: string[] = []
): Map<string, string> {
const candidateSet = new Set(
this.buildGroupNicknameIdCandidates(candidates)
.map((id) => this.normalizeGroupNicknameIdentity(id))
.filter(Boolean)
)
const buckets = new Map<string, Set<string>>()
for (const [memberIdRaw, nicknameRaw] of entries) {
const identity = this.normalizeGroupNicknameIdentity(memberIdRaw || '')
if (!identity) continue
if (candidateSet.size > 0 && !candidateSet.has(identity)) continue
const nickname = this.normalizeGroupNickname(nicknameRaw || '')
if (!nickname) continue
const slot = buckets.get(identity)
if (slot) {
slot.add(nickname)
} else {
buckets.set(identity, new Set([nickname]))
}
const extBuffer = this.decodeExtBuffer(result.extBuffer)
if (!extBuffer) return nicknameMap
this.mergeGroupNicknameEntries(nicknameMap, this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates).entries())
return nicknameMap
} catch (e) {
console.error('getGroupNicknamesForRoom error:', e)
return nicknameMap
}
const trusted = new Map<string, string>()
for (const [identity, nicknameSet] of buckets.entries()) {
if (nicknameSet.size !== 1) continue
trusted.set(identity, Array.from(nicknameSet)[0])
}
return trusted
}
private mergeGroupNicknameEntries(
@@ -475,6 +509,16 @@ class GroupAnalyticsService {
return Array.from(set)
}
private buildGroupNicknameIdCandidates(values: Array<string | undefined | null>): string[] {
const set = new Set<string>()
for (const rawValue of values) {
const raw = String(rawValue || '').trim()
if (!raw) continue
set.add(raw)
}
return Array.from(set)
}
private toNonNegativeInteger(value: unknown): number {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return 0
@@ -663,30 +707,23 @@ class GroupAnalyticsService {
}
private resolveGroupNicknameByCandidates(groupNicknames: Map<string, string>, candidates: string[]): string {
const idCandidates = this.buildIdCandidates(candidates)
const idCandidates = this.buildGroupNicknameIdCandidates(candidates)
if (idCandidates.length === 0) return ''
let resolved = ''
for (const id of idCandidates) {
const exact = this.normalizeGroupNickname(groupNicknames.get(id) || '')
if (exact) return exact
}
for (const id of idCandidates) {
const lower = id.toLowerCase()
let found = ''
let matched = 0
for (const [key, value] of groupNicknames.entries()) {
if (String(key || '').toLowerCase() !== lower) continue
const normalized = this.normalizeGroupNickname(value || '')
if (!normalized) continue
found = normalized
matched += 1
if (matched > 1) return ''
const normalizedId = this.normalizeGroupNicknameIdentity(id)
if (!normalizedId) continue
const candidateNickname = this.normalizeGroupNickname(groupNicknames.get(normalizedId) || '')
if (!candidateNickname) continue
if (!resolved) {
resolved = candidateNickname
continue
}
if (matched === 1 && found) return found
if (resolved !== candidateNickname) return ''
}
return ''
return resolved
}
private sanitizeWorksheetName(name: string): string {
@@ -768,7 +805,12 @@ class GroupAnalyticsService {
return normalized > 10000000000 ? Math.floor(normalized / 1000) : normalized
}
private extractRowSenderUsername(row: Record<string, any>): string {
private extractRowSenderUsername(row: Record<string, any>, myWxid?: string): string {
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? row.WCDB_CT_is_send
if (isSendRaw != null && parseInt(isSendRaw, 10) === 1 && myWxid) {
return myWxid
}
const candidates = [
row.sender_username,
row.senderUsername,
@@ -791,13 +833,33 @@ class GroupAnalyticsService {
if (normalizedValue) return normalizedValue
}
}
// Fallback: fast extract from raw content to avoid full parse
const rawContent = String(row.StrContent || row.message_content || row.content || row.msg_content || '').trim()
if (rawContent) {
const match = /^\s*([a-zA-Z0-9_@-]{4,}):(?!\/\/)\s*(?:\r?\n|<br\s*\/?>)/i.exec(rawContent)
if (match && match[1]) {
return match[1].trim()
}
}
return ''
}
private parseSingleMessageRow(row: Record<string, any>): Message | null {
try {
const mapped = chatService.mapRowsToMessagesForApi([row])
return Array.isArray(mapped) && mapped.length > 0 ? mapped[0] : null
if (Array.isArray(mapped) && mapped.length > 0) {
const msg = mapped[0]
if (!msg.localType) {
msg.localType = parseInt(row.Type || row.type || row.local_type || row.msg_type || '0', 10)
}
if (!msg.createTime) {
msg.createTime = parseInt(row.CreateTime || row.create_time || row.createTime || row.msg_time || '0', 10)
}
return msg
}
return null
} catch {
return null
}
@@ -852,7 +914,7 @@ class GroupAnalyticsService {
if (rows.length === 0) break
for (const row of rows) {
const senderFromRow = this.extractRowSenderUsername(row)
const senderFromRow = this.extractRowSenderUsername(row, String(this.configService.get('myWxid') || '').trim())
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
continue
}
@@ -958,7 +1020,7 @@ class GroupAnalyticsService {
const row = rows[index]
consumedRows += 1
const senderFromRow = this.extractRowSenderUsername(row)
const senderFromRow = this.extractRowSenderUsername(row, String(this.configService.get('myWxid') || '').trim())
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
continue
}
@@ -1438,6 +1500,154 @@ class GroupAnalyticsService {
}
}
async getGroupMemberAnalytics(
chatroomId: string,
memberUsername: string,
startTime?: number,
endTime?: number
): Promise<{ success: boolean; data?: GroupMemberAnalytics; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success) return { success: false, error: conn.error }
const normalizedChatroomId = String(chatroomId || '').trim()
const normalizedMemberUsername = String(memberUsername || '').trim()
const batchSize = 10000
const senderMatchCache = new Map<string, boolean>()
const matchesTargetSender = (sender: string | null | undefined): boolean => {
const key = String(sender || '').trim().toLowerCase()
if (!key) return false
const cached = senderMatchCache.get(key)
if (typeof cached === 'boolean') return cached
const matched = this.isSameAccountIdentity(normalizedMemberUsername, sender)
senderMatchCache.set(key, matched)
return matched
}
const cursorResult = await this.openMemberMessageCursor(normalizedChatroomId, batchSize, true, startTime || 0, endTime || 0)
if (!cursorResult.success || !cursorResult.cursor) {
return { success: false, error: cursorResult.error || '创建游标失败' }
}
const cursor = cursorResult.cursor
const stats: ChatStatistics = {
totalMessages: 0,
textMessages: 0,
imageMessages: 0,
voiceMessages: 0,
videoMessages: 0,
emojiMessages: 0,
otherMessages: 0,
sentMessages: 0, // In group, we only fetch messages of this member, so sentMessages = totalMessages
receivedMessages: 0, // No meaning here
firstMessageTime: null,
lastMessageTime: null,
activeDays: 0,
messageTypeCounts: {}
}
const hourlyDistribution: Record<number, number> = {}
for (let i = 0; i < 24; i++) hourlyDistribution[i] = 0
const dailySet = new Set<string>()
const textTypes = [1, 244813135921]
const phraseCounts = new Map<string, number>()
const emojiCounts = new Map<string, number>()
const myWxid = String(this.configService.get('myWxid') || '').trim()
try {
while (true) {
const batch = await wcdbService.fetchMessageBatch(cursor)
if (!batch.success) {
return { success: false, error: batch.error || '获取分析数据失败' }
}
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
if (rows.length === 0) break
for (const row of rows) {
let senderFromRow = this.extractRowSenderUsername(row, myWxid)
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? row.WCDB_CT_is_send
const isSend = isSendRaw != null ? parseInt(isSendRaw, 10) === 1 : false
if (isSend) {
senderFromRow = myWxid
}
if (!senderFromRow || !matchesTargetSender(senderFromRow)) {
continue
}
const msgType = parseInt(row.Type || row.type || row.local_type || row.msg_type || '0', 10)
const createTime = parseInt(row.CreateTime || row.create_time || row.createTime || row.msg_time || '0', 10)
let content = String(row.StrContent || row.message_content || row.content || row.msg_content || '')
if (content) {
content = content.replace(/^\s*([a-zA-Z0-9_@-]{4,}):(?!\/\/)\s*(?:\r?\n|<br\s*\/?>)/i, '')
}
stats.totalMessages++
if (textTypes.includes(msgType)) {
stats.textMessages++
if (content) {
const text = content.trim()
if (text && text.length <= 20) {
phraseCounts.set(text, (phraseCounts.get(text) || 0) + 1)
}
const emojiMatches = text.match(/\[.*?\]/g)
if (emojiMatches) {
for (const em of emojiMatches) {
emojiCounts.set(em, (emojiCounts.get(em) || 0) + 1)
}
}
}
}
else if (msgType === 3) stats.imageMessages++
else if (msgType === 34) stats.voiceMessages++
else if (msgType === 43) stats.videoMessages++
else if (msgType === 47) stats.emojiMessages++
else stats.otherMessages++
stats.sentMessages++
stats.messageTypeCounts[msgType] = (stats.messageTypeCounts[msgType] || 0) + 1
if (createTime > 0) {
if (stats.firstMessageTime === null || createTime < stats.firstMessageTime) stats.firstMessageTime = createTime
if (stats.lastMessageTime === null || createTime > stats.lastMessageTime) stats.lastMessageTime = createTime
const d = new Date(createTime * 1000)
const hour = d.getHours()
hourlyDistribution[hour]++
dailySet.add(`${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`)
}
}
if (!batch.hasMore) break
}
} finally {
await wcdbService.closeMessageCursor(cursor)
}
stats.activeDays = dailySet.size
const commonPhrases = Array.from(phraseCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([phrase, count]) => ({ phrase, count }))
const commonEmojis = Array.from(emojiCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([emoji, count]) => ({ emoji, count }))
return { success: true, data: { statistics: stats, timeDistribution: hourlyDistribution, commonPhrases, commonEmojis } }
} catch (e) {
return { success: false, error: String(e) }
}
}
async exportGroupMemberMessages(
chatroomId: string,
memberUsername: string,

View File

@@ -101,6 +101,7 @@ class HttpService {
private server: http.Server | null = null
private configService: ConfigService
private port: number = 5031
private host: string = '127.0.0.1'
private running: boolean = false
private connections: Set<import('net').Socket> = new Set()
private messagePushClients: Set<http.ServerResponse> = new Set()
@@ -114,12 +115,13 @@ class HttpService {
/**
* 启动 HTTP 服务
*/
async start(port: number = 5031): Promise<{ success: boolean; port?: number; error?: string }> {
async start(port: number = 5031, host: string = '127.0.0.1'): Promise<{ success: boolean; port?: number; error?: string }> {
if (this.running && this.server) {
return { success: true, port: this.port }
}
this.port = port
this.host = host
return new Promise((resolve) => {
this.server = http.createServer((req, res) => this.handleRequest(req, res))
@@ -153,10 +155,10 @@ class HttpService {
}
})
this.server.listen(this.port, '127.0.0.1', () => {
this.server.listen(this.port, this.host, () => {
this.running = true
this.startMessagePushHeartbeat()
console.log(`[HttpService] HTTP API server started on http://127.0.0.1:${this.port}`)
console.log(`[HttpService] HTTP API server started on http://${this.host}:${this.port}`)
resolve({ success: true, port: this.port })
})
})
@@ -225,7 +227,7 @@ class HttpService {
}
getMessagePushStreamUrl(): string {
return `http://127.0.0.1:${this.port}/api/v1/push/messages`
return `http://${this.host}:${this.port}/api/v1/push/messages`
}
broadcastMessagePush(payload: Record<string, unknown>): void {
@@ -246,49 +248,116 @@ class HttpService {
}
}
/**
* 处理 HTTP 请求
*/
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
// 设置 CORS 头
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
if (req.method === 'OPTIONS') {
res.writeHead(204)
res.end()
return
}
const url = new URL(req.url || '/', `http://127.0.0.1:${this.port}`)
const pathname = url.pathname
try {
// 路由处理
if (pathname === '/health' || pathname === '/api/v1/health') {
this.sendJson(res, { status: 'ok' })
} else if (pathname === '/api/v1/push/messages') {
this.handleMessagePushStream(req, res)
} else if (pathname === '/api/v1/messages') {
await this.handleMessages(url, res)
} else if (pathname === '/api/v1/sessions') {
await this.handleSessions(url, res)
} else if (pathname === '/api/v1/contacts') {
await this.handleContacts(url, res)
} else if (pathname === '/api/v1/group-members') {
await this.handleGroupMembers(url, res)
} else if (pathname.startsWith('/api/v1/media/')) {
this.handleMediaRequest(pathname, res)
} else {
this.sendError(res, 404, 'Not Found')
async autoStart(): Promise<void> {
const enabled = this.configService.get('httpApiEnabled')
if (enabled) {
const port = Number(this.configService.get('httpApiPort')) || 5031
const host = String(this.configService.get('httpApiHost') || '127.0.0.1').trim() || '127.0.0.1'
try {
await this.start(port, host)
console.log(`[HttpService] Auto-started on port ${port}`)
} catch (err) {
console.error('[HttpService] Auto-start failed:', err)
}
} catch (error) {
console.error('[HttpService] Request error:', error)
this.sendError(res, 500, String(error))
}
}
/**
* 解析 POST 请求的 JSON Body
*/
private async parseBody(req: http.IncomingMessage): Promise<Record<string, any>> {
if (req.method !== 'POST') return {}
return new Promise((resolve) => {
let body = ''
req.on('data', chunk => { body += chunk.toString() })
req.on('end', () => {
try {
resolve(JSON.parse(body))
} catch {
resolve({})
}
})
req.on('error', () => resolve({}))
})
}
/**
* 鉴权拦截器
*/
private verifyToken(req: http.IncomingMessage, url: URL, body: Record<string, any>): boolean {
const expectedToken = String(this.configService.get('httpApiToken') || '').trim()
if (!expectedToken) return true
const authHeader = req.headers.authorization
if (authHeader && authHeader.toLowerCase().startsWith('bearer ')) {
const token = authHeader.substring(7).trim()
if (token === expectedToken) return true
}
const queryToken = url.searchParams.get('access_token')
if (queryToken && queryToken.trim() === expectedToken) return true
const bodyToken = body['access_token']
return !!(bodyToken && String(bodyToken).trim() === expectedToken);
}
/**
* 处理 HTTP 请求 (重构后)
*/
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
if (req.method === 'OPTIONS') {
res.writeHead(204)
res.end()
return
}
const url = new URL(req.url || '/', `http://${this.host}:${this.port}`)
const pathname = url.pathname
try {
const bodyParams = await this.parseBody(req)
for (const [key, value] of Object.entries(bodyParams)) {
if (!url.searchParams.has(key)) {
url.searchParams.set(key, String(value))
}
}
if (pathname !== '/health' && pathname !== '/api/v1/health') {
if (!this.verifyToken(req, url, bodyParams)) {
this.sendError(res, 401, 'Unauthorized: Invalid or missing access_token')
return
}
}
if (pathname === '/health' || pathname === '/api/v1/health') {
this.sendJson(res, { status: 'ok' })
} else if (pathname === '/api/v1/push/messages') {
this.handleMessagePushStream(req, res)
} else if (pathname === '/api/v1/messages') {
await this.handleMessages(url, res)
} else if (pathname === '/api/v1/sessions') {
await this.handleSessions(url, res)
} else if (pathname === '/api/v1/contacts') {
await this.handleContacts(url, res)
} else if (pathname === '/api/v1/group-members') {
await this.handleGroupMembers(url, res)
} else if (pathname.startsWith('/api/v1/media/')) {
this.handleMediaRequest(pathname, res)
} else {
this.sendError(res, 404, 'Not Found')
}
} catch (error) {
console.error('[HttpService] Request error:', error)
this.sendError(res, 500, String(error))
}
}
private startMessagePushHeartbeat(): void {
if (this.messagePushHeartbeatTimer) return
this.messagePushHeartbeatTimer = setInterval(() => {
@@ -895,7 +964,7 @@ class HttpService {
parsedContent: msg.parsedContent,
mediaType: media?.kind,
mediaFileName: media?.fileName,
mediaUrl: media ? `http://127.0.0.1:${this.port}/api/v1/media/${media.relativePath}` : undefined,
mediaUrl: media ? `http://${this.host}:${this.port}/api/v1/media/${media.relativePath}` : undefined,
mediaLocalPath: media?.fullPath
}
}
@@ -1017,13 +1086,31 @@ class HttpService {
}
private lookupGroupNickname(groupNicknamesMap: Map<string, string>, sender: string): string {
if (!sender) return ''
const cleaned = this.normalizeAccountId(sender)
return groupNicknamesMap.get(sender)
|| groupNicknamesMap.get(sender.toLowerCase())
|| groupNicknamesMap.get(cleaned)
|| groupNicknamesMap.get(cleaned.toLowerCase())
|| ''
const key = String(sender || '').trim().toLowerCase()
if (!key) return ''
return groupNicknamesMap.get(key) || ''
}
private buildTrustedGroupNicknameMap(nicknames: Record<string, string>): Map<string, string> {
const buckets = new Map<string, Set<string>>()
for (const [memberIdRaw, nicknameRaw] of Object.entries(nicknames || {})) {
const memberId = String(memberIdRaw || '').trim().toLowerCase()
const nickname = String(nicknameRaw || '').trim()
if (!memberId || !nickname) continue
const slot = buckets.get(memberId)
if (slot) {
slot.add(nickname)
} else {
buckets.set(memberId, new Set([nickname]))
}
}
const trusted = new Map<string, string>()
for (const [memberId, nicknameSet] of buckets.entries()) {
if (nicknameSet.size !== 1) continue
trusted.set(memberId, Array.from(nicknameSet)[0])
}
return trusted
}
private resolveChatLabSenderInfo(
@@ -1094,21 +1181,7 @@ class HttpService {
try {
const result = await wcdbService.getGroupNicknames(talkerId)
if (result.success && result.nicknames) {
groupNicknamesMap = new Map()
for (const [memberIdRaw, nicknameRaw] of Object.entries(result.nicknames)) {
const memberId = String(memberIdRaw || '').trim()
const nickname = String(nicknameRaw || '').trim()
if (!memberId || !nickname) continue
groupNicknamesMap.set(memberId, nickname)
groupNicknamesMap.set(memberId.toLowerCase(), nickname)
const cleaned = this.normalizeAccountId(memberId)
if (cleaned) {
groupNicknamesMap.set(cleaned, nickname)
groupNicknamesMap.set(cleaned.toLowerCase(), nickname)
}
}
groupNicknamesMap = this.buildTrustedGroupNicknameMap(result.nicknames)
}
} catch (e) {
console.error('[HttpService] Failed to get group nicknames:', e)
@@ -1161,7 +1234,7 @@ class HttpService {
type: this.mapMessageType(msg.localType, msg),
content: this.getMessageContent(msg),
platformMessageId: msg.serverId ? String(msg.serverId) : undefined,
mediaPath: mediaMap.get(msg.localId) ? `http://127.0.0.1:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined
mediaPath: mediaMap.get(msg.localId) ? `http://${this.host}:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined
}
})
@@ -1226,7 +1299,7 @@ class HttpService {
* 映射 Type 49 子类型
*/
private mapType49(msg: Message): number {
const xmlType = msg.xmlType
const xmlType = this.resolveType49Subtype(msg)
switch (xmlType) {
case '5': // 链接
@@ -1250,10 +1323,97 @@ class HttpService {
}
}
private extractType49Subtype(rawContent: string): string {
const content = String(rawContent || '')
if (!content) return ''
const appmsgMatch = /<appmsg[\s\S]*?>([\s\S]*?)<\/appmsg>/i.exec(content)
if (appmsgMatch) {
const appmsgInner = appmsgMatch[1]
.replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '')
.replace(/<patMsg[\s\S]*?<\/patMsg>/gi, '')
const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(appmsgInner)
if (typeMatch) {
return typeMatch[1].replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
}
}
const fallbackMatch = /<type>([\s\S]*?)<\/type>/i.exec(content)
if (fallbackMatch) {
return fallbackMatch[1].replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
}
return ''
}
private resolveType49Subtype(msg: Message): string {
const xmlType = String(msg.xmlType || '').trim()
if (xmlType) return xmlType
const extractedType = this.extractType49Subtype(msg.rawContent)
if (extractedType) return extractedType
switch (msg.appMsgKind) {
case 'official-link':
case 'link':
return '5'
case 'file':
return '6'
case 'chat-record':
return '19'
case 'miniapp':
return '33'
case 'quote':
return '57'
case 'transfer':
return '2000'
case 'red-packet':
return '2001'
case 'music':
return '3'
default:
if (msg.linkUrl) return '5'
if (msg.fileName) return '6'
return ''
}
}
private getType49Content(msg: Message): string {
const subtype = this.resolveType49Subtype(msg)
const title = msg.linkTitle || msg.fileName || ''
switch (subtype) {
case '5':
case '49':
return title ? `[链接] ${title}` : '[链接]'
case '6':
return title ? `[文件] ${title}` : '[文件]'
case '19':
return title ? `[聊天记录] ${title}` : '[聊天记录]'
case '33':
case '36':
return title ? `[小程序] ${title}` : '[小程序]'
case '57':
return msg.parsedContent || title || '[引用消息]'
case '2000':
return title ? `[转账] ${title}` : '[转账]'
case '2001':
return title ? `[红包] ${title}` : '[红包]'
case '3':
return title ? `[音乐] ${title}` : '[音乐]'
default:
return msg.parsedContent || title || '[消息]'
}
}
/**
* 获取消息内容
*/
private getMessageContent(msg: Message): string | null {
if (msg.localType === 49) {
return this.getType49Content(msg)
}
// 优先使用已解析的内容
if (msg.parsedContent) {
return msg.parsedContent
@@ -1276,7 +1436,7 @@ class HttpService {
case 48:
return '[位置]'
case 49:
return msg.linkTitle || msg.fileName || '[消息]'
return this.getType49Content(msg)
default:
return msg.rawContent || null
}
@@ -1302,4 +1462,3 @@ class HttpService {
}
export const httpService = new HttpService()

View File

@@ -64,6 +64,7 @@ type CachedImagePayload = {
type DecryptImagePayload = CachedImagePayload & {
force?: boolean
hardlinkOnly?: boolean
}
export class ImageDecryptService {
@@ -158,7 +159,9 @@ export class ImageDecryptService {
}
async decryptImage(payload: DecryptImagePayload): Promise<DecryptResult> {
await this.ensureCacheIndexed()
if (!payload.hardlinkOnly) {
await this.ensureCacheIndexed()
}
const cacheKeys = this.getCacheKeys(payload)
const cacheKey = cacheKeys[0]
if (!cacheKey) {
@@ -180,14 +183,16 @@ export class ImageDecryptService {
}
}
for (const key of cacheKeys) {
const existingHd = this.findCachedOutput(key, true, payload.sessionId)
if (!existingHd || this.isThumbnailPath(existingHd)) continue
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd)
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
const localPath = this.resolveLocalPathForPayload(existingHd, payload.preferFilePath)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingHd, payload.preferFilePath))
return { success: true, localPath }
if (!payload.hardlinkOnly) {
for (const key of cacheKeys) {
const existingHd = this.findCachedOutput(key, true, payload.sessionId)
if (!existingHd || this.isThumbnailPath(existingHd)) continue
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd)
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
const localPath = this.resolveLocalPathForPayload(existingHd, payload.preferFilePath)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingHd, payload.preferFilePath))
return { success: true, localPath }
}
}
}
@@ -255,7 +260,7 @@ export class ImageDecryptService {
payload: DecryptImagePayload,
cacheKey: string
): Promise<DecryptResult> {
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force })
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force, hardlinkOnly: payload.hardlinkOnly === true })
try {
const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath')
@@ -275,7 +280,11 @@ export class ImageDecryptService {
payload.imageMd5,
payload.imageDatName,
payload.sessionId,
{ allowThumbnail: !payload.force, skipResolvedCache: Boolean(payload.force) }
{
allowThumbnail: !payload.force,
skipResolvedCache: Boolean(payload.force),
hardlinkOnly: payload.hardlinkOnly === true
}
)
// 如果要求高清图但没找到,直接返回提示
@@ -298,18 +307,20 @@ export class ImageDecryptService {
return { success: true, localPath, isThumb }
}
// 查找已缓存的解密文件
const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId)
if (existing) {
this.logInfo('找到已解密文件', { existing, isHd: this.isHdPath(existing) })
const isHd = this.isHdPath(existing)
// 如果要求高清但找到的是缩略图,继续解密高清图
if (!(payload.force && !isHd)) {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing)
const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath)
const isThumb = this.isThumbnailPath(existing)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existing, payload.preferFilePath))
return { success: true, localPath, isThumb }
// 查找已缓存的解密文件hardlink-only 模式下跳过全缓存目录扫描)
if (!payload.hardlinkOnly) {
const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId)
if (existing) {
this.logInfo('找到已解密文件', { existing, isHd: this.isHdPath(existing) })
const isHd = this.isHdPath(existing)
// 如果要求高清但找到的是缩略图,继续解密高清图
if (!(payload.force && !isHd)) {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing)
const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath)
const isThumb = this.isThumbnailPath(existing)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existing, payload.preferFilePath))
return { success: true, localPath, isThumb }
}
}
}
@@ -467,15 +478,17 @@ export class ImageDecryptService {
imageMd5?: string,
imageDatName?: string,
sessionId?: string,
options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean }
options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean; hardlinkOnly?: boolean }
): Promise<string | null> {
const allowThumbnail = options?.allowThumbnail ?? true
const skipResolvedCache = options?.skipResolvedCache ?? false
const hardlinkOnly = options?.hardlinkOnly ?? false
this.logInfo('[ImageDecrypt] resolveDatPath', {
imageMd5,
imageDatName,
allowThumbnail,
skipResolvedCache
skipResolvedCache,
hardlinkOnly
})
if (!skipResolvedCache) {
@@ -500,7 +513,7 @@ export class ImageDecryptService {
}
// 1. 通过 MD5 快速定位 (MsgAttach 目录)
if (imageMd5) {
if (!hardlinkOnly && allowThumbnail && imageMd5) {
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageMd5, allowThumbnail)
if (res) return res
if (imageDatName && imageDatName !== imageMd5 && this.looksLikeMd5(imageDatName)) {
@@ -510,7 +523,7 @@ export class ImageDecryptService {
}
// 2. 如果 imageDatName 看起来像 MD5也尝试快速定位
if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) {
if (!hardlinkOnly && allowThumbnail && !imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) {
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail)
if (res) return res
}
@@ -587,6 +600,11 @@ export class ImageDecryptService {
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
}
if (hardlinkOnly) {
this.logInfo('[ImageDecrypt] resolveDatPath miss (hardlink-only)', { imageMd5, imageDatName })
return null
}
// 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢)
if (!allowThumbnail) {
return null

View File

@@ -1,7 +1,7 @@
import { app } from 'electron'
import { join } from 'path'
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
import { execFile, exec } from 'child_process'
import { execFile, exec, spawn } from 'child_process'
import { promisify } from 'util'
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
@@ -45,33 +45,104 @@ export class KeyServiceLinux {
onStatus?: (message: string, level: number) => void
): Promise<DbKeyResult> {
try {
// 1. 构造一个包含常用系统命令路径的环境变量,防止打包后找不到命令
const envWithPath = {
...process.env,
PATH: `${process.env.PATH || ''}:/bin:/usr/bin:/sbin:/usr/sbin:/usr/local/bin`
};
onStatus?.('正在尝试结束当前微信进程...', 0)
await execAsync('killall -9 wechat wechat-bin xwechat').catch(() => {})
console.log('[Debug] 开始执行进程清理逻辑...');
try {
const { stdout, stderr } = await execAsync('killall -9 wechat wechat-bin xwechat', { env: envWithPath });
console.log(`[Debug] killall 成功退出. stdout: ${stdout}, stderr: ${stderr}`);
} catch (err: any) {
// 命令如果没找到进程通常会返回 code 1这也是正常的但我们需要记录下来
console.log(`[Debug] killall 报错或未找到进程: ${err.message}`);
// Fallback: 尝试使用 pkill 兜底
try {
console.log('[Debug] 尝试使用备用命令 pkill...');
await execAsync('pkill -9 -x "wechat|wechat-bin|xwechat"', { env: envWithPath });
console.log('[Debug] pkill 执行完成');
} catch (e: any) {
console.log(`[Debug] pkill 报错或未找到进程: ${e.message}`);
}
}
// 稍微等待进程完全退出
await new Promise(r => setTimeout(r, 1000))
onStatus?.('正在尝试拉起微信...', 0)
const startCmds = [
'nohup wechat >/dev/null 2>&1 &',
'nohup wechat-bin >/dev/null 2>&1 &',
'nohup xwechat >/dev/null 2>&1 &'
const cleanEnv = { ...process.env };
delete cleanEnv.ELECTRON_RUN_AS_NODE;
delete cleanEnv.ELECTRON_NO_ATTACH_CONSOLE;
delete cleanEnv.APPDIR;
delete cleanEnv.APPIMAGE;
const wechatBins = [
'wechat',
'wechat-bin',
'xwechat',
'/opt/wechat/wechat',
'/usr/bin/wechat',
'/opt/apps/com.tencent.wechat/files/wechat'
]
for (const cmd of startCmds) execAsync(cmd).catch(() => {})
for (const binName of wechatBins) {
try {
const child = spawn(binName, [], {
detached: true,
stdio: 'ignore',
env: cleanEnv
});
child.on('error', (err) => {
console.log(`[Debug] 拉起 ${binName} 失败:`, err.message);
});
child.unref();
console.log(`[Debug] 尝试拉起 ${binName} 完毕`);
} catch (e: any) {
console.log(`[Debug] 尝试拉起 ${binName} 发生异常:`, e.message);
}
}
onStatus?.('等待微信进程出现...', 0)
let pid = 0
for (let i = 0; i < 15; i++) { // 最多等 15 秒
await new Promise(r => setTimeout(r, 1000))
const { stdout } = await execAsync('pidof wechat wechat-bin xwechat').catch(() => ({ stdout: '' }))
const pids = stdout.trim().split(/\s+/).filter(p => p)
if (pids.length > 0) {
pid = parseInt(pids[0], 10)
break
try {
const { stdout } = await execAsync('pidof wechat wechat-bin xwechat', { env: envWithPath });
const pids = stdout.trim().split(/\s+/).filter(p => p);
if (pids.length > 0) {
pid = parseInt(pids[0], 10);
console.log(`[Debug] 第 ${i + 1} 秒,通过 pidof 成功获取 PID: ${pid}`);
break;
}
} catch (err: any) {
console.log(`[Debug] 第 ${i + 1}pidof 失败: ${err.message.split('\n')[0]}`);
// Fallback: 使用 pgrep 兜底
try {
const { stdout: pgrepOut } = await execAsync('pgrep -x "wechat|wechat-bin|xwechat"', { env: envWithPath });
const pids = pgrepOut.trim().split(/\s+/).filter(p => p);
if (pids.length > 0) {
pid = parseInt(pids[0], 10);
console.log(`[Debug] 第 ${i + 1} 秒,通过 pgrep 成功获取 PID: ${pid}`);
break;
}
} catch (e: any) {
console.log(`[Debug] 第 ${i + 1}pgrep 也失败: ${e.message.split('\n')[0]}`);
}
}
}
if (!pid) {
const err = '未能自动启动微信,手动启动并登录。'
const err = '未能自动启动微信,或获取PID失败请查看控制台日志或手动启动并登录。'
onStatus?.(err, 2)
return { success: false, error: err }
}
@@ -82,6 +153,7 @@ export class KeyServiceLinux {
return await this.getDbKey(pid, onStatus)
} catch (err: any) {
console.error('[Debug] 自动获取流程彻底崩溃:', err);
const errMsg = '自动获取微信 PID 失败: ' + err.message
onStatus?.(errMsg, 2)
return { success: false, error: errMsg }

View File

@@ -389,7 +389,7 @@ export class KeyServiceMac {
`set timeoutSec to ${timeoutSec}`,
'try',
'with timeout of timeoutSec seconds',
'set outText to do shell script cmd with administrator privileges',
'set outText to do shell script (cmd & " 2>&1") with administrator privileges',
'end timeout',
'return "WF_OK::" & outText',
'on error errMsg number errNum partial result pr',
@@ -935,10 +935,17 @@ export class KeyServiceMac {
private resolveXwechatRootFromPath(accountPath?: string): string | null {
const normalized = String(accountPath || '').replace(/\\/g, '/').replace(/\/+$/, '')
if (!normalized) return null
// 旧路径xwechat_files
const marker = '/xwechat_files'
const markerIdx = normalized.indexOf(marker)
if (markerIdx < 0) return null
return normalized.slice(0, markerIdx + marker.length)
if (markerIdx >= 0) return normalized.slice(0, markerIdx + marker.length)
// 新路径(微信 4.0.5+Application Support/com.tencent.xinWeChat/2.0b4.0.9
const newMarkerMatch = normalized.match(/^(.*\/com\.tencent\.xinWeChat\/(?:\d+\.\d+b\d+\.\d+|\d+\.\d+\.\d+))(\/|$)/)
if (newMarkerMatch) return newMarkerMatch[1]
return null
}
private pushAccountIdCandidates(candidates: string[], value?: string): void {
@@ -1096,6 +1103,16 @@ export class KeyServiceMac {
candidates.add(`${base}/app_data/net/kvcomm`)
}
// 微信 4.0.5+ 新路径推导:版本目录同级的 net/kvcomm
const newMarkerMatch = normalized.match(/^(.*\/com\.tencent\.xinWeChat\/(?:\d+\.\d+b\d+\.\d+|\d+\.\d+\.\d+))/)
if (newMarkerMatch) {
const versionBase = newMarkerMatch[1]
candidates.add(`${versionBase}/net/kvcomm`)
// 上级目录也尝试
const parentBase = versionBase.replace(/\/[^\/]+$/, '')
candidates.add(`${parentBase}/net/kvcomm`)
}
let cursor = accountPath
for (let i = 0; i < 6; i++) {
candidates.add(join(cursor, 'net', 'kvcomm'))

View File

@@ -304,11 +304,8 @@ class MessagePushService {
}
const groupNicknames = await this.getGroupNicknames(chatroomId)
const normalizedSender = this.normalizeAccountId(senderUsername)
const nickname = groupNicknames[senderUsername]
|| groupNicknames[senderUsername.toLowerCase()]
|| groupNicknames[normalizedSender]
|| groupNicknames[normalizedSender.toLowerCase()]
const senderKey = senderUsername.toLowerCase()
const nickname = groupNicknames[senderKey]
if (nickname) {
return nickname
@@ -328,22 +325,33 @@ class MessagePushService {
}
const result = await wcdbService.getGroupNicknames(cacheKey)
const nicknames = result.success && result.nicknames ? result.nicknames : {}
const nicknames = result.success && result.nicknames
? this.sanitizeGroupNicknames(result.nicknames)
: {}
this.groupNicknameCache.set(cacheKey, { nicknames, updatedAt: Date.now() })
return nicknames
}
private normalizeAccountId(value: string): string {
const trimmed = String(value || '').trim()
if (!trimmed) return trimmed
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
return match ? match[1] : trimmed
private sanitizeGroupNicknames(nicknames: Record<string, string>): Record<string, string> {
const buckets = new Map<string, Set<string>>()
for (const [memberIdRaw, nicknameRaw] of Object.entries(nicknames || {})) {
const memberId = String(memberIdRaw || '').trim().toLowerCase()
const nickname = String(nicknameRaw || '').trim()
if (!memberId || !nickname) continue
const slot = buckets.get(memberId)
if (slot) {
slot.add(nickname)
} else {
buckets.set(memberId, new Set([nickname]))
}
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
return suffixMatch ? suffixMatch[1] : trimmed
const trusted: Record<string, string> = {}
for (const [memberId, nicknameSet] of buckets.entries()) {
if (nicknameSet.size !== 1) continue
trusted[memberId] = Array.from(nicknameSet)[0]
}
return trusted
}
private isRecentMessage(messageKey: string): boolean {

View File

@@ -27,6 +27,17 @@ export interface SnsMedia {
livePhoto?: SnsLivePhoto
}
export interface SnsLocation {
latitude?: number
longitude?: number
city?: string
country?: string
poiName?: string
poiAddress?: string
poiAddressName?: string
label?: string
}
export interface SnsPost {
id: string
tid?: string // 数据库主键(雪花 ID用于精确删除
@@ -39,6 +50,7 @@ export interface SnsPost {
media: SnsMedia[]
likes: string[]
comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] }[]
location?: SnsLocation
rawXml?: string
linkTitle?: string
linkUrl?: string
@@ -287,6 +299,17 @@ function parseCommentsFromXml(xml: string): ParsedCommentItem[] {
return comments
}
const decodeXmlText = (text: string): string => {
if (!text) return ''
return text
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
}
class SnsService {
private configService: ConfigService
private contactCache: ContactCacheService
@@ -647,6 +670,110 @@ class SnsService {
return { media, videoKey }
}
private toOptionalNumber(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
if (!trimmed) return undefined
const parsed = Number.parseFloat(trimmed)
return Number.isFinite(parsed) ? parsed : undefined
}
private normalizeLocation(input: unknown): SnsLocation | undefined {
if (!input || typeof input !== 'object') return undefined
const row = input as Record<string, unknown>
const normalizeText = (value: unknown): string | undefined => {
if (typeof value !== 'string') return undefined
return this.toOptionalString(decodeXmlText(value))
}
const location: SnsLocation = {}
const latitude = this.toOptionalNumber(row.latitude ?? row.lat ?? row.x)
const longitude = this.toOptionalNumber(row.longitude ?? row.lng ?? row.y)
const city = normalizeText(row.city)
const country = normalizeText(row.country)
const poiName = normalizeText(row.poiName ?? row.poiname)
const poiAddress = normalizeText(row.poiAddress ?? row.poiaddress)
const poiAddressName = normalizeText(row.poiAddressName ?? row.poiaddressname)
const label = normalizeText(row.label)
if (latitude !== undefined) location.latitude = latitude
if (longitude !== undefined) location.longitude = longitude
if (city) location.city = city
if (country) location.country = country
if (poiName) location.poiName = poiName
if (poiAddress) location.poiAddress = poiAddress
if (poiAddressName) location.poiAddressName = poiAddressName
if (label) location.label = label
return Object.keys(location).length > 0 ? location : undefined
}
private parseLocationFromXml(xml: string): SnsLocation | undefined {
if (!xml) return undefined
try {
const locationTagMatch = xml.match(/<location\b([^>]*)>/i)
const locationAttrs = locationTagMatch?.[1] || ''
const readAttr = (name: string): string | undefined => {
if (!locationAttrs) return undefined
const match = locationAttrs.match(new RegExp(`${name}\\s*=\\s*["']([\\s\\S]*?)["']`, 'i'))
if (!match?.[1]) return undefined
return this.toOptionalString(decodeXmlText(match[1]))
}
const readTag = (name: string): string | undefined => {
const match = xml.match(new RegExp(`<${name}>([\\s\\S]*?)<\\/${name}>`, 'i'))
if (!match?.[1]) return undefined
return this.toOptionalString(decodeXmlText(match[1]))
}
const location: SnsLocation = {}
const latitude = this.toOptionalNumber(readAttr('latitude') || readAttr('x') || readTag('latitude') || readTag('x'))
const longitude = this.toOptionalNumber(readAttr('longitude') || readAttr('y') || readTag('longitude') || readTag('y'))
const city = readAttr('city') || readTag('city')
const country = readAttr('country') || readTag('country')
const poiName = readAttr('poiName') || readAttr('poiname') || readTag('poiName') || readTag('poiname')
const poiAddress = readAttr('poiAddress') || readAttr('poiaddress') || readTag('poiAddress') || readTag('poiaddress')
const poiAddressName = readAttr('poiAddressName') || readAttr('poiaddressname') || readTag('poiAddressName') || readTag('poiaddressname')
const label = readAttr('label') || readTag('label')
if (latitude !== undefined) location.latitude = latitude
if (longitude !== undefined) location.longitude = longitude
if (city) location.city = city
if (country) location.country = country
if (poiName) location.poiName = poiName
if (poiAddress) location.poiAddress = poiAddress
if (poiAddressName) location.poiAddressName = poiAddressName
if (label) location.label = label
return Object.keys(location).length > 0 ? location : undefined
} catch (e) {
console.error('[SnsService] 解析位置 XML 失败:', e)
return undefined
}
}
private mergeLocation(primary?: SnsLocation, fallback?: SnsLocation): SnsLocation | undefined {
if (!primary && !fallback) return undefined
const merged: SnsLocation = {}
const setValue = <K extends keyof SnsLocation>(key: K, value: SnsLocation[K] | undefined) => {
if (value !== undefined) merged[key] = value
}
setValue('latitude', primary?.latitude ?? fallback?.latitude)
setValue('longitude', primary?.longitude ?? fallback?.longitude)
setValue('city', primary?.city ?? fallback?.city)
setValue('country', primary?.country ?? fallback?.country)
setValue('poiName', primary?.poiName ?? fallback?.poiName)
setValue('poiAddress', primary?.poiAddress ?? fallback?.poiAddress)
setValue('poiAddressName', primary?.poiAddressName ?? fallback?.poiAddressName)
setValue('label', primary?.label ?? fallback?.label)
return Object.keys(merged).length > 0 ? merged : undefined
}
private getSnsCacheDir(): string {
const cachePath = this.configService.getCacheBasePath()
const snsCacheDir = join(cachePath, 'sns_cache')
@@ -948,7 +1075,12 @@ class SnsService {
const enrichedTimeline = result.timeline.map((post: any) => {
const contact = this.contactCache.get(post.username)
const isVideoPost = post.type === 15
const videoKey = extractVideoKey(post.rawXml || '')
const rawXml = post.rawXml || ''
const videoKey = extractVideoKey(rawXml)
const location = this.mergeLocation(
this.normalizeLocation((post as { location?: unknown }).location),
this.parseLocationFromXml(rawXml)
)
const fixedMedia = (post.media || []).map((m: any) => ({
url: fixSnsUrl(m.url, m.token, isVideoPost),
@@ -971,7 +1103,6 @@ class SnsService {
// 如果 DLL 评论缺少表情包信息,回退到从 rawXml 重新解析
const dllComments: any[] = post.comments || []
const hasEmojisInDll = dllComments.some((c: any) => c.emojis && c.emojis.length > 0)
const rawXml = post.rawXml || ''
let finalComments: any[]
if (dllComments.length > 0 && (hasEmojisInDll || !rawXml)) {
@@ -990,7 +1121,8 @@ class SnsService {
avatarUrl: contact?.avatarUrl,
nickname: post.nickname || contact?.displayName || post.username,
media: fixedMedia,
comments: finalComments
comments: finalComments,
location
}
})
@@ -1346,6 +1478,7 @@ class SnsService {
})),
likes: p.likes,
comments: p.comments,
location: p.location,
linkTitle: (p as any).linkTitle,
linkUrl: (p as any).linkUrl
}))
@@ -1397,6 +1530,7 @@ class SnsService {
})),
likes: post.likes,
comments: post.comments,
location: post.location,
likesDetail,
commentsDetail,
linkTitle: (post as any).linkTitle,
@@ -1479,6 +1613,27 @@ class SnsService {
const ch = name.charAt(0)
return escapeHtml(ch || '?')
}
const normalizeLocationText = (value?: string): string => (
decodeXmlText(String(value || '')).replace(/\s+/g, ' ').trim()
)
const resolveLocationText = (location?: SnsLocation): string => {
if (!location) return ''
const primaryCandidates = [
normalizeLocationText(location.poiName),
normalizeLocationText(location.poiAddressName),
normalizeLocationText(location.label),
normalizeLocationText(location.poiAddress)
].filter(Boolean)
const primary = primaryCandidates[0] || ''
const region = [
normalizeLocationText(location.country),
normalizeLocationText(location.city)
].filter(Boolean).join(' ')
if (primary && region && !primary.includes(region)) {
return `${primary} · ${region}`
}
return primary || region
}
let filterInfo = ''
if (filters.keyword) filterInfo += `关键词: "${escapeHtml(filters.keyword)}" `
@@ -1502,6 +1657,10 @@ class SnsService {
const linkHtml = post.linkTitle && post.linkUrl
? `<a class="lk" href="${escapeHtml(post.linkUrl)}" target="_blank"><span class="lk-t">${escapeHtml(post.linkTitle)}</span><span class="lk-a"></span></a>`
: ''
const locationText = resolveLocationText(post.location)
const locationHtml = locationText
? `<div class="loc"><span class="loc-i">📍</span><span class="loc-t">${escapeHtml(locationText)}</span></div>`
: ''
const likesHtml = post.likes.length > 0
? `<div class="interactions"><div class="likes">♥ ${post.likes.map(l => `<span>${escapeHtml(l)}</span>`).join('、')}</div></div>`
@@ -1524,6 +1683,7 @@ ${avatarHtml}
<div class="body">
<div class="hd"><span class="nick">${escapeHtml(post.nickname)}</span><span class="tm">${formatTime(post.createTime)}</span></div>
${post.contentDesc ? `<div class="txt">${escapeHtml(post.contentDesc)}</div>` : ''}
${locationHtml}
${mediaHtml ? `<div class="mg ${gridClass}">${mediaHtml}</div>` : ''}
${linkHtml}
${likesHtml}
@@ -1559,6 +1719,9 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hira
.nick{font-size:15px;font-weight:700;color:var(--accent);margin-bottom:2px}
.tm{font-size:12px;color:var(--t3)}
.txt{font-size:15px;line-height:1.6;white-space:pre-wrap;word-break:break-word;margin-bottom:12px}
.loc{display:flex;align-items:flex-start;gap:6px;font-size:13px;color:var(--t2);margin:-4px 0 12px}
.loc-i{line-height:1.3}
.loc-t{line-height:1.45;word-break:break-word}
/* 媒体网格 */
.mg{display:grid;gap:6px;margin-bottom:12px;max-width:320px}

View File

@@ -75,6 +75,14 @@ export class VoiceTranscribeService {
if (candidates.length === 0) {
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
}
} else if (process.platform === 'win32') {
// Windows: 把 sherpa-onnx DLL 所在目录加到 PATH否则 native module 找不到依赖
const existing = env['PATH'] || ''
const merged = [...candidates, ...existing.split(';').filter(Boolean)]
env['PATH'] = Array.from(new Set(merged)).join(';')
if (candidates.length === 0) {
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
}
}
return env
@@ -273,8 +281,20 @@ export class VoiceTranscribeService {
})
worker.on('error', (err: Error) => resolve({ success: false, error: String(err) }))
worker.on('exit', (code: number) => {
if (code !== 0) resolve({ success: false, error: `Worker exited with code ${code}` })
worker.on('exit', (code: number | null, signal: string | null) => {
if (code === null || signal === 'SIGSEGV') {
console.error(`[VoiceTranscribe] Worker 异常崩溃,信号: ${signal}。可能是由于底层 C++ 运行库在当前系统上发生段错误。`);
resolve({
success: false,
error: 'SEGFAULT_ERROR'
});
return;
}
if (code !== 0) {
resolve({ success: false, error: `Worker exited with code ${code}` });
}
})
} catch (error) {

View File

@@ -68,6 +68,8 @@ export class WcdbCore {
private wcdbListMediaDbs: any = null
private wcdbGetMessageById: any = null
private wcdbGetEmoticonCdnUrl: any = null
private wcdbGetEmoticonCaption: any = null
private wcdbGetEmoticonCaptionStrict: any = null
private wcdbGetDbStatus: any = null
private wcdbGetVoiceData: any = null
private wcdbGetVoiceDataBatch: any = null
@@ -124,6 +126,10 @@ export class WcdbCore {
this.writeLog(`[bootstrap] setPaths resourcesPath=${resourcesPath} userDataPath=${userDataPath}`, true)
}
getLastInitError(): string | null {
return lastDllInitError
}
setLogEnabled(enabled: boolean): void {
this.logEnabled = enabled
this.writeLog(`[bootstrap] setLogEnabled=${enabled ? '1' : '0'} env.WCDB_LOG_ENABLED=${process.env.WCDB_LOG_ENABLED || ''}`, true)
@@ -264,8 +270,9 @@ export class WcdbCore {
private getDllPath(): string {
const isMac = process.platform === 'darwin'
const isLinux = process.platform === 'linux'
const isArm64 = process.arch === 'arm64'
const libName = isMac ? 'libwcdb_api.dylib' : isLinux ? 'libwcdb_api.so' : 'wcdb_api.dll'
const subDir = isMac ? 'macos' : isLinux ? 'linux' : ''
const subDir = isMac ? 'macos' : isLinux ? 'linux' : (isArm64 ? 'arm64' : '')
const envDllPath = process.env.WCDB_DLL_PATH
if (envDllPath && envDllPath.length > 0) {
@@ -296,6 +303,20 @@ export class WcdbCore {
return candidates[0] || libName
}
private formatInitProtectionError(code: number): string {
const messages: Record<number, string> = {
'-3001': '未找到数据库目录 (db_storage),请确认已选择正确的微信数据目录(应包含以 wxid_ 开头的子文件夹)',
'-3002': '未找到 session.db 文件,请确认微信已登录并且数据目录完整',
'-3003': '数据库句柄无效,请重试',
'-3004': '恢复数据库连接失败,请重试',
'-2301': '动态库加载失败,请检查安装是否完整',
'-2302': 'WCDB 初始化异常,请重试',
'-2303': 'WCDB 未能成功初始化',
}
const msg = messages[String(code) as keyof typeof messages]
return msg ? `${msg} (错误码: ${code})` : `操作失败,错误码: ${code}`
}
private isLogEnabled(): boolean {
// 移除 Worker 线程的日志禁用逻辑,允许在 Worker 中记录日志
if (process.env.WCDB_LOG_ENABLED === '1') return true
@@ -469,6 +490,49 @@ export class WcdbCore {
}
} catch { }
}
// 兜底:向上查找 db_storage最多 2 级),处理用户选择了子目录的情况
try {
let parent = normalized
for (let i = 0; i < 2; i++) {
const up = join(parent, '..')
if (up === parent) break
parent = up
const candidateUp = join(parent, 'db_storage')
if (existsSync(candidateUp)) return candidateUp
if (wxid) {
const viaWxidUp = join(parent, wxid, 'db_storage')
if (existsSync(viaWxidUp)) return viaWxidUp
}
}
} catch { }
// 兜底:递归搜索 basePath 下的 db_storage 目录(最多 3 层深)
try {
const found = this.findDbStorageRecursive(normalized, 3)
if (found) return found
} catch { }
return null
}
private findDbStorageRecursive(dir: string, maxDepth: number): string | null {
if (maxDepth <= 0) return null
try {
const entries = readdirSync(dir)
for (const entry of entries) {
if (entry.toLowerCase() === 'db_storage') {
const candidate = join(dir, entry)
try { if (statSync(candidate).isDirectory()) return candidate } catch { }
}
}
for (const entry of entries) {
const entryPath = join(dir, entry)
try {
if (statSync(entryPath).isDirectory()) {
const found = this.findDbStorageRecursive(entryPath, maxDepth - 1)
if (found) return found
}
} catch { }
}
} catch { }
return null
}
@@ -617,11 +681,13 @@ export class WcdbCore {
}
}
this.writeLog(`[bootstrap] koffi.load begin path=${dllPath}`, true)
this.lib = this.koffi.load(dllPath)
this.writeLog('[bootstrap] koffi.load ok', true)
// InitProtection (Added for security)
try {
this.wcdbInitProtection = this.lib.func('bool InitProtection(const char* resourcePath)')
this.wcdbInitProtection = this.lib.func('int32 InitProtection(const char* resourcePath)')
// 尝试多个可能的资源路径
const resourcePaths = [
@@ -634,26 +700,40 @@ export class WcdbCore {
].filter(Boolean)
let protectionOk = false
let protectionCode = -1
let bestFailCode: number | null = null
const scoreFailCode = (code: number): number => {
if (code >= -2212 && code <= -2201) return 0 // manifest/signature/hash failures
if (code === -102 || code === -101 || code === -1006) return 1
return 2
}
for (const resPath of resourcePaths) {
try {
//
protectionOk = this.wcdbInitProtection(resPath)
if (protectionOk) {
//
this.writeLog(`[bootstrap] InitProtection call path=${resPath}`, true)
protectionCode = Number(this.wcdbInitProtection(resPath))
if (protectionCode === 0) {
protectionOk = true
break
}
if (bestFailCode === null || scoreFailCode(protectionCode) < scoreFailCode(bestFailCode)) {
bestFailCode = protectionCode
}
this.writeLog(`[bootstrap] InitProtection rc=${protectionCode} path=${resPath}`, true)
} catch (e) {
// console.warn(`[WCDB] InitProtection 失败 (${resPath}):`, e)
this.writeLog(`[bootstrap] InitProtection exception path=${resPath}: ${String(e)}`, true)
}
}
if (!protectionOk) {
// console.warn('[WCDB] Core security check failed - 继续运行但可能不稳定')
// this.writeLog('InitProtection 失败,继续运行')
// 不返回 false允许继续运行
const finalCode = bestFailCode ?? protectionCode
lastDllInitError = this.formatInitProtectionError(finalCode)
this.writeLog(`[bootstrap] InitProtection failed finalCode=${finalCode}`, true)
return false
}
} catch (e) {
// console.warn('InitProtection symbol not found:', e)
lastDllInitError = this.formatInitProtectionError(-2301)
this.writeLog(`[bootstrap] InitProtection symbol load failed: ${String(e)}`, true)
return false
}
// 定义类型
@@ -852,6 +932,22 @@ export class WcdbCore {
// wcdb_status wcdb_get_emoticon_cdn_url(wcdb_handle handle, const char* db_path, const char* md5, char** out_url)
this.wcdbGetEmoticonCdnUrl = this.lib.func('int32 wcdb_get_emoticon_cdn_url(int64 handle, const char* dbPath, const char* md5, _Out_ void** outUrl)')
// wcdb_status wcdb_get_emoticon_caption(wcdb_handle handle, const char* db_path, const char* md5, char** out_caption)
try {
this.wcdbGetEmoticonCaption = this.lib.func('int32 wcdb_get_emoticon_caption(int64 handle, const char* dbPath, const char* md5, _Out_ void** outCaption)')
} catch (e) {
this.wcdbGetEmoticonCaption = null
this.writeLog(`[diag:emoji] symbol missing wcdb_get_emoticon_caption: ${String(e)}`, true)
}
// wcdb_status wcdb_get_emoticon_caption_strict(wcdb_handle handle, const char* md5, char** out_caption)
try {
this.wcdbGetEmoticonCaptionStrict = this.lib.func('int32 wcdb_get_emoticon_caption_strict(int64 handle, const char* md5, _Out_ void** outCaption)')
} catch (e) {
this.wcdbGetEmoticonCaptionStrict = null
this.writeLog(`[diag:emoji] symbol missing wcdb_get_emoticon_caption_strict: ${String(e)}`, true)
}
// wcdb_status wcdb_list_message_dbs(wcdb_handle handle, char** out_json)
this.wcdbListMessageDbs = this.lib.func('int32 wcdb_list_message_dbs(int64 handle, _Out_ void** outJson)')
@@ -1055,7 +1151,7 @@ export class WcdbCore {
const initResult = this.wcdbInit()
if (initResult !== 0) {
console.error('WCDB 初始化失败:', initResult)
lastDllInitError = `初始化失败(错误码: ${initResult}`
lastDllInitError = this.formatInitProtectionError(initResult)
return false
}
@@ -1066,14 +1162,7 @@ export class WcdbCore {
const errorMsg = e instanceof Error ? e.message : String(e)
console.error('WCDB 初始化异常:', errorMsg)
this.writeLog(`WCDB 初始化异常: ${errorMsg}`, true)
lastDllInitError = errorMsg
// 检查是否是常见的 VC++ 运行时缺失错误
if (errorMsg.includes('126') || errorMsg.includes('找不到指定的模块') ||
errorMsg.includes('The specified module could not be found')) {
lastDllInitError = '可能缺少 Visual C++ 运行时库。请安装 Microsoft Visual C++ Redistributable (x64)。'
} else if (errorMsg.includes('193') || errorMsg.includes('不是有效的 Win32 应用程序')) {
lastDllInitError = 'DLL 架构不匹配。请确保使用 64 位版本的应用程序。'
}
lastDllInitError = this.formatInitProtectionError(-2302)
return false
}
}
@@ -1100,8 +1189,7 @@ export class WcdbCore {
if (!this.initialized) {
const initOk = await this.initialize()
if (!initOk) {
// 返回更详细的错误信息,帮助用户诊断问题
const detailedError = lastDllInitError || 'WCDB 初始化失败'
const detailedError = lastDllInitError || this.formatInitProtectionError(-2303)
return { success: false, error: detailedError }
}
}
@@ -1111,7 +1199,7 @@ export class WcdbCore {
this.writeLog(`testConnection dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`)
if (!dbStoragePath || !existsSync(dbStoragePath)) {
return { success: false, error: `数据库目录不存在: ${dbPath}` }
return { success: false, error: this.formatInitProtectionError(-3001) }
}
// 递归查找 session.db
@@ -1119,7 +1207,7 @@ export class WcdbCore {
this.writeLog(`testConnection sessionDb=${sessionDbPath || 'null'}`)
if (!sessionDbPath) {
return { success: false, error: `未找到 session.db 文件` }
return { success: false, error: this.formatInitProtectionError(-3002) }
}
// 分配输出参数内存
@@ -1128,17 +1216,13 @@ export class WcdbCore {
if (result !== 0) {
await this.printLogs()
let errorMsg = '数据库打开失败'
if (result === -1) errorMsg = '参数错误'
else if (result === -2) errorMsg = '密钥错误'
else if (result === -3) errorMsg = '数据库打开失败'
this.writeLog(`testConnection openAccount failed code=${result}`)
return { success: false, error: `${errorMsg} (错误码: ${result})` }
return { success: false, error: this.formatInitProtectionError(result) }
}
const tempHandle = handleOut[0]
if (tempHandle <= 0) {
return { success: false, error: '无效的数据库句柄' }
return { success: false, error: this.formatInitProtectionError(-3003) }
}
// 测试成功:使用 shutdown 清理资源(包括测试句柄)
@@ -1167,7 +1251,7 @@ export class WcdbCore {
} catch (e) {
console.error('测试连接异常:', e)
this.writeLog(`testConnection exception: ${String(e)}`)
return { success: false, error: String(e) }
return { success: false, error: this.formatInitProtectionError(-3004) }
}
}
@@ -1359,6 +1443,7 @@ export class WcdbCore {
*/
async open(dbPath: string, hexKey: string, wxid: string): Promise<boolean> {
try {
lastDllInitError = null
if (!this.initialized) {
const initOk = await this.initialize()
if (!initOk) return false
@@ -1386,6 +1471,7 @@ export class WcdbCore {
if (!dbStoragePath || !existsSync(dbStoragePath)) {
console.error('数据库目录不存在:', dbPath)
this.writeLog(`open failed: dbStorage not found for ${dbPath}`)
lastDllInitError = this.formatInitProtectionError(-3001)
return false
}
@@ -1394,6 +1480,7 @@ export class WcdbCore {
if (!sessionDbPath) {
console.error('未找到 session.db 文件')
this.writeLog('open failed: session.db not found')
lastDllInitError = this.formatInitProtectionError(-3002)
return false
}
@@ -1404,11 +1491,13 @@ export class WcdbCore {
console.error('打开数据库失败:', result)
await this.printLogs()
this.writeLog(`open failed: openAccount code=${result}`)
lastDllInitError = this.formatInitProtectionError(result)
return false
}
const handle = handleOut[0]
if (handle <= 0) {
lastDllInitError = this.formatInitProtectionError(-3003)
return false
}
@@ -1418,6 +1507,7 @@ export class WcdbCore {
this.currentWxid = wxid
this.currentDbStoragePath = dbStoragePath
this.initialized = true
lastDllInitError = null
if (this.wcdbSetMyWxid && wxid) {
try {
this.wcdbSetMyWxid(this.handle, wxid)
@@ -1435,6 +1525,7 @@ export class WcdbCore {
} catch (e) {
console.error('打开数据库异常:', e)
this.writeLog(`open exception: ${String(e)}`)
lastDllInitError = this.formatInitProtectionError(-3004)
return false
}
}
@@ -2700,6 +2791,48 @@ export class WcdbCore {
}
}
async getEmoticonCaption(dbPath: string, md5: string): Promise<{ success: boolean; caption?: string; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
if (!this.wcdbGetEmoticonCaption) {
return { success: false, error: '接口未就绪: wcdb_get_emoticon_caption' }
}
try {
const outPtr = [null as any]
const result = this.wcdbGetEmoticonCaption(this.handle, dbPath || '', md5, outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取表情释义失败: ${result}` }
}
const captionStr = this.decodeJsonPtr(outPtr[0])
if (captionStr === null) return { success: false, error: '解析表情释义失败' }
return { success: true, caption: captionStr || undefined }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getEmoticonCaptionStrict(md5: string): Promise<{ success: boolean; caption?: string; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
if (!this.wcdbGetEmoticonCaptionStrict) {
return { success: false, error: '接口未就绪: wcdb_get_emoticon_caption_strict' }
}
try {
const outPtr = [null as any]
const result = this.wcdbGetEmoticonCaptionStrict(this.handle, md5, outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取表情释义失败(strict): ${result}` }
}
const captionStr = this.decodeJsonPtr(outPtr[0])
if (captionStr === null) return { success: false, error: '解析表情释义失败(strict)' }
return { success: true, caption: captionStr || undefined }
} catch (e) {
return { success: false, error: String(e) }
}
}
async listMessageDbs(): Promise<{ success: boolean; data?: string[]; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
try {

View File

@@ -164,6 +164,10 @@ export class WcdbService {
return this.callWorker('open', { dbPath, hexKey, wxid })
}
async getLastInitError(): Promise<string | null> {
return this.callWorker('getLastInitError')
}
/**
* 关闭数据库连接
*/
@@ -455,6 +459,20 @@ export class WcdbService {
return this.callWorker('getEmoticonCdnUrl', { dbPath, md5 })
}
/**
* 获取表情包释义
*/
async getEmoticonCaption(dbPath: string, md5: string): Promise<{ success: boolean; caption?: string; error?: string }> {
return this.callWorker('getEmoticonCaption', { dbPath, md5 })
}
/**
* 获取表情包释义(严格 DLL 接口)
*/
async getEmoticonCaptionStrict(md5: string): Promise<{ success: boolean; caption?: string; error?: string }> {
return this.callWorker('getEmoticonCaptionStrict', { md5 })
}
/**
* 列出消息数据库
*/

View File

@@ -37,6 +37,9 @@ if (parentPort) {
case 'open':
result = await core.open(payload.dbPath, payload.hexKey, payload.wxid)
break
case 'getLastInitError':
result = core.getLastInitError()
break
case 'close':
core.close()
result = { success: true }
@@ -170,6 +173,12 @@ if (parentPort) {
case 'getEmoticonCdnUrl':
result = await core.getEmoticonCdnUrl(payload.dbPath, payload.md5)
break
case 'getEmoticonCaption':
result = await core.getEmoticonCaption(payload.dbPath, payload.md5)
break
case 'getEmoticonCaptionStrict':
result = await core.getEmoticonCaptionStrict(payload.md5)
break
case 'listMessageDbs':
result = await core.listMessageDbs()
break

6
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "weflow",
"version": "2.1.0",
"version": "4.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "weflow",
"version": "2.1.0",
"version": "4.2.0",
"hasInstallScript": true,
"dependencies": {
"echarts": "^5.5.1",
@@ -11062,4 +11062,4 @@
}
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "weflow",
"version": "2.1.0",
"version": "4.2.0",
"description": "WeFlow",
"main": "dist-electron/main.js",
"author": {
@@ -95,12 +95,18 @@
"linux": {
"icon": "public/icon.png",
"target": [
"deb",
"appimage",
"tar.gz"
],
"category": "Utility",
"executableName": "weflow",
"synopsis": "WeFlow for Linux"
"synopsis": "WeFlow for Linux",
"extraFiles": [
{
"from": "resources/linux/install.sh",
"to": "install.sh"
}
]
},
"nsis": {
"oneClick": false,
@@ -172,4 +178,4 @@
],
"icon": "resources/icon.icns"
}
}
}

BIN
resources/arm64/WCDB.dll Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
resources/libwcdb_api.so Executable file

Binary file not shown.

View File

@@ -0,0 +1,59 @@
#!/bin/bash
set -e
APP_NAME="weflow"
APP_EXEC="weflow"
OPT_DIR="/opt/$APP_NAME"
BIN_LINK="/usr/bin/$APP_NAME"
DESKTOP_DIR="/usr/share/applications"
ICON_DIR="/usr/share/pixmaps"
if [ "$EUID" -ne 0 ]; then
echo "❌ 请使用 root 权限运行此脚本 (例如: sudo ./install.sh)"
exit 1
fi
echo "🚀 开始安装 $APP_NAME..."
echo "📦 正在复制文件到 $OPT_DIR..."
rm -rf "$OPT_DIR"
mkdir -p "$OPT_DIR"
cp -r ./* "$OPT_DIR/"
chmod -R 755 "$OPT_DIR"
chmod +x "$OPT_DIR/$APP_EXEC"
echo "🔗 正在创建软链接 $BIN_LINK..."
ln -sf "$OPT_DIR/$APP_EXEC" "$BIN_LINK"
echo "📝 正在创建桌面快捷方式..."
cat <<EOF >"$DESKTOP_DIR/${APP_NAME}.desktop"
[Desktop Entry]
Name=WeFlow
Exec=$OPT_DIR/$APP_EXEC %U
Terminal=false
Type=Application
Icon=$APP_NAME
StartupWMClass=WeFlow
Comment=A local WeChat database decryption and analysis tool
Categories=Utility;
EOF
chmod 644 "$DESKTOP_DIR/${APP_NAME}.desktop"
echo "🖼️ 正在安装图标..."
if [ -f "$OPT_DIR/resources/icon.png" ]; then
cp "$OPT_DIR/resources/icon.png" "$ICON_DIR/${APP_NAME}.png"
chmod 644 "$ICON_DIR/${APP_NAME}.png"
elif [ -f "$OPT_DIR/icon.png" ]; then
cp "$OPT_DIR/icon.png" "$ICON_DIR/${APP_NAME}.png"
chmod 644 "$ICON_DIR/${APP_NAME}.png"
else
echo "⚠️ 警告: 未找到图标文件,跳过图标安装。"
fi
if command -v update-desktop-database >/dev/null 2>&1; then
echo "🔄 更新桌面数据库..."
update-desktop-database "$DESKTOP_DIR"
fi
echo "✅ 安装完成!你现在可以在应用菜单中找到 WeFlow或者在终端输入 'weflow' 启动。"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
resources/xkey_helper_macos Normal file

Binary file not shown.

View File

@@ -104,6 +104,44 @@ function App() {
// 数据收集同意状态
const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false)
const [showWaylandWarning, setShowWaylandWarning] = useState(false)
useEffect(() => {
const checkWaylandStatus = async () => {
try {
// 防止在非客户端环境报错,先检查 API 是否存在
if (!window.electronAPI?.app?.checkWayland) return
// 通过 configService 检查是否已经弹过窗
const hasWarned = await window.electronAPI.config.get('waylandWarningShown')
if (!hasWarned) {
const isWayland = await window.electronAPI.app.checkWayland()
if (isWayland) {
setShowWaylandWarning(true)
}
}
} catch (e) {
console.error('检查 Wayland 状态失败:', e)
}
}
// 只有在协议同意之后并且已经进入主应用流程才检查
if (!isAgreementWindow && !isOnboardingWindow && !agreementLoading) {
checkWaylandStatus()
}
}, [isAgreementWindow, isOnboardingWindow, agreementLoading])
const handleDismissWaylandWarning = async () => {
try {
// 记录到本地配置中,下次不再提示
await window.electronAPI.config.set('waylandWarningShown', true)
} catch (e) {
console.error('保存 Wayland 提示状态失败:', e)
}
setShowWaylandWarning(false)
}
useEffect(() => {
if (location.pathname !== '/settings') {
settingsBackgroundRef.current = location
@@ -432,6 +470,8 @@ function App() {
checkLock()
}, [isAgreementWindow, isOnboardingWindow, isVideoPlayerWindow])
// 独立协议窗口
if (isAgreementWindow) {
return <AgreementPage />
@@ -614,6 +654,33 @@ function App() {
</div>
)}
{showWaylandWarning && (
<div className="agreement-overlay">
<div className="agreement-modal">
<div className="agreement-header">
<Shield size={32} />
<h2> (Wayland)</h2>
</div>
<div className="agreement-content">
<div className="agreement-text">
<p>使 <strong>Wayland</strong> </p>
<p> Wayland <strong></strong></p>
<p></p>
<br />
<p>使</p>
<p>1. <strong>X11 (Xorg)</strong> </p>
<p>2. (WM/DE) </p>
</div>
</div>
<div className="agreement-footer">
<div className="agreement-actions">
<button className="btn btn-primary" onClick={handleDismissWaylandWarning}></button>
</div>
</div>
</div>
</div>
)}
{/* 更新提示对话框 */}
<UpdateDialog
open={showUpdateDialog}

View File

@@ -1,7 +1,7 @@
import React, { useState, useMemo, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { Heart, ChevronRight, ImageIcon, Code, Trash2 } from 'lucide-react'
import { SnsPost, SnsLinkCardData } from '../../types/sns'
import { Heart, ChevronRight, ImageIcon, Code, Trash2, MapPin } from 'lucide-react'
import { SnsPost, SnsLinkCardData, SnsLocation } from '../../types/sns'
import { Avatar } from '../Avatar'
import { SnsMediaGrid } from './SnsMediaGrid'
import { getEmojiPath } from 'wechat-emojis'
@@ -134,6 +134,30 @@ const buildLinkCardData = (post: SnsPost): SnsLinkCardData | null => {
}
}
const buildLocationText = (location?: SnsLocation): string => {
if (!location) return ''
const normalize = (value?: string): string => (
decodeHtmlEntities(String(value || '')).replace(/\s+/g, ' ').trim()
)
const primary = [
normalize(location.poiName),
normalize(location.poiAddressName),
normalize(location.label),
normalize(location.poiAddress)
].find(Boolean) || ''
const region = [normalize(location.country), normalize(location.city)]
.filter(Boolean)
.join(' ')
if (primary && region && !primary.includes(region)) {
return `${primary} · ${region}`
}
return primary || region
}
const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => {
const [thumbFailed, setThumbFailed] = useState(false)
const hostname = useMemo(() => {
@@ -254,6 +278,7 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
const [deleting, setDeleting] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const linkCard = buildLinkCardData(post)
const locationText = useMemo(() => buildLocationText(post.location), [post.location])
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
const showLinkCard = Boolean(linkCard) && post.media.length <= 1 && !hasVideoMedia
const showMediaGrid = post.media.length > 0 && !showLinkCard
@@ -379,6 +404,13 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
<div className="post-text">{renderTextWithEmoji(decodeHtmlEntities(post.contentDesc))}</div>
)}
{locationText && (
<div className="post-location" title={locationText}>
<MapPin size={14} />
<span className="post-location-text">{locationText}</span>
</div>
)}
{showLinkCard && linkCard && (
<SnsLinkCard card={linkCard} />
)}

View File

@@ -625,7 +625,7 @@
.bubble-content {
background: var(--primary-gradient);
color: #fff;
color: var(--on-primary);
border-radius: 18px 18px 4px 18px;
padding: 10px 14px;
font-size: 14px;
@@ -1962,7 +1962,7 @@
.bubble-content {
background: var(--primary);
color: white;
color: var(--on-primary);
border-radius: 18px 18px 4px 18px;
}
}
@@ -2420,7 +2420,6 @@
background: rgba(0, 0, 0, 0.04);
border-left: 2px solid var(--primary);
padding: 6px 10px;
margin-bottom: 8px;
border-radius: 4px;
font-size: 13px;
@@ -2454,15 +2453,15 @@
// 自己发送的消息中的引用样式
.message-bubble.sent .quoted-message {
background: rgba(255, 255, 255, 0.15);
border-left-color: rgba(255, 255, 255, 0.5);
background: color-mix(in srgb, var(--on-primary) 12%, var(--primary));
border-left-color: color-mix(in srgb, var(--on-primary) 36%, var(--primary));
.quoted-sender {
color: rgba(255, 255, 255, 0.9);
color: color-mix(in srgb, var(--on-primary) 92%, var(--primary));
}
.quoted-text {
color: rgba(255, 255, 255, 0.8);
color: color-mix(in srgb, var(--on-primary) 80%, var(--primary));
}
}
@@ -2482,6 +2481,14 @@
.bubble-content {
-webkit-app-region: no-drag;
&.quote-layout-top .quoted-message {
margin-bottom: 8px;
}
&.quote-layout-bottom .quoted-message {
margin-top: 8px;
}
}
// 时间分隔

View File

@@ -52,6 +52,8 @@ interface GlobalMsgPrefixCacheEntry {
completed: boolean
}
type QuoteLayout = configService.QuoteLayout
const GLOBAL_MSG_PER_SESSION_LIMIT = 10
const GLOBAL_MSG_SEED_LIMIT = 120
const GLOBAL_MSG_BACKFILL_CONCURRENCY = 3
@@ -7556,6 +7558,7 @@ function MessageBubble({
const [senderAvatarUrl, setSenderAvatarUrl] = useState<string | undefined>(undefined)
const [senderName, setSenderName] = useState<string | undefined>(undefined)
const [quotedSenderName, setQuotedSenderName] = useState<string | undefined>(undefined)
const [quoteLayout, setQuoteLayout] = useState<QuoteLayout>('quote-top')
const senderProfileRequestSeqRef = useRef(0)
const [emojiError, setEmojiError] = useState(false)
const [emojiLoading, setEmojiLoading] = useState(false)
@@ -7611,6 +7614,12 @@ function MessageBubble({
const [voiceWaveform, setVoiceWaveform] = useState<number[]>([])
const voiceAutoDecryptTriggered = useRef(false)
const [systemAlert, setSystemAlert] = useState<{
title: string;
message: React.ReactNode;
} | null>(null)
// 转账消息双方名称
const [transferPayerName, setTransferPayerName] = useState<string | undefined>(undefined)
const [transferReceiverName, setTransferReceiverName] = useState<string | undefined>(undefined)
@@ -8290,9 +8299,9 @@ function MessageBubble({
}
const result = await window.electronAPI.chat.getVoiceTranscript(
session.username,
String(message.localId),
message.createTime
session.username,
String(message.localId),
message.createTime
)
if (result.success) {
@@ -8300,6 +8309,21 @@ function MessageBubble({
voiceTranscriptCache.set(voiceTranscriptCacheKey, transcriptText)
setVoiceTranscript(transcriptText)
} else {
if (result.error === 'SEGFAULT_ERROR') {
console.warn('[ChatPage] 捕获到语音引擎底层段错误');
setSystemAlert({
title: '引擎崩溃提示',
message: (
<>
(Segmentation Fault)<br /><br />
使 Linux <code>sherpa-onnx</code> ( glibc )
</>
)
});
}
setVoiceTranscriptError(true)
voiceTranscriptRequestedRef.current = false
}
@@ -8528,6 +8552,18 @@ function MessageBubble({
myWxid
])
useEffect(() => {
let cancelled = false
void configService.getQuoteLayout().then((layout) => {
if (!cancelled) setQuoteLayout(layout)
}).catch(() => {
if (!cancelled) setQuoteLayout('quote-top')
})
return () => {
cancelled = true
}
}, [])
const locationMessageMeta = useMemo(() => {
if (message.localType !== 48) return null
const raw = message.rawContent || ''
@@ -8563,6 +8599,31 @@ function MessageBubble({
// 是否有引用消息
const hasQuote = quotedContent.length > 0
const displayQuotedSenderName = quotedSenderName || quotedSenderFallbackName
const renderBubbleWithQuote = useCallback((quotedNode: React.ReactNode, messageNode: React.ReactNode) => {
const quoteFirst = quoteLayout !== 'quote-bottom'
return (
<div className={`bubble-content ${quoteFirst ? 'quote-layout-top' : 'quote-layout-bottom'}`}>
{quoteFirst ? (
<>
{quotedNode}
{messageNode}
</>
) : (
<>
{messageNode}
{quotedNode}
</>
)}
</div>
)
}, [quoteLayout])
const renderQuotedMessageBlock = useCallback((contentNode: React.ReactNode) => (
<div className="quoted-message">
{displayQuotedSenderName && <span className="quoted-sender">{displayQuotedSenderName}</span>}
<span className="quoted-text">{contentNode}</span>
</div>
), [displayQuotedSenderName])
const handlePlayVideo = useCallback(async () => {
if (!videoInfo?.videoUrl) return
@@ -9002,13 +9063,10 @@ function MessageBubble({
}
return (
<div className="bubble-content">
<div className="quoted-message">
{displayQuotedSenderName && <span className="quoted-sender">{displayQuotedSenderName}</span>}
<span className="quoted-text">{renderReferContent()}</span>
</div>
renderBubbleWithQuote(
renderQuotedMessageBlock(renderReferContent()),
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
</div>
)
)
}
@@ -9101,13 +9159,10 @@ function MessageBubble({
const replyText = message.linkTitle || q('title') || cleanedParsedContent || ''
const referContent = message.quotedContent || q('refermsg > content') || ''
return (
<div className="bubble-content">
<div className="quoted-message">
{displayQuotedSenderName && <span className="quoted-sender">{displayQuotedSenderName}</span>}
<span className="quoted-text">{renderTextWithEmoji(cleanMessageContent(referContent))}</span>
</div>
renderBubbleWithQuote(
renderQuotedMessageBlock(renderTextWithEmoji(cleanMessageContent(referContent))),
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
</div>
)
)
}
@@ -9317,13 +9372,10 @@ function MessageBubble({
}
return (
<div className="bubble-content">
<div className="quoted-message">
{displayQuotedSenderName && <span className="quoted-sender">{displayQuotedSenderName}</span>}
<span className="quoted-text">{renderReferContent2()}</span>
</div>
renderBubbleWithQuote(
renderQuotedMessageBlock(renderReferContent2()),
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
</div>
)
)
}
@@ -9602,14 +9654,9 @@ function MessageBubble({
// 带引用的消息
if (hasQuote) {
return (
<div className="bubble-content">
<div className="quoted-message">
{displayQuotedSenderName && <span className="quoted-sender">{displayQuotedSenderName}</span>}
<span className="quoted-text">{renderTextWithEmoji(cleanMessageContent(quotedContent))}</span>
</div>
<div className="message-text">{renderTextWithEmoji(cleanedParsedContent)}</div>
</div>
return renderBubbleWithQuote(
renderQuotedMessageBlock(renderTextWithEmoji(cleanMessageContent(quotedContent))),
<div className="message-text">{renderTextWithEmoji(cleanedParsedContent)}</div>
)
}
@@ -9699,6 +9746,31 @@ function MessageBubble({
{isSelected && <Check size={14} strokeWidth={3} />}
</div>
)}
{systemAlert && createPortal(
<div className="modal-overlay" onClick={() => setSystemAlert(null)} style={{ zIndex: 99999 }}>
<div className="delete-confirm-card" onClick={(e) => e.stopPropagation()} style={{ maxWidth: '400px' }}>
<div className="confirm-icon">
<AlertCircle size={32} color="var(--danger)" />
</div>
<div className="confirm-content">
<h3>{systemAlert.title}</h3>
<p style={{ marginTop: '12px', lineHeight: '1.6', fontSize: '14px', color: 'var(--text-secondary)' }}>
{systemAlert.message}
</p>
</div>
<div className="confirm-actions" style={{ justifyContent: 'center', marginTop: '24px' }}>
<button
className="btn-primary"
onClick={() => setSystemAlert(null)}
style={{ padding: '8px 32px' }}
>
</button>
</div>
</div>
</div>,
document.body
)}
</div>
</>
)

View File

@@ -18,7 +18,7 @@ const AVATAR_ENRICH_BATCH_SIZE = 80
const SEARCH_DEBOUNCE_MS = 120
const VIRTUAL_ROW_HEIGHT = 76
const VIRTUAL_OVERSCAN = 10
const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000
const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 10000
const AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
interface ContactsLoadSession {
@@ -397,6 +397,10 @@ function ContactsPage() {
displayName: contact.displayName,
remark: contact.remark,
nickname: contact.nickname,
alias: contact.alias,
labels: contact.labels,
detailDescription: contact.detailDescription,
region: contact.region,
type: contact.type
}))
).catch((error) => {
@@ -1110,6 +1114,16 @@ function ContactsPage() {
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.username}</span></div>
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.nickname || selectedContact.displayName}</span></div>
{selectedContact.remark && <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.remark}</span></div>}
{selectedContact.alias && <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.alias}</span></div>}
{selectedContact.labels && selectedContact.labels.length > 0 && (
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.labels.join('、')}</span></div>
)}
{selectedContact.detailDescription && (
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.detailDescription}</span></div>
)}
{selectedContact.region && (
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.region}</span></div>
)}
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{getContactTypeName(selectedContact.type)}</span></div>
{selectedContactSupportsSns && (
<div className="detail-row">

View File

@@ -49,6 +49,7 @@ import { SnsPostItem } from '../components/Sns/SnsPostItem'
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog'
import { ExportDefaultsSettingsForm, type ExportDefaultsSettingsPatch } from '../components/Export/ExportDefaultsSettingsForm'
import { Avatar } from '../components/Avatar'
import type { SnsPost } from '../types/sns'
import {
cloneExportDateRange,
@@ -92,6 +93,7 @@ interface ExportOptions {
txtColumns: string[]
displayNamePreference: DisplayNamePreference
exportConcurrency: number
imageDeepSearchOnMiss: boolean
}
interface SessionRow extends AppChatSession {
@@ -537,6 +539,14 @@ const getAvatarLetter = (name: string): string => {
return [...name][0] || '?'
}
const normalizeExportAvatarUrl = (value?: string | null): string | undefined => {
const normalized = String(value || '').trim()
if (!normalized) return undefined
const lower = normalized.toLowerCase()
if (lower === 'null' || lower === 'undefined') return undefined
return normalized
}
const toComparableNameSet = (values: Array<string | undefined | null>): Set<string> => {
const set = new Set<string>()
for (const value of values) {
@@ -558,7 +568,7 @@ const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(
const CONTACT_ENRICH_TIMEOUT_MS = 7000
const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000
const EXPORT_AVATAR_ENRICH_BATCH_SIZE = 80
const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000
const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 10000
const EXPORT_REENTER_SESSION_SOFT_REFRESH_MS = 5 * 60 * 1000
const EXPORT_REENTER_CONTACTS_SOFT_REFRESH_MS = 5 * 60 * 1000
const EXPORT_REENTER_SNS_SOFT_REFRESH_MS = 3 * 60 * 1000
@@ -1026,7 +1036,7 @@ const toSessionRowsWithContacts = (
kind: toKindByContact(contact),
wechatId: contact.username,
displayName: contact.displayName || session?.displayName || contact.username,
avatarUrl: contact.avatarUrl || session?.avatarUrl,
avatarUrl: session?.avatarUrl || contact.avatarUrl,
hasSession: Boolean(session)
} as SessionRow
})
@@ -1046,7 +1056,7 @@ const toSessionRowsWithContacts = (
kind: toKindByContactType(session, contact),
wechatId: contact?.username || session.username,
displayName: contact?.displayName || session.displayName || session.username,
avatarUrl: contact?.avatarUrl || session.avatarUrl,
avatarUrl: session.avatarUrl || contact?.avatarUrl,
hasSession: true
} as SessionRow
})
@@ -1593,6 +1603,7 @@ function ExportPage() {
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
const [exportDefaultImageDeepSearchOnMiss, setExportDefaultImageDeepSearchOnMiss] = useState(true)
const [options, setOptions] = useState<ExportOptions>({
format: 'json',
@@ -1611,7 +1622,8 @@ function ExportPage() {
excelCompactColumns: true,
txtColumns: defaultTxtColumns,
displayNamePreference: 'remark',
exportConcurrency: 2
exportConcurrency: 2,
imageDeepSearchOnMiss: true
})
const [exportDialog, setExportDialog] = useState<ExportDialogState>({
@@ -1710,6 +1722,7 @@ function ExportPage() {
startIndex: 0,
endIndex: -1
})
const avatarHydrationRequestedRef = useRef<Set<string>>(new Set())
const sessionMutualFriendsMetricsRef = useRef<Record<string, SessionMutualFriendsMetric>>({})
const sessionMutualFriendsDirectMetricsRef = useRef<Record<string, SessionMutualFriendsMetric>>({})
const sessionMutualFriendsQueueRef = useRef<string[]>([])
@@ -1915,7 +1928,7 @@ function ExportPage() {
setIsContactsListLoading(true)
try {
const contactsResult = await window.electronAPI.chat.getContacts()
const contactsResult = await window.electronAPI.chat.getContacts({ lite: true })
if (contactsLoadVersionRef.current !== loadVersion) return
if (contactsResult.success && contactsResult.contacts) {
@@ -1954,6 +1967,7 @@ function ExportPage() {
displayName: contact.displayName,
remark: contact.remark,
nickname: contact.nickname,
alias: contact.alias,
type: contact.type
}))
).catch((error) => {
@@ -1995,6 +2009,94 @@ function ExportPage() {
}
}, [ensureExportCacheScope, syncContactTypeCounts])
const hydrateVisibleContactAvatars = useCallback(async (usernames: string[]) => {
const targets = Array.from(new Set(
(usernames || [])
.map((username) => String(username || '').trim())
.filter(Boolean)
)).filter((username) => {
if (avatarHydrationRequestedRef.current.has(username)) return false
const contact = contactsList.find((item) => item.username === username)
const session = sessions.find((item) => item.username === username)
const existingAvatarUrl = normalizeExportAvatarUrl(contact?.avatarUrl || session?.avatarUrl)
return !existingAvatarUrl
})
if (targets.length === 0) return
targets.forEach((username) => avatarHydrationRequestedRef.current.add(username))
const settled = await Promise.allSettled(
targets.map(async (username) => {
const profile = await window.electronAPI.chat.getContactAvatar(username)
return {
username,
avatarUrl: normalizeExportAvatarUrl(profile?.avatarUrl),
displayName: profile?.displayName ? String(profile.displayName).trim() : undefined
}
})
)
const avatarPatches = new Map<string, { avatarUrl?: string; displayName?: string }>()
for (const item of settled) {
if (item.status !== 'fulfilled') continue
const { username, avatarUrl, displayName } = item.value
if (!avatarUrl && !displayName) continue
avatarPatches.set(username, { avatarUrl, displayName })
}
if (avatarPatches.size === 0) return
const now = Date.now()
setContactsList((prev) => prev.map((contact) => {
const patch = avatarPatches.get(contact.username)
if (!patch) return contact
return {
...contact,
displayName: patch.displayName || contact.displayName,
avatarUrl: patch.avatarUrl || contact.avatarUrl
}
}))
setSessions((prev) => prev.map((session) => {
const patch = avatarPatches.get(session.username)
if (!patch) return session
return {
...session,
displayName: patch.displayName || session.displayName,
avatarUrl: patch.avatarUrl || session.avatarUrl
}
}))
setSessionDetail((prev) => {
if (!prev) return prev
const patch = avatarPatches.get(prev.wxid)
if (!patch) return prev
return {
...prev,
displayName: patch.displayName || prev.displayName,
avatarUrl: patch.avatarUrl || prev.avatarUrl
}
})
let avatarCacheChanged = false
for (const [username, patch] of avatarPatches.entries()) {
if (!patch.avatarUrl) continue
const previous = contactsAvatarCacheRef.current[username]
if (previous?.avatarUrl === patch.avatarUrl) continue
contactsAvatarCacheRef.current[username] = {
avatarUrl: patch.avatarUrl,
updatedAt: now,
checkedAt: now
}
avatarCacheChanged = true
}
if (avatarCacheChanged) {
setAvatarCacheUpdatedAt(now)
const scopeKey = exportCacheScopeRef.current
if (scopeKey) {
void configService.setContactsAvatarCache(scopeKey, contactsAvatarCacheRef.current).catch(() => {})
}
}
}, [contactsList, sessions])
useEffect(() => {
if (!isExportRoute) return
let cancelled = false
@@ -2138,7 +2240,7 @@ function ExportPage() {
setIsBaseConfigLoading(true)
let isReady = true
try {
const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, exportCacheScope] = await Promise.all([
const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedImageDeepSearchOnMiss, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, exportCacheScope] = await Promise.all([
configService.getExportPath(),
configService.getExportDefaultFormat(),
configService.getExportDefaultAvatars(),
@@ -2147,6 +2249,7 @@ function ExportPage() {
configService.getExportDefaultExcelCompactColumns(),
configService.getExportDefaultTxtColumns(),
configService.getExportDefaultConcurrency(),
configService.getExportDefaultImageDeepSearchOnMiss(),
configService.getExportLastSessionRunMap(),
configService.getExportLastContentRunMap(),
configService.getExportSessionRecordMap(),
@@ -2183,6 +2286,7 @@ function ExportPage() {
setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
setExportDefaultConcurrency(savedConcurrency ?? 2)
setExportDefaultImageDeepSearchOnMiss(savedImageDeepSearchOnMiss ?? true)
const resolvedDefaultDateRange = resolveExportDateRangeConfig(savedDefaultDateRange)
setExportDefaultDateRangeSelection(resolvedDefaultDateRange)
setTimeRangeSelection(resolvedDefaultDateRange)
@@ -2215,7 +2319,8 @@ function ExportPage() {
exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText,
excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns,
txtColumns,
exportConcurrency: savedConcurrency ?? prev.exportConcurrency
exportConcurrency: savedConcurrency ?? prev.exportConcurrency,
imageDeepSearchOnMiss: savedImageDeepSearchOnMiss ?? prev.imageDeepSearchOnMiss
}))
} catch (error) {
isReady = false
@@ -3677,7 +3782,7 @@ function ExportPage() {
if (isStale()) return
if (detailStatsPriorityRef.current) return
const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS)
const contactsResult = await withTimeout(window.electronAPI.chat.getContacts({ lite: true }), CONTACT_ENRICH_TIMEOUT_MS)
if (isStale()) return
const contactsFromNetwork: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : []
@@ -3818,10 +3923,12 @@ function ExportPage() {
displayName: contact.displayName || contact.username,
remark: contact.remark,
nickname: contact.nickname,
alias: contact.alias,
type: contact.type
}))
const persistAt = Date.now()
setContactsList(contactsForPersist)
setSessions(nextSessions)
sessionsHydratedAtRef.current = persistAt
if (hasNetworkContactsSnapshot && contactsCachePayload.length > 0) {
@@ -3989,7 +4096,8 @@ function ExportPage() {
exportEmojis: exportDefaultMedia.emojis,
exportVoiceAsText: exportDefaultVoiceAsText,
excelCompactColumns: exportDefaultExcelCompactColumns,
exportConcurrency: exportDefaultConcurrency
exportConcurrency: exportDefaultConcurrency,
imageDeepSearchOnMiss: exportDefaultImageDeepSearchOnMiss
}
if (payload.scope === 'sns') {
@@ -4022,7 +4130,8 @@ function ExportPage() {
exportDefaultAvatars,
exportDefaultMedia,
exportDefaultVoiceAsText,
exportDefaultConcurrency
exportDefaultConcurrency,
exportDefaultImageDeepSearchOnMiss
])
const closeExportDialog = useCallback(() => {
@@ -4241,6 +4350,7 @@ function ExportPage() {
txtColumns: options.txtColumns,
displayNamePreference: options.displayNamePreference,
exportConcurrency: options.exportConcurrency,
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
sessionLayout,
sessionNameWithTypePrefix,
dateRange: options.useAllTime
@@ -4833,6 +4943,8 @@ function ExportPage() {
await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns)
await configService.setExportDefaultTxtColumns(options.txtColumns)
await configService.setExportDefaultConcurrency(options.exportConcurrency)
await configService.setExportDefaultImageDeepSearchOnMiss(options.imageDeepSearchOnMiss)
setExportDefaultImageDeepSearchOnMiss(options.imageDeepSearchOnMiss)
}
const openSingleExport = useCallback((session: SessionRow) => {
@@ -5369,6 +5481,11 @@ function ExportPage() {
const endIndex = Number.isFinite(range?.endIndex) ? Math.max(startIndex, Math.floor(range.endIndex)) : startIndex
sessionMediaMetricVisibleRangeRef.current = { startIndex, endIndex }
sessionMutualFriendsVisibleRangeRef.current = { startIndex, endIndex }
void hydrateVisibleContactAvatars(
filteredContacts
.slice(startIndex, endIndex + 1)
.map((contact) => contact.username)
)
const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts)
if (visibleTargets.length === 0) return
enqueueSessionMediaMetricRequests(visibleTargets, { front: true })
@@ -5384,10 +5501,23 @@ function ExportPage() {
enqueueSessionMediaMetricRequests,
enqueueSessionMutualFriendsRequests,
filteredContacts,
hydrateVisibleContactAvatars,
scheduleSessionMediaMetricWorker,
scheduleSessionMutualFriendsWorker
])
useEffect(() => {
if (filteredContacts.length === 0) return
const bootstrapTargets = filteredContacts.slice(0, 24).map((contact) => contact.username)
void hydrateVisibleContactAvatars(bootstrapTargets)
}, [filteredContacts, hydrateVisibleContactAvatars])
useEffect(() => {
const sessionId = String(sessionDetail?.wxid || '').trim()
if (!sessionId) return
void hydrateVisibleContactAvatars([sessionId])
}, [hydrateVisibleContactAvatars, sessionDetail?.wxid])
useEffect(() => {
if (activeTaskCount > 0) return
if (filteredContacts.length === 0) return
@@ -5582,6 +5712,45 @@ function ExportPage() {
return map
}, [contactsList])
useEffect(() => {
if (!showSessionDetailPanel) return
const sessionId = String(sessionDetail?.wxid || '').trim()
if (!sessionId) return
const mappedSession = sessionRowByUsername.get(sessionId)
const mappedContact = contactByUsername.get(sessionId)
if (!mappedSession && !mappedContact) return
setSessionDetail((prev) => {
if (!prev || prev.wxid !== sessionId) return prev
const nextDisplayName = mappedSession?.displayName || mappedContact?.displayName || prev.displayName || sessionId
const nextRemark = mappedContact?.remark ?? prev.remark
const nextNickName = mappedContact?.nickname ?? prev.nickName
const nextAlias = mappedContact?.alias ?? prev.alias
const nextAvatarUrl = mappedSession?.avatarUrl || mappedContact?.avatarUrl || prev.avatarUrl
if (
nextDisplayName === prev.displayName &&
nextRemark === prev.remark &&
nextNickName === prev.nickName &&
nextAlias === prev.alias &&
nextAvatarUrl === prev.avatarUrl
) {
return prev
}
return {
...prev,
displayName: nextDisplayName,
remark: nextRemark,
nickName: nextNickName,
alias: nextAlias,
avatarUrl: nextAvatarUrl
}
})
}, [contactByUsername, sessionDetail?.wxid, sessionRowByUsername, showSessionDetailPanel])
const currentSessionExportRecords = useMemo(() => {
const sessionId = String(sessionDetail?.wxid || '').trim()
if (!sessionId) return [] as configService.ExportSessionRecordEntry[]
@@ -5700,7 +5869,7 @@ function ExportPage() {
displayName: mappedSession?.displayName || mappedContact?.displayName || prev?.displayName || normalizedSessionId,
remark: sameSession ? prev?.remark : mappedContact?.remark,
nickName: sameSession ? prev?.nickName : mappedContact?.nickname,
alias: sameSession ? prev?.alias : undefined,
alias: sameSession ? prev?.alias : mappedContact?.alias,
avatarUrl: mappedSession?.avatarUrl || mappedContact?.avatarUrl || (sameSession ? prev?.avatarUrl : undefined),
messageCount: initialMessageCount ?? (sameSession ? prev.messageCount : Number.NaN),
voiceMessages: metricVoice ?? (sameSession ? prev?.voiceMessages : undefined),
@@ -5987,7 +6156,11 @@ function ExportPage() {
loadSnsUserPostCounts({ force: true })
])
if (String(sessionDetail?.wxid || '').trim()) {
const currentDetailSessionId = showSessionDetailPanel
? String(sessionDetail?.wxid || '').trim()
: ''
if (currentDetailSessionId) {
await loadSessionDetail(currentDetailSessionId)
void loadSessionRelationStats({ forceRefresh: true })
}
}, [
@@ -5998,11 +6171,13 @@ function ExportPage() {
filteredContacts,
isSessionCountStageReady,
loadContactsList,
loadSessionDetail,
loadSessionRelationStats,
loadSnsStats,
loadSnsUserPostCounts,
resetSessionMutualFriendsLoader,
scheduleSessionMutualFriendsWorker,
showSessionDetailPanel,
sessionDetail?.wxid
])
@@ -6270,6 +6445,10 @@ function ExportPage() {
const useCollapsedSessionFormatSelector = isSessionScopeDialog || isContentTextDialog
const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog
const shouldShowMediaSection = !isContentScopeDialog
const shouldShowImageDeepSearchToggle = exportDialog.scope !== 'sns' && (
(isSessionScopeDialog && options.exportImages) ||
(isContentScopeDialog && exportDialog.contentType === 'image')
)
const avatarExportStatusLabel = options.exportAvatars ? '已开启聊天消息导出带头像' : '已关闭聊天消息导出带头像'
const contentTextDialogSummary = '此模式只导出聊天文本,不包含图片语音视频表情包等多媒体文件。'
const activeDialogFormatLabel = exportDialog.scope === 'sns'
@@ -6567,11 +6746,12 @@ function ExportPage() {
</button>
</div>
<div className="contact-avatar">
{contact.avatarUrl ? (
<img src={contact.avatarUrl} alt="" loading="lazy" />
) : (
<span>{getAvatarLetter(contact.displayName)}</span>
)}
<Avatar
src={normalizeExportAvatarUrl(contact.avatarUrl)}
name={contact.displayName}
size="100%"
shape="rounded"
/>
</div>
<div className="contact-info">
<div className="contact-name">{contact.displayName}</div>
@@ -7454,11 +7634,12 @@ function ExportPage() {
<div className="session-mutual-friends-header">
<div className="session-mutual-friends-header-main">
<div className="session-mutual-friends-avatar">
{sessionMutualFriendsDialogTarget.avatarUrl ? (
<img src={sessionMutualFriendsDialogTarget.avatarUrl} alt="" />
) : (
<span>{getAvatarLetter(sessionMutualFriendsDialogTarget.displayName)}</span>
)}
<Avatar
src={normalizeExportAvatarUrl(sessionMutualFriendsDialogTarget.avatarUrl)}
name={sessionMutualFriendsDialogTarget.displayName}
size="100%"
shape="rounded"
/>
</div>
<div className="session-mutual-friends-meta">
<h4>{sessionMutualFriendsDialogTarget.displayName} </h4>
@@ -7539,11 +7720,12 @@ function ExportPage() {
<div className="detail-header">
<div className="detail-header-main">
<div className="detail-header-avatar">
{sessionDetail?.avatarUrl ? (
<img src={sessionDetail.avatarUrl} alt="" />
) : (
<span>{getAvatarLetter(sessionDetail?.displayName || sessionDetail?.wxid || '')}</span>
)}
<Avatar
src={normalizeExportAvatarUrl(sessionDetail?.avatarUrl)}
name={sessionDetail?.displayName || sessionDetail?.wxid || ''}
size="100%"
shape="rounded"
/>
</div>
<div className="detail-header-meta">
<h4>{sessionDetail?.displayName || '会话详情'}</h4>
@@ -7986,6 +8168,26 @@ function ExportPage() {
</div>
)}
{shouldShowImageDeepSearchToggle && (
<div className="dialog-section">
<div className="dialog-switch-row">
<div className="dialog-switch-copy">
<h4></h4>
<div className="format-note"> hardlink </div>
</div>
<button
type="button"
className={`dialog-switch ${options.imageDeepSearchOnMiss ? 'on' : ''}`}
aria-pressed={options.imageDeepSearchOnMiss}
aria-label="切换缺图时深度搜索"
onClick={() => setOptions(prev => ({ ...prev, imageDeepSearchOnMiss: !prev.imageDeepSearchOnMiss }))}
>
<span className="dialog-switch-thumb" />
</button>
</div>
</div>
)}
{isSessionScopeDialog && (
<div className="dialog-section">
<div className="dialog-switch-row">

View File

@@ -834,11 +834,13 @@
}
.member-export-panel,
.member-messages-panel {
.member-messages-panel,
.member-analytics-panel {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
flex: 1;
.member-export-empty {
padding: 20px;
@@ -1521,29 +1523,73 @@
}
}
.stats-cards {
.stats-overview {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
margin-bottom: 20px;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
padding-top: 10px;
}
.stat-card {
background: transparent;
.stat-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: var(--card-bg);
border-radius: 12px;
border: 1px solid var(--border-color);
.stat-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: var(--primary-light);
border-radius: 12px;
padding: 16px;
text-align: center;
color: var(--primary);
}
.value {
display: block;
.stat-info {
display: flex;
flex-direction: column;
gap: 4px;
.stat-value {
font-size: 24px;
font-weight: 600;
color: var(--primary);
margin-bottom: 4px;
color: var(--text-primary);
}
.label {
.stat-label {
font-size: 13px;
color: var(--text-secondary);
color: var(--text-tertiary);
}
}
}
.charts-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
margin-bottom: 24px;
.chart-card {
background: var(--card-bg);
border-radius: 12px;
border: 1px solid var(--border-color);
padding: 20px;
&.wide {
grid-column: span 2;
}
h3 {
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
margin: 0 0 16px;
}
}
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useLocation } from 'react-router-dom'
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, ChevronLeft, Copy, Check, Download, ChevronDown, MessageSquare } from 'lucide-react'
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, ChevronLeft, Copy, Check, Download, ChevronDown, MessageSquare, Calendar, PieChart, Hash, Smile } from 'lucide-react'
import { Avatar } from '../components/Avatar'
import ReactECharts from 'echarts-for-react'
import DateRangePicker from '../components/DateRangePicker'
@@ -37,7 +37,7 @@ interface GroupMessageRank {
messageCount: number
}
type AnalysisFunction = 'members' | 'memberMessages' | 'ranking' | 'activeHours' | 'mediaStats'
type AnalysisFunction = 'members' | 'memberMessages' | 'memberAnalytics' | 'ranking' | 'activeHours' | 'mediaStats'
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone'
interface MemberMessageExportOptions {
@@ -167,6 +167,8 @@ function GroupAnalyticsPage() {
const [isExportingMembers, setIsExportingMembers] = useState(false)
const [isExportingMemberMessages, setIsExportingMemberMessages] = useState(false)
const [memberMessages, setMemberMessages] = useState<Message[]>([])
const [memberAnalyticsData, setMemberAnalyticsData] = useState<any | null>(null)
const [analyticsError, setAnalyticsError] = useState<string | null>(null)
const [memberMessagesHasMore, setMemberMessagesHasMore] = useState(false)
const [memberMessagesCursor, setMemberMessagesCursor] = useState(0)
const [memberMessagesLoadingMore, setMemberMessagesLoadingMore] = useState(false)
@@ -524,6 +526,7 @@ function GroupAnalyticsPage() {
break
}
case 'memberMessages': {
resetMemberMessageState(false)
updateBackgroundTask(taskId, {
detail: '正在读取成员列表与消息',
progressText: '成员消息'
@@ -566,7 +569,57 @@ function GroupAnalyticsPage() {
})
break
}
case 'memberAnalytics': {
setMemberAnalyticsData(null)
setAnalyticsError(null)
updateBackgroundTask(taskId, {
detail: '正在读取成员列表与消息分析',
progressText: '成员分析'
})
const result = await window.electronAPI.groupAnalytics.getGroupMembers(targetGroup.username)
if (isBackgroundTaskCancelRequested(taskId)) {
finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,成员分析未继续写入' })
return
}
if (!result.success || !result.data) {
finishBackgroundTask(taskId, 'failed', { detail: result.error || '获取成员列表失败' })
return
}
setMembers(result.data)
let targetMember = preferredMemberUsername
? result.data.find(m => m.username === preferredMemberUsername)
: result.data.find(m => m.username === selectedMessageMemberUsername)
if (!targetMember && result.data.length > 0) {
targetMember = result.data[0]
setSelectedMessageMemberUsername(targetMember.username)
}
if (!targetMember) {
finishBackgroundTask(taskId, 'failed', { detail: '找不到目标成员' })
return
}
updateBackgroundTask(taskId, {
detail: `正在分析 ${targetMember.displayName || targetMember.username} 的发言记录`,
progressText: '统计分析'
})
const analyticsResult = await window.electronAPI.groupAnalytics.getGroupMemberAnalytics(targetGroup.username, targetMember.username, startTime, endTime)
if (isBackgroundTaskCancelRequested(taskId)) {
finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,成员分析未继续写入' })
return
}
if (analyticsResult.success && analyticsResult.data) {
setMemberAnalyticsData(analyticsResult.data)
finishBackgroundTask(taskId, 'completed', {
detail: `分析完成,共计 ${analyticsResult.data.statistics?.totalMessages || 0} 条消息`,
progressText: '已完成'
})
} else {
setAnalyticsError(analyticsResult.error || '分析失败')
finishBackgroundTask(taskId, 'failed', { detail: analyticsResult.error || '分析失败' })
}
break
}
case 'ranking': {
setRankings([])
updateBackgroundTask(taskId, {
detail: '正在计算群消息排行',
progressText: '消息排行'
@@ -584,6 +637,7 @@ function GroupAnalyticsPage() {
break
}
case 'activeHours': {
setActiveHours({})
updateBackgroundTask(taskId, {
detail: '正在计算群活跃时段',
progressText: '活跃时段'
@@ -601,6 +655,7 @@ function GroupAnalyticsPage() {
break
}
case 'mediaStats': {
setMediaStats(null)
updateBackgroundTask(taskId, {
detail: '正在统计群消息类型',
progressText: '消息类型'
@@ -633,6 +688,12 @@ function GroupAnalyticsPage() {
return num.toLocaleString()
}
const formatDate = (timestamp: number | null) => {
if (!timestamp) return '-'
const date = new Date(timestamp * 1000)
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`
}
const sanitizeFileName = (name: string) => {
return name.replace(/[<>:"/\\|?*]+/g, '_').trim()
}
@@ -764,6 +825,16 @@ function GroupAnalyticsPage() {
await loadFunctionData('memberMessages', selectedGroup, member.username)
}
const handleViewMemberAnalyticsFromModal = async (member: GroupMember) => {
if (!selectedGroup) return
setSelectedMember(null)
setSelectedFunction('memberAnalytics')
setSelectedMessageMemberUsername(member.username)
setMessageMemberSearchKeyword('')
setShowMessageMemberSelect(false)
await loadFunctionData('memberAnalytics', selectedGroup, member.username)
}
const handleOpenMemberExportModal = () => {
setShowMessageMemberSelect(false)
setShowFormatSelect(false)
@@ -982,6 +1053,14 @@ function GroupAnalyticsPage() {
<button
type="button"
className="member-modal-primary-btn"
onClick={() => void handleViewMemberAnalyticsFromModal(selectedMember)}
>
<BarChart3 size={16} />
<span></span>
</button>
<button
type="button"
className="member-modal-secondary-btn"
onClick={() => void handleViewMemberMessagesFromModal(selectedMember)}
>
<MessageSquare size={16} />
@@ -1080,6 +1159,11 @@ function GroupAnalyticsPage() {
<span></span>
<small></small>
</div>
<div className="function-card" onClick={() => handleFunctionSelect('memberAnalytics')}>
<PieChart size={32} />
<span></span>
<small></small>
</div>
<div className="function-card" onClick={() => handleFunctionSelect('ranking')}>
<BarChart3 size={32} />
<span></span>
@@ -1104,6 +1188,7 @@ function GroupAnalyticsPage() {
switch (selectedFunction) {
case 'members': return '群成员查看'
case 'memberMessages': return '成员消息筛选与导出'
case 'memberAnalytics': return '群成员详细分析'
case 'ranking': return '群聊发言排行'
case 'activeHours': return '群聊活跃时段'
case 'mediaStats': return '媒体内容统计'
@@ -1282,6 +1367,187 @@ function GroupAnalyticsPage() {
)}
</div>
)}
{selectedFunction === 'memberAnalytics' && (
<div className="member-analytics-panel">
{members.length === 0 ? (
<div className="member-message-empty"></div>
) : (
<>
<div className="member-message-toolbar" style={{ marginBottom: 20 }}>
<div className="member-export-field" ref={messageMemberSelectDropdownRef}>
<span></span>
<button
type="button"
className={`select-trigger member-message-select-trigger ${showMessageMemberSelect ? 'open' : ''}`}
onClick={() => setShowMessageMemberSelect(prev => !prev)}
>
<div className="member-select-trigger-value">
<Avatar
src={selectedMessageMember?.avatarUrl}
name={selectedMessageMember?.displayName || selectedMessageMember?.username || '?'}
size={24}
/>
<span className="select-value">{selectedMessageMember?.displayName || selectedMessageMember?.username || '请选择成员'}</span>
</div>
<ChevronDown size={16} />
</button>
{showMessageMemberSelect && (
<div className="select-dropdown member-select-dropdown">
<div className="member-select-search">
<Search size={14} />
<input
type="text"
value={messageMemberSearchKeyword}
onChange={e => setMessageMemberSearchKeyword(e.target.value)}
placeholder="搜索 wxid / 昵称 / 备注 / 微信号"
onClick={e => e.stopPropagation()}
/>
</div>
<div className="member-select-options">
{filteredMessageMemberOptions.length === 0 ? (
<div className="member-select-empty"></div>
) : (
filteredMessageMemberOptions.map(member => (
<button
key={member.username}
type="button"
className={`select-option member-select-option ${selectedMessageMemberUsername === member.username ? 'active' : ''}`}
onClick={() => {
setSelectedMessageMemberUsername(member.username)
setShowMessageMemberSelect(false)
if (selectedGroup) {
void loadFunctionData('memberAnalytics', selectedGroup, member.username)
}
}}
>
<Avatar src={member.avatarUrl} name={member.displayName} size={28} />
<span className="member-option-main">{member.displayName || member.username}</span>
<span className="member-option-meta">
wxid: {member.username}
{member.alias ? ` · 微信号: ${member.alias}` : ''}
{member.remark ? ` · 备注: ${member.remark}` : ''}
{member.nickname ? ` · 昵称: ${member.nickname}` : ''}
{member.groupNickname ? ` · 群昵称: ${member.groupNickname}` : ''}
</span>
</button>
))
)}
</div>
</div>
)}
</div>
</div>
{analyticsError ? (
<div className="member-message-empty">{analyticsError}</div>
) : memberAnalyticsData ? (
<div className="analytics-content-scrollable" style={{ padding: '0', display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, overflowY: 'auto' }}>
<div className="stats-overview">
<div className="stat-card">
<div className="stat-icon"><MessageSquare size={24} /></div>
<div className="stat-info">
<span className="stat-value">{formatNumber(memberAnalyticsData.statistics.sentMessages)}</span>
<span className="stat-label"></span>
</div>
</div>
<div className="stat-card">
<div className="stat-icon"><Clock size={24} /></div>
<div className="stat-info">
<span className="stat-value">{memberAnalyticsData.statistics.activeDays}</span>
<span className="stat-label"></span>
</div>
</div>
<div className="stat-card" style={{ gridColumn: 'span 2' }}>
<div className="stat-icon"><Calendar size={24} /></div>
<div className="stat-info">
<span className="stat-value">
{formatDate(memberAnalyticsData.statistics.firstMessageTime)} - {formatDate(memberAnalyticsData.statistics.lastMessageTime)}
</span>
<span className="stat-label"></span>
</div>
</div>
</div>
<div className="charts-grid">
<div className="chart-card wide">
<h3></h3>
<div className="chart-wrapper">
<ReactECharts
option={{
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: Array.from({ length: 24 }, (_, i) => `${i}`) },
yAxis: { type: 'value' },
series: [{ type: 'bar', data: Array.from({ length: 24 }, (_, i) => memberAnalyticsData.timeDistribution[i] || 0), itemStyle: { color: '#07c160', borderRadius: [4, 4, 0, 0] } }]
}}
style={{ height: '300px', width: '100%' }}
/>
</div>
</div>
<div className="chart-card wide">
<h3></h3>
<div className="chart-wrapper">
<ReactECharts
option={{
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
series: [{
type: 'pie',
radius: ['40%', '70%'],
data: [
{ name: '文本', value: memberAnalyticsData.statistics.textMessages, itemStyle: { color: '#3b82f6' } },
{ name: '图片', value: memberAnalyticsData.statistics.imageMessages, itemStyle: { color: '#22c55e' } },
{ name: '语音', value: memberAnalyticsData.statistics.voiceMessages, itemStyle: { color: '#f97316' } },
{ name: '视频', value: memberAnalyticsData.statistics.videoMessages, itemStyle: { color: '#a855f7' } },
{ name: '表情', value: memberAnalyticsData.statistics.emojiMessages, itemStyle: { color: '#ec4899' } },
{ name: '其他', value: memberAnalyticsData.statistics.otherMessages, itemStyle: { color: '#6b7280' } }
].filter(item => item.value > 0),
label: { show: true, formatter: '{b} {d}%' }
}]
}}
style={{ height: '300px', width: '100%' }}
/>
</div>
</div>
<div className="chart-card wide" style={{ display: 'flex', gap: '32px' }}>
<div style={{ flex: 1 }}>
<h3 style={{ display: 'flex', alignItems: 'center', gap: '6px' }}><Hash size={18} /> </h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{memberAnalyticsData.commonPhrases && memberAnalyticsData.commonPhrases.length > 0 ? (
memberAnalyticsData.commonPhrases.map((item: any, idx: number) => (
<div key={idx} style={{ background: 'var(--bg-tertiary)', padding: '6px 12px', borderRadius: '8px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '6px', border: '1px solid var(--border-color)' }}>
<span style={{ color: 'var(--text-primary)' }}>{item.phrase}</span>
<span style={{ color: 'var(--text-tertiary)', fontSize: '11px' }}>{item.count}</span>
</div>
))
) : (
<span style={{ color: 'var(--text-tertiary)', fontSize: '13px' }}></span>
)}
</div>
</div>
<div style={{ flex: 1 }}>
<h3 style={{ display: 'flex', alignItems: 'center', gap: '6px' }}><Smile size={18} /> </h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{memberAnalyticsData.commonEmojis && memberAnalyticsData.commonEmojis.length > 0 ? (
memberAnalyticsData.commonEmojis.map((item: any, idx: number) => (
<div key={idx} style={{ background: 'var(--bg-tertiary)', padding: '6px 12px', borderRadius: '8px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '6px', border: '1px solid var(--border-color)' }}>
<span style={{ color: 'var(--text-primary)' }}>{item.emoji}</span>
<span style={{ color: 'var(--text-tertiary)', fontSize: '11px' }}>{item.count}</span>
</div>
))
) : (
<span style={{ color: 'var(--text-tertiary)', fontSize: '13px' }}></span>
)}
</div>
</div>
</div>
</div>
</div>
) : (
<div className="content-loading"><Loader2 size={32} className="spin" /></div>
)}
</>
)}
</div>
)}
{selectedFunction === 'ranking' && (
<div className="rankings-list">
{rankings.map((item, index) => (

View File

@@ -1145,6 +1145,185 @@
}
}
.quote-layout-group {
margin-top: 14px;
}
.quote-layout-picker {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 12px;
margin-top: 10px;
}
.quote-layout-card {
position: relative;
border: 1px solid var(--border-color);
border-radius: 14px;
padding: 14px 14px 12px;
background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary));
color: inherit;
cursor: pointer;
text-align: left;
transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
&:hover {
border-color: color-mix(in srgb, var(--primary) 32%, var(--border-color));
}
&.active {
border-color: color-mix(in srgb, var(--primary) 68%, var(--border-color));
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 12%, transparent);
background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary));
}
}
.quote-layout-card-check {
position: absolute;
top: 12px;
left: 12px;
width: 18px;
height: 18px;
border-radius: 50%;
border: 1.5px solid color-mix(in srgb, var(--primary) 46%, var(--border-color));
background: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.2s ease;
&::after {
content: '';
width: 7px;
height: 7px;
border-radius: 50%;
background: color-mix(in srgb, var(--primary) 78%, #6f8653);
transform: scale(0);
transition: transform 0.2s ease;
}
&.active {
border-color: color-mix(in srgb, var(--primary) 78%, var(--border-color));
}
&.active::after {
transform: scale(1);
}
}
.quote-layout-preview-shell {
padding-left: 22px;
min-height: 72px;
}
.quote-layout-preview-chat {
display: flex;
align-items: flex-start;
}
.quote-layout-preview-chat .message-bubble {
display: flex;
gap: 10px;
max-width: 70%;
align-items: flex-start;
}
.quote-layout-preview-chat .message-bubble.sent {
flex-direction: row-reverse;
}
.quote-layout-preview-chat .message-bubble.sent .bubble-content {
background: var(--primary);
color: var(--on-primary);
border-radius: 18px 18px 4px 18px;
}
.quote-layout-preview-chat .bubble-content {
padding: 10px 14px;
font-size: 14px;
line-height: 1.6;
word-break: break-word;
white-space: pre-wrap;
}
.quote-layout-preview-chat .message-text {
font-size: 14px;
line-height: 1.6;
}
.quote-layout-preview-chat .quoted-message {
background: rgba(0, 0, 0, 0.04);
border-left: 2px solid var(--primary);
padding: 6px 10px;
border-radius: 4px;
font-size: 13px;
}
.quote-layout-preview-chat .quoted-sender {
color: var(--primary);
font-weight: 500;
margin-right: 4px;
}
.quote-layout-preview-chat .quoted-sender::after {
content: ':';
}
.quote-layout-preview-chat .quoted-text {
color: var(--text-secondary);
white-space: pre-wrap;
}
.quote-layout-preview-chat .message-bubble.sent .quoted-message {
background: color-mix(in srgb, var(--on-primary) 12%, var(--primary));
border-left-color: color-mix(in srgb, var(--on-primary) 36%, var(--primary));
}
.quote-layout-preview-chat .message-bubble.sent .quoted-sender {
color: color-mix(in srgb, var(--on-primary) 92%, var(--primary));
}
.quote-layout-preview-chat .message-bubble.sent .quoted-text {
color: color-mix(in srgb, var(--on-primary) 80%, var(--primary));
}
.quote-layout-preview-chat .bubble-content.quote-layout-top .quoted-message {
margin-bottom: 8px;
}
.quote-layout-preview-chat .bubble-content.quote-layout-bottom .quoted-message {
margin-top: 8px;
}
.quote-layout-card-footer {
margin-top: 8px;
padding-left: 22px;
}
.quote-layout-card-title-group {
display: flex;
flex-direction: column;
gap: 2px;
}
.quote-layout-card-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.quote-layout-card-desc {
font-size: 11px;
color: var(--text-tertiary);
}
@media (max-width: 760px) {
.quote-layout-picker {
grid-template-columns: 1fr;
}
}
.theme-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));

View File

@@ -32,6 +32,7 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
const isMac = navigator.userAgent.toLowerCase().includes('mac')
const isLinux = navigator.userAgent.toLowerCase().includes('linux')
const isWindows = !isMac && !isLinux
const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录'
const dbPathPlaceholder = isMac
@@ -102,6 +103,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [whisperProgressData, setWhisperProgressData] = useState<{ downloaded: number; total: number; speed: number }>({ downloaded: 0, total: 0, speed: 0 })
const [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; modelPath?: string; tokensPath?: string } | null>(null)
const [httpApiToken, setHttpApiToken] = useState('')
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
@@ -110,6 +113,25 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const generateRandomToken = async () => {
// 生成 32 字符的十六进制随机字符串 (16 bytes)
const array = new Uint8Array(16)
crypto.getRandomValues(array)
const token = Array.from(array).map(b => b.toString(16).padStart(2, '0')).join('')
setHttpApiToken(token)
await configService.setHttpApiToken(token)
showMessage('已生成并保存新的 Access Token', true)
}
const clearApiToken = async () => {
setHttpApiToken('')
await configService.setHttpApiToken('')
showMessage('已清除 Access TokenAPI 将允许无鉴权访问', true)
}
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
@@ -118,6 +140,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all')
const [notificationFilterList, setNotificationFilterList] = useState<string[]>([])
const [windowCloseBehavior, setWindowCloseBehavior] = useState<configService.WindowCloseBehavior>('ask')
const [quoteLayout, setQuoteLayout] = useState<configService.QuoteLayout>('quote-top')
const [filterSearchKeyword, setFilterSearchKeyword] = useState('')
const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false)
const [positionDropdownOpen, setPositionDropdownOpen] = useState(false)
@@ -167,6 +190,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
// HTTP API 设置 state
const [httpApiEnabled, setHttpApiEnabled] = useState(false)
const [httpApiPort, setHttpApiPort] = useState(5031)
const [httpApiHost, setHttpApiHost] = useState('127.0.0.1')
const [httpApiRunning, setHttpApiRunning] = useState(false)
const [httpApiMediaExportPath, setHttpApiMediaExportPath] = useState('')
const [isTogglingApi, setIsTogglingApi] = useState(false)
@@ -175,11 +199,26 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache
const [isWayland, setIsWayland] = useState(false)
useEffect(() => {
const checkWaylandStatus = async () => {
if (window.electronAPI?.app?.checkWayland) {
try {
const wayland = await window.electronAPI.app.checkWayland()
setIsWayland(wayland)
} catch (e) {
console.error('检查 Wayland 状态失败:', e)
}
}
}
checkWaylandStatus()
}, [])
// 检查 Hello 可用性
useEffect(() => {
if (window.PublicKeyCredential) {
void PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().then(setHelloAvailable)
}
setHelloAvailable(isWindows)
}, [])
// 检查 HTTP API 服务状态
@@ -299,10 +338,21 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const savedNotificationFilterList = await configService.getNotificationFilterList()
const savedMessagePushEnabled = await configService.getMessagePushEnabled()
const savedWindowCloseBehavior = await configService.getWindowCloseBehavior()
const savedQuoteLayout = await configService.getQuoteLayout()
const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled()
const savedAuthUseHello = await configService.getAuthUseHello()
const savedIsLockMode = await window.electronAPI.auth.isLockMode()
const savedHttpApiToken = await configService.getHttpApiToken()
if (savedHttpApiToken) setHttpApiToken(savedHttpApiToken)
const savedApiPort = await configService.getHttpApiPort()
if (savedApiPort) setHttpApiPort(savedApiPort)
const savedApiHost = await configService.getHttpApiHost()
if (savedApiHost) setHttpApiHost(savedApiHost)
setAuthEnabled(savedAuthEnabled)
setAuthUseHello(savedAuthUseHello)
setIsLockMode(savedIsLockMode)
@@ -336,6 +386,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setNotificationFilterList(savedNotificationFilterList)
setMessagePushEnabled(savedMessagePushEnabled)
setWindowCloseBehavior(savedWindowCloseBehavior)
setQuoteLayout(savedQuoteLayout)
const savedExcludeWords = await configService.getWordCloudExcludeWords()
setWordCloudExcludeWords(savedExcludeWords)
@@ -344,6 +395,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const savedAnalyticsConsent = await configService.getAnalyticsConsent()
setAnalyticsConsent(savedAnalyticsConsent ?? false)
// 如果语言列表为空,保存默认值
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
const defaultLanguages = ['zh']
@@ -1043,6 +1096,79 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
))}
</div>
<div className="form-group quote-layout-group">
<label></label>
<span className="form-hint"></span>
<div className="quote-layout-picker" role="radiogroup" aria-label="引用样式选择">
{[
{
value: 'quote-top' as const,
label: '引用在上',
description: '更接近当前 WeFlow 风格',
successMessage: '已切换为引用在上样式'
},
{
value: 'quote-bottom' as const,
label: '正文在上',
description: '更接近微信 / 密语风格',
successMessage: '已切换为正文在上样式'
}
].map(option => {
const selected = quoteLayout === option.value
const isQuoteBottom = option.value === 'quote-bottom'
return (
<button
key={option.value}
type="button"
className={`quote-layout-card ${selected ? 'active' : ''}`}
onClick={async () => {
if (selected) return
setQuoteLayout(option.value)
await configService.setQuoteLayout(option.value)
showMessage(option.successMessage, true)
}}
role="radio"
aria-checked={selected}
>
<span className={`quote-layout-card-check ${selected ? 'active' : ''}`} aria-hidden="true" />
<div className="quote-layout-preview-shell">
<div className="quote-layout-preview-chat">
<div className="message-bubble sent">
<div className={`bubble-content ${isQuoteBottom ? 'quote-layout-bottom' : 'quote-layout-top'}`}>
{isQuoteBottom ? (
<>
<div className="message-text">!</div>
<div className="quoted-message">
<span className="quoted-sender"></span>
<span className="quoted-text">...</span>
</div>
</>
) : (
<>
<div className="quoted-message">
<span className="quoted-sender"></span>
<span className="quoted-text">...</span>
</div>
<div className="message-text">!</div>
</>
)}
</div>
</div>
</div>
</div>
<div className="quote-layout-card-footer">
<div className="quote-layout-card-title-group">
<span className="quote-layout-card-title">{option.label}</span>
<span className="quote-layout-card-desc">{option.description}</span>
</div>
</div>
</button>
)
})}
</div>
</div>
<div className="divider" />
<div className="form-group">
@@ -1169,6 +1295,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="form-group">
<label></label>
<span className="form-hint"></span>
{isWayland && (
<span className="form-hint" style={{ color: '#ff4d4f', marginTop: '4px', display: 'block' }}>
Wayland
</span>
)}
<div className="custom-select">
<div
className={`custom-select-trigger ${positionDropdownOpen ? 'open' : ''}`}
@@ -1652,34 +1783,49 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
)
const renderCacheTab = () => (
<div className="tab-content">
<p className="section-desc"></p>
<div className="form-group">
<label> <span className="optional">()</span></label>
<span className="form-hint">使</span>
<input
type="text"
placeholder="留空使用默认目录"
value={cachePath}
onChange={(e) => {
const value = e.target.value
setCachePath(value)
scheduleConfigSave('cachePath', () => configService.setCachePath(value))
}}
/>
<div className="btn-row">
<button className="btn btn-secondary" onClick={handleSelectCachePath}><FolderOpen size={16} /> </button>
<button
className="btn btn-secondary"
onClick={async () => {
setCachePath('')
await configService.setCachePath('')
}}
>
<RotateCcw size={16} />
</button>
<div className="tab-content">
<p className="section-desc"></p>
<div className="form-group">
<label> <span className="optional">()</span></label>
<span className="form-hint">使</span>
<input
type="text"
placeholder="留空使用默认目录"
value={cachePath}
onChange={(e) => {
const value = e.target.value
setCachePath(value)
scheduleConfigSave('cachePath', () => configService.setCachePath(value))
}}
/>
<div style={{ marginTop: '8px', fontSize: '13px', color: 'var(--text-secondary)' }}>
<code style={{
background: 'var(--bg-secondary)',
padding: '3px 6px',
borderRadius: '4px',
userSelect: 'all',
wordBreak: 'break-all',
marginLeft: '4px'
}}>
{cachePath || (isMac ? '~/Documents/WeFlow' : isLinux ? '~/Documents/WeFlow' : '系统 文档\\WeFlow 目录')}
</code>
</div>
<div className="btn-row" style={{ marginTop: '12px' }}>
<button className="btn btn-secondary" onClick={handleSelectCachePath}><FolderOpen size={16} /> </button>
<button
className="btn btn-secondary"
onClick={async () => {
setCachePath('')
await configService.setCachePath('')
}}
>
<RotateCcw size={16} />
</button>
</div>
</div>
</div>
<div className="btn-row">
<button className="btn btn-secondary" onClick={handleClearAnalyticsCache} disabled={isClearingCache}>
@@ -1715,6 +1861,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
try {
await window.electronAPI.http.stop()
setHttpApiRunning(false)
await configService.setHttpApiEnabled(false)
showMessage('API 服务已停止', true)
} catch (e: any) {
showMessage(`操作失败: ${e}`, false)
@@ -1728,10 +1875,14 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setShowApiWarning(false)
setIsTogglingApi(true)
try {
const result = await window.electronAPI.http.start(httpApiPort)
const result = await window.electronAPI.http.start(httpApiPort, httpApiHost)
if (result.success) {
setHttpApiRunning(true)
if (result.port) setHttpApiPort(result.port)
await configService.setHttpApiEnabled(true)
await configService.setHttpApiPort(result.port || httpApiPort)
showMessage(`API 服务已启动,端口 ${result.port}`, true)
} else {
showMessage(`启动失败: ${result.error}`, false)
@@ -1744,7 +1895,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
}
const handleCopyApiUrl = () => {
const url = `http://127.0.0.1:${httpApiPort}`
const url = `http://${httpApiHost}:${httpApiPort}`
navigator.clipboard.writeText(url)
showMessage('已复制 API 地址', true)
}
@@ -1776,21 +1927,75 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint">
API <code>127.0.0.1</code> 访Docker/N8N <code>0.0.0.0</code> 访 Token
</span>
<input
type="text"
className="field-input"
value={httpApiHost}
placeholder="127.0.0.1"
onChange={(e) => {
const host = e.target.value.trim() || '127.0.0.1'
setHttpApiHost(host)
scheduleConfigSave('httpApiHost', () => configService.setHttpApiHost(host))
}}
disabled={httpApiRunning}
style={{ width: 180, fontFamily: 'monospace' }}
/>
</div>
<div className="form-group">
<label></label>
<span className="form-hint">API 1024-65535</span>
<input
type="number"
className="field-input"
value={httpApiPort}
onChange={(e) => setHttpApiPort(parseInt(e.target.value, 10) || 5031)}
disabled={httpApiRunning}
style={{ width: 120 }}
min={1024}
max={65535}
type="number"
className="field-input"
value={httpApiPort}
onChange={(e) => {
const port = parseInt(e.target.value, 10) || 5031
setHttpApiPort(port)
scheduleConfigSave('httpApiPort', () => configService.setHttpApiPort(port))
}}
disabled={httpApiRunning}
style={{ width: 120 }}
min={1024}
max={65535}
/>
</div>
<div className="form-group">
<label>Access Token ()</label>
<span className="form-hint">
<code>Authorization: Bearer &lt;token&gt;</code>
<code>?access_token=&lt;token&gt;</code>
</span>
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
<input
type="text"
className="field-input"
value={httpApiToken}
placeholder="留空表示不验证 Token"
onChange={(e) => {
const val = e.target.value
setHttpApiToken(val)
scheduleConfigSave('httpApiToken', () => configService.setHttpApiToken(val))
}}
style={{ flex: 1, fontFamily: 'monospace' }}
/>
<button className="btn btn-secondary" onClick={generateRandomToken}>
<RefreshCw size={14} style={{ marginRight: 4 }} />
</button>
{httpApiToken && (
<button className="btn btn-danger" onClick={clearApiToken} title="清除 Token">
<Trash2 size={14} />
</button>
)}
</div>
</div>
{httpApiRunning && (
<div className="form-group">
<label>API </label>
@@ -1799,7 +2004,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<input
type="text"
className="field-input"
value={`http://127.0.0.1:${httpApiPort}`}
value={`http://${httpApiHost}:${httpApiPort}`}
readOnly
/>
<button className="btn btn-secondary" onClick={handleCopyApiUrl} title="复制">
@@ -1846,18 +2051,18 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<span className="form-hint"> SSE `HTTP API 服务`</span>
<div className="api-url-display">
<input
type="text"
className="field-input"
value={`http://127.0.0.1:${httpApiPort}/api/v1/push/messages`}
readOnly
type="text"
className="field-input"
value={`http://${httpApiHost}:${httpApiPort}/api/v1/push/messages${httpApiToken ? `?access_token=${httpApiToken}` : ''}`}
readOnly
/>
<button
className="btn btn-secondary"
onClick={() => {
navigator.clipboard.writeText(`http://127.0.0.1:${httpApiPort}/api/v1/push/messages`)
showMessage('已复制推送地址', true)
}}
title="复制"
className="btn btn-secondary"
onClick={() => {
navigator.clipboard.writeText(`http://${httpApiHost}:${httpApiPort}/api/v1/push/messages${httpApiToken ? `?access_token=${httpApiToken}` : ''}`)
showMessage('已复制推送地址', true)
}}
title="复制"
>
<Copy size={16} />
</button>
@@ -1871,7 +2076,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="api-item">
<div className="api-endpoint">
<span className="method get">GET</span>
<code>{`http://127.0.0.1:${httpApiPort}/api/v1/push/messages`}</code>
<code>{`http://${httpApiHost}:${httpApiPort}/api/v1/push/messages`}</code>
</div>
<p className="api-desc"> SSE `messageKey` </p>
<div className="api-params">
@@ -1928,33 +2133,29 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
showMessage('请输入当前密码以开启 Hello', false)
return
}
if (!isWindows) {
showMessage('当前系统不支持 Windows Hello', false)
return
}
setIsSettingHello(true)
try {
const challenge = new Uint8Array(32)
window.crypto.getRandomValues(challenge)
const credential = await navigator.credentials.create({
publicKey: {
challenge,
rp: { name: 'WeFlow', id: 'localhost' },
user: { id: new Uint8Array([1]), name: 'user', displayName: 'User' },
pubKeyCredParams: [{ alg: -7, type: 'public-key' }],
authenticatorSelection: { userVerification: 'required' },
timeout: 60000
}
})
if (credential) {
// 存储密码作为 Hello Secret以便 Hello 解锁时能派生密钥
await window.electronAPI.auth.setHelloSecret(helloPassword)
setAuthUseHello(true)
setHelloPassword('')
showMessage('Windows Hello 设置成功', true)
const verifyResult = await window.electronAPI.auth.hello('请验证您的身份以开启 Windows Hello')
if (!verifyResult.success) {
showMessage(verifyResult.error || 'Windows Hello 验证失败', false)
return
}
const saveResult = await window.electronAPI.auth.setHelloSecret(helloPassword)
if (!saveResult.success) {
showMessage('Windows Hello 配置保存失败', false)
return
}
setAuthUseHello(true)
setHelloPassword('')
showMessage('Windows Hello 设置成功', true)
} catch (e: any) {
if (e.name !== 'NotAllowedError') {
showMessage(`Windows Hello 设置失败: ${e.message}`, false)
}
showMessage(`Windows Hello 设置失败: ${e?.message || String(e)}`, false)
} finally {
setIsSettingHello(false)
}

View File

@@ -759,6 +759,26 @@
margin-bottom: 12px;
}
.post-location {
display: flex;
align-items: flex-start;
gap: 6px;
margin: -4px 0 12px;
font-size: 13px;
line-height: 1.45;
color: var(--text-secondary);
svg {
flex-shrink: 0;
margin-top: 1px;
color: var(--text-tertiary);
}
}
.post-location-text {
word-break: break-word;
}
.post-media-container {
margin-bottom: 12px;
}

View File

@@ -13,6 +13,7 @@ import './WelcomePage.scss'
const isMac = navigator.userAgent.toLowerCase().includes('mac')
const isLinux = navigator.userAgent.toLowerCase().includes('linux')
const isWindows = !isMac && !isLinux
const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录'
const dbPathPlaceholder = isMac
@@ -46,6 +47,19 @@ const formatDbKeyFailureMessage = (error?: string, logs?: string[]): string => {
return `${base};最近状态:${tailLogs.join(' | ')}`
}
const normalizeDbKeyStatusMessage = (message: string): string => {
if (isWindows && message.includes('Hook安装成功')) {
return '已准备就绪,现在登录微信或退出登录后重新登录微信'
}
return message
}
const isDbKeyReadyMessage = (message: string): boolean => (
message.includes('现在可以登录')
|| message.includes('Hook安装成功')
|| message.includes('已准备就绪,现在登录微信或退出登录后重新登录微信')
)
function WelcomePage({ standalone = false }: WelcomePageProps) {
const navigate = useNavigate()
const { isDbConnected, setDbConnected, setLoading } = useAppStore()
@@ -89,9 +103,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
// 检查 Hello 可用性
useEffect(() => {
if (window.PublicKeyCredential) {
void PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().then(setHelloAvailable)
}
setHelloAvailable(isWindows)
}, [])
async function sha256(message: string) {
@@ -103,35 +115,27 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
}
const handleSetupHello = async () => {
if (!isWindows) {
setError('当前系统不支持 Windows Hello')
return
}
if (!authPassword || authPassword !== authConfirmPassword) {
setError('请先设置并确认应用密码,再开启 Windows Hello')
return
}
setIsSettingHello(true)
try {
// 注册凭证 (WebAuthn)
const challenge = new Uint8Array(32)
window.crypto.getRandomValues(challenge)
const credential = await navigator.credentials.create({
publicKey: {
challenge,
rp: { name: 'WeFlow', id: 'localhost' },
user: {
id: new Uint8Array([1]),
name: 'user',
displayName: 'User'
},
pubKeyCredParams: [{ alg: -7, type: 'public-key' }],
authenticatorSelection: { userVerification: 'required' },
timeout: 60000
}
})
if (credential) {
setEnableHello(true)
// 成功提示?
const result = await window.electronAPI.auth.hello('请验证您的身份以开启 Windows Hello')
if (!result.success) {
setError(`Windows Hello 设置失败: ${result.error || '验证失败'}`)
return
}
setEnableHello(true)
setError('')
} catch (e: any) {
if (e.name !== 'NotAllowedError') {
setError('Windows Hello 设置失败: ' + e.message)
}
setError(`Windows Hello 设置失败: ${e?.message || String(e)}`)
} finally {
setIsSettingHello(false)
}
@@ -139,8 +143,9 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
useEffect(() => {
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
setDbKeyStatus(payload.message)
if (payload.message.includes('现在可以登录') || payload.message.includes('Hook安装成功')) {
const normalizedMessage = normalizeDbKeyStatusMessage(payload.message)
setDbKeyStatus(normalizedMessage)
if (isDbKeyReadyMessage(normalizedMessage)) {
window.electronAPI.notification?.show({
title: 'WeFlow 准备就绪',
content: '现在可以登录微信了',
@@ -487,7 +492,17 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
const hash = await sha256(authPassword)
await configService.setAuthEnabled(true)
await configService.setAuthPassword(hash)
await configService.setAuthUseHello(enableHello)
if (enableHello) {
const helloResult = await window.electronAPI.auth.setHelloSecret(authPassword)
if (!helloResult.success) {
setError('Windows Hello 配置保存失败')
setLoading(false)
return
}
} else {
await window.electronAPI.auth.clearHelloSecret()
await configService.setAuthUseHello(false)
}
}
await configService.setOnboardingDone(true)
@@ -761,8 +776,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
)}
</div>
{dbKeyStatus && <div className={`status-message ${dbKeyStatus.includes('现在可以登录') || dbKeyStatus.includes('Hook安装成功') ? 'is-success' : ''}`}>{dbKeyStatus}</div>}
<div className="field-hint"></div>
{dbKeyStatus && <div className={`status-message ${isDbKeyReadyMessage(dbKeyStatus) ? 'is-success' : ''}`}>{dbKeyStatus}</div>}
</div>
)}

View File

@@ -34,6 +34,7 @@ export const CONFIG_KEYS = {
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns',
EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency',
EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS: 'exportDefaultImageDeepSearchOnMiss',
EXPORT_WRITE_LAYOUT: 'exportWriteLayout',
EXPORT_SESSION_NAME_PREFIX_ENABLED: 'exportSessionNamePrefixEnabled',
EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap',
@@ -63,8 +64,13 @@ export const CONFIG_KEYS = {
NOTIFICATION_POSITION: 'notificationPosition',
NOTIFICATION_FILTER_MODE: 'notificationFilterMode',
NOTIFICATION_FILTER_LIST: 'notificationFilterList',
HTTP_API_TOKEN: 'httpApiToken',
HTTP_API_ENABLED: 'httpApiEnabled',
HTTP_API_PORT: 'httpApiPort',
HTTP_API_HOST: 'httpApiHost',
MESSAGE_PUSH_ENABLED: 'messagePushEnabled',
WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior',
QUOTE_LAYOUT: 'quoteLayout',
// 词云
WORD_CLOUD_EXCLUDE_WORDS: 'wordCloudExcludeWords',
@@ -89,6 +95,7 @@ export interface ExportDefaultMediaConfig {
}
export type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
export type QuoteLayout = 'quote-top' | 'quote-bottom'
const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = {
images: true,
@@ -114,6 +121,17 @@ export async function getDbPath(): Promise<string | null> {
return value as string | null
}
// 获取api access_token
export async function getHttpApiToken(): Promise<string> {
const value = await config.get(CONFIG_KEYS.HTTP_API_TOKEN)
return (value as string) || ''
}
// 设置access_token
export async function setHttpApiToken(token: string): Promise<void> {
await config.set(CONFIG_KEYS.HTTP_API_TOKEN, token)
}
// 设置数据库路径
export async function setDbPath(path: string): Promise<void> {
await config.set(CONFIG_KEYS.DB_PATH, path)
@@ -462,6 +480,18 @@ export async function setExportDefaultConcurrency(concurrency: number): Promise<
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY, concurrency)
}
// 获取缺图时是否深度搜索(默认导出行为)
export async function getExportDefaultImageDeepSearchOnMiss(): Promise<boolean | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS)
if (typeof value === 'boolean') return value
return null
}
// 设置缺图时是否深度搜索(默认导出行为)
export async function setExportDefaultImageDeepSearchOnMiss(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS, enabled)
}
export type ExportWriteLayout = 'A' | 'B' | 'C'
export async function getExportWriteLayout(): Promise<ExportWriteLayout> {
@@ -647,6 +677,10 @@ export interface ContactsListCacheContact {
displayName: string
remark?: string
nickname?: string
alias?: string
labels?: string[]
detailDescription?: string
region?: string
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
}
@@ -1121,16 +1155,18 @@ export async function setSnsPageCache(
export async function getContactsLoadTimeoutMs(): Promise<number> {
const value = await config.get(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS)
if (typeof value === 'number' && Number.isFinite(value) && value >= 1000 && value <= 60000) {
return Math.floor(value)
const normalized = Math.floor(value)
// 兼容历史默认值 3000ms自动提升到新的更稳妥阈值。
return normalized === 3000 ? 10000 : normalized
}
return 3000
return 10000
}
// 设置通讯录加载超时阈值(毫秒)
export async function setContactsLoadTimeoutMs(timeoutMs: number): Promise<void> {
const normalized = Number.isFinite(timeoutMs)
? Math.min(60000, Math.max(1000, Math.floor(timeoutMs)))
: 3000
: 10000
await config.set(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS, normalized)
}
@@ -1159,6 +1195,12 @@ export async function getContactsListCache(scopeKey: string): Promise<ContactsLi
displayName,
remark: typeof item.remark === 'string' ? item.remark : undefined,
nickname: typeof item.nickname === 'string' ? item.nickname : undefined,
alias: typeof item.alias === 'string' ? item.alias : undefined,
labels: Array.isArray(item.labels)
? Array.from(new Set(item.labels.map((label) => String(label || '').trim()).filter(Boolean)))
: undefined,
detailDescription: typeof item.detailDescription === 'string' ? (item.detailDescription.trim() || undefined) : undefined,
region: typeof item.region === 'string' ? (item.region.trim() || undefined) : undefined,
type: (type === 'friend' || type === 'group' || type === 'official' || type === 'former_friend' || type === 'other')
? type
: 'other'
@@ -1192,6 +1234,12 @@ export async function setContactsListCache(scopeKey: string, contacts: ContactsL
displayName,
remark: contact?.remark ? String(contact.remark) : undefined,
nickname: contact?.nickname ? String(contact.nickname) : undefined,
alias: contact?.alias ? String(contact.alias) : undefined,
labels: Array.isArray(contact?.labels)
? Array.from(new Set(contact.labels.map((label) => String(label || '').trim()).filter(Boolean)))
: undefined,
detailDescription: contact?.detailDescription ? (String(contact.detailDescription).trim() || undefined) : undefined,
region: contact?.region ? (String(contact.region).trim() || undefined) : undefined,
type
})
}
@@ -1396,6 +1444,16 @@ export async function setWindowCloseBehavior(behavior: WindowCloseBehavior): Pro
await config.set(CONFIG_KEYS.WINDOW_CLOSE_BEHAVIOR, behavior)
}
export async function getQuoteLayout(): Promise<QuoteLayout> {
const value = await config.get(CONFIG_KEYS.QUOTE_LAYOUT)
if (value === 'quote-bottom') return value
return 'quote-top'
}
export async function setQuoteLayout(layout: QuoteLayout): Promise<void> {
await config.set(CONFIG_KEYS.QUOTE_LAYOUT, layout)
}
// 获取词云排除词列表
export async function getWordCloudExcludeWords(): Promise<string[]> {
const value = await config.get(CONFIG_KEYS.WORD_CLOUD_EXCLUDE_WORDS)
@@ -1429,3 +1487,35 @@ export async function getAnalyticsDenyCount(): Promise<number> {
export async function setAnalyticsDenyCount(count: number): Promise<void> {
await config.set(CONFIG_KEYS.ANALYTICS_DENY_COUNT, count)
}
// 获取 HTTP API 自动启动状态
export async function getHttpApiEnabled(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.HTTP_API_ENABLED)
return value === true
}
// 设置 HTTP API 自动启动状态
export async function setHttpApiEnabled(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.HTTP_API_ENABLED, enabled)
}
// 获取 HTTP API 端口
export async function getHttpApiPort(): Promise<number> {
const value = await config.get(CONFIG_KEYS.HTTP_API_PORT)
return typeof value === 'number' ? value : 5031
}
// 设置 HTTP API 端口
export async function setHttpApiPort(port: number): Promise<void> {
await config.set(CONFIG_KEYS.HTTP_API_PORT, port)
}
export async function getHttpApiHost(): Promise<string> {
const value = await config.get(CONFIG_KEYS.HTTP_API_HOST)
return typeof value === 'string' && value.trim() ? value.trim() : '127.0.0.1'
}
export async function setHttpApiHost(host: string): Promise<void> {
await config.set(CONFIG_KEYS.HTTP_API_HOST, host)
}

View File

@@ -61,6 +61,7 @@ export interface ElectronAPI {
ignoreUpdate: (version: string) => Promise<{ success: boolean }>
onDownloadProgress: (callback: (progress: number) => void) => () => void
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => () => void
checkWayland: () => Promise<boolean>
}
notification: {
show: (data: { title: string; content: string; avatarUrl?: string; sessionId: string }) => Promise<{ success?: boolean; error?: string } | void>
@@ -218,7 +219,7 @@ export interface ElectronAPI {
updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) => Promise<{ success: boolean; error?: string }>
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) => Promise<{ success: boolean; error?: string }>
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) => Promise<{ payerName: string; receiverName: string }>
getContacts: () => Promise<{
getContacts: (options?: { lite?: boolean }) => Promise<{
success: boolean
contacts?: ContactInfo[]
error?: string
@@ -495,6 +496,28 @@ export interface ElectronAPI {
}
error?: string
}>
getGroupMemberAnalytics: (chatroomId: string, memberUsername: string, startTime?: number, endTime?: number) => Promise<{
success: boolean
data?: {
statistics: {
totalMessages: number
textMessages: number
imageMessages: number
voiceMessages: number
videoMessages: number
emojiMessages: number
otherMessages: number
sentMessages: number
receivedMessages: number
firstMessageTime: number | null
lastMessageTime: number | null
activeDays: number
messageTypeCounts: Record<number, number>
}
timeDistribution: Record<number, number>
}
error?: string
}>
getGroupMemberMessages: (
chatroomId: string,
memberUsername: string,
@@ -790,6 +813,16 @@ export interface ElectronAPI {
}>
likes: Array<string>
comments: Array<{ id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: Array<{ url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }> }>
location?: {
latitude?: number
longitude?: number
city?: string
country?: string
poiName?: string
poiAddress?: string
poiAddressName?: string
label?: string
}
rawXml?: string
}>
error?: string
@@ -827,7 +860,7 @@ export interface ElectronAPI {
getLogs: () => Promise<string[]>
}
http: {
start: (port?: number) => Promise<{ success: boolean; port?: number; error?: string }>
start: (port?: number, host?: string) => Promise<{ success: boolean; port?: number; error?: string }>
stop: () => Promise<{ success: boolean }>
status: () => Promise<{ running: boolean; port: number; mediaExportPath: string }>
}
@@ -852,6 +885,7 @@ export interface ExportOptions {
sessionNameWithTypePrefix?: boolean
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
exportConcurrency?: number
imageDeepSearchOnMiss?: boolean
}
export interface ExportProgress {

View File

@@ -37,6 +37,9 @@ export interface ContactInfo {
remark?: string
nickname?: string
alias?: string
labels?: string[]
detailDescription?: string
region?: string
avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
}

View File

@@ -34,6 +34,17 @@ export interface SnsComment {
emojis?: SnsCommentEmoji[]
}
export interface SnsLocation {
latitude?: number
longitude?: number
city?: string
country?: string
poiName?: string
poiAddress?: string
poiAddressName?: string
label?: string
}
export interface SnsPost {
id: string
tid?: string // 数据库主键(雪花 ID用于精确删除
@@ -46,6 +57,7 @@ export interface SnsPost {
media: SnsMedia[]
likes: string[]
comments: SnsComment[]
location?: SnsLocation
rawXml?: string
linkTitle?: string
linkUrl?: string

BIN
temp_assets.json Normal file

Binary file not shown.

View File

@@ -4,6 +4,10 @@ import electron from 'vite-plugin-electron'
import renderer from 'vite-plugin-electron-renderer'
import { resolve } from 'path'
const handleElectronOnStart = (options: { reload: () => void }) => {
options.reload()
}
export default defineConfig({
base: './',
server: {
@@ -23,6 +27,7 @@ export default defineConfig({
electron([
{
entry: 'electron/main.ts',
onstart: handleElectronOnStart,
vite: {
build: {
outDir: 'dist-electron',
@@ -43,6 +48,7 @@ export default defineConfig({
},
{
entry: 'electron/annualReportWorker.ts',
onstart: handleElectronOnStart,
vite: {
build: {
outDir: 'dist-electron',
@@ -61,6 +67,7 @@ export default defineConfig({
},
{
entry: 'electron/dualReportWorker.ts',
onstart: handleElectronOnStart,
vite: {
build: {
outDir: 'dist-electron',
@@ -79,6 +86,7 @@ export default defineConfig({
},
{
entry: 'electron/imageSearchWorker.ts',
onstart: handleElectronOnStart,
vite: {
build: {
outDir: 'dist-electron',
@@ -93,6 +101,7 @@ export default defineConfig({
},
{
entry: 'electron/wcdbWorker.ts',
onstart: handleElectronOnStart,
vite: {
build: {
outDir: 'dist-electron',
@@ -112,6 +121,7 @@ export default defineConfig({
},
{
entry: 'electron/transcribeWorker.ts',
onstart: handleElectronOnStart,
vite: {
build: {
outDir: 'dist-electron',
@@ -129,6 +139,7 @@ export default defineConfig({
},
{
entry: 'electron/exportWorker.ts',
onstart: handleElectronOnStart,
vite: {
build: {
outDir: 'dist-electron',
@@ -149,9 +160,7 @@ export default defineConfig({
},
{
entry: 'electron/preload.ts',
onstart(options) {
options.reload()
},
onstart: handleElectronOnStart,
vite: {
build: {
outDir: 'dist-electron'