This commit is contained in:
cc
2026-06-03 19:13:07 +08:00
parent f3b0cf5d3a
commit 54016abac5
382 changed files with 2 additions and 188190 deletions

View File

@@ -1,140 +0,0 @@
name: "报告 Bug"
description: "代码出现了非预期的问题、崩溃或报错"
title: "[Bug]: "
labels: ["type: bug", "status: needs info"]
body:
- type: markdown
attributes:
value: |
请提供尽可能详细的信息,帮助我们快速定位和修复问题。
- type: input
id: confirm-unique
attributes:
label: 确认唯一性
description: 请输入:我已确认没有相同问题出现
placeholder: 我已确认没有相同问题出现
validations:
required: true
- type: dropdown
id: standard-operation
attributes:
label: 这是标准操作流程下出现的问题吗?
description: 确认你是按照正常方式使用,而不是非常规操作
options:
- 是,标准操作
- 否,非标准操作
validations:
required: true
- type: dropdown
id: code-issue-confirm
attributes:
label: 你确认这真的是我们的代码导致的吗?
description: 请仔细思考,排除网络、系统、第三方服务等外部因素
options:
- 是,确认是代码问题
- 不确定
- 否,可能是其他原因
validations:
required: true
- type: checkboxes
id: pre-check
attributes:
label: 基础确认
options:
- label: 我使用的是最新版本
required: true
- label: 我已阅读过相关文档
required: true
- type: dropdown
id: platform
attributes:
label: 使用平台
description: 选择出现问题的平台
options:
- Windows
- macOS
- Linux
validations:
required: true
- type: dropdown
id: severity
attributes:
label: 问题严重程度
description: 这个问题对你的使用造成了多大影响?
options:
- 严重崩溃或数据丢失(无法使用)
- 核心功能受影响(在下一个常规发布中必须修复)
- 边缘场景或轻微问题(等待空闲时修复)
validations:
required: true
- type: textarea
id: description
attributes:
label: 问题描述
description: 清晰描述你遇到的问题,包括实际发生了什么
placeholder: 例如:当我点击发送按钮时,应用程序崩溃并显示白屏
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: 复现步骤
description: 提供详细的操作步骤,让我们能够重现这个问题
placeholder: |
1. 打开应用并登录账号
2. 进入聊天页面
3. 点击发送按钮
4. 观察到应用崩溃
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: 预期行为
description: 描述你期望的正确行为应该是什么样的
placeholder: 例如:点击发送按钮后,消息应该正常发送并显示在聊天窗口中
validations:
required: true
- type: textarea
id: actual-behavior
attributes:
label: 实际行为
description: 描述实际发生的错误行为
placeholder: 例如:点击后应用直接崩溃,显示白屏
validations:
required: true
- type: textarea
id: logs
attributes:
label: 错误日志或截图
description: 粘贴控制台错误信息、崩溃日志,或拖入截图
placeholder: 请粘贴完整的错误堆栈信息
render: shell
- type: input
id: os
attributes:
label: 操作系统版本
description: 例如Windows 11 24H2、macOS 15.0、Ubuntu 24.04
placeholder: Windows 11 24H2
validations:
required: true
- type: input
id: app-version
attributes:
label: 应用版本
description: 在关于页面或设置中查看版本号
placeholder: v1.2.3
validations:
required: true
- type: input
id: architecture
attributes:
label: 系统架构
description: 例如x64、arm64
placeholder: x64
- type: textarea
id: additional-context
attributes:
label: 补充信息
description: 其他可能有助于定位问题的信息
placeholder: 例如:这个问题是在某次更新后开始出现的,或者只在特定网络环境下出现

View File

@@ -1 +0,0 @@
blank_issues_enabled: false

View File

@@ -1,75 +0,0 @@
name: "文档反馈"
description: "文档存在错别字、描述不清晰或缺少必要的示例"
title: "[Docs]: "
labels: ["type: docs"]
body:
- type: markdown
attributes:
value: |
优秀的文档和代码一样重要。感谢你帮助我们完善文档!
- type: input
id: confirm-unique
attributes:
label: 确认唯一性
description: 请输入:我已确认没有相同问题出现
placeholder: 我已确认没有相同问题出现
validations:
required: true
- type: dropdown
id: doc-type
attributes:
label: 文档类型
description: 问题出现在哪类文档中?
options:
- README 或项目说明
- 安装部署文档
- 使用教程
- API 文档
- 开发者文档
- 其他
validations:
required: true
- type: input
id: doc-link
attributes:
label: 文档位置
description: 提供文档的 URL 或文件路径
placeholder: 例如docs/installation.md 或 https://github.com/xxx/xxx/wiki/xxx
validations:
required: true
- type: dropdown
id: issue-type
attributes:
label: 问题类型
description: 文档存在什么问题?
options:
- 错别字或语法错误
- 内容过时或不准确
- 描述不清晰或有歧义
- 缺少必要的示例代码
- 缺少重要的说明或警告
- 链接失效或错误
- 其他
validations:
required: true
- type: textarea
id: issue-desc
attributes:
label: 问题描述
description: 详细说明文档中存在的问题
placeholder: 例如:第 3 步中的命令拼写错误,应该是 "npm install" 而不是 "npm instal"
validations:
required: true
- type: textarea
id: suggestion
attributes:
label: 修改建议
description: 你认为应该如何修改?
placeholder: 例如:建议将"安装依赖"部分补充完整的命令示例,并说明不同操作系统的差异
validations:
required: true
- type: textarea
id: additional
attributes:
label: 补充说明
description: 其他需要补充的信息

View File

@@ -1,92 +0,0 @@
name: "功能与体验优化"
description: "对现有的功能逻辑进行优化,或改进用户体验"
title: "[Enhancement]: "
labels: ["type: enhancement"]
body:
- type: markdown
attributes:
value: |
持续优化是项目进步的动力!请告诉我们哪个现有功能可以做得更好。
- type: input
id: confirm-unique
attributes:
label: 确认唯一性
description: 请输入:我已确认没有相同问题出现
placeholder: 我已确认没有相同问题出现
validations:
required: true
- type: input
id: confirm-beneficial
attributes:
label: 确认有益性
description: 请输入:我确认这真的对应用有益
placeholder: 我确认这真的对应用有益
validations:
required: true
- type: checkboxes
id: pre-check
attributes:
label: 提交前确认
options:
- label: 这是对现有功能的改进,而不是全新功能
required: true
- type: dropdown
id: category
attributes:
label: 优化类别
description: 这个优化主要属于哪个方面?
options:
- 性能优化(速度、内存、资源占用)
- 交互体验(操作流程、界面布局)
- 视觉设计(样式、动画、美观度)
- 易用性(降低使用门槛、减少操作步骤)
- 稳定性(减少崩溃、提高可靠性)
- 其他
validations:
required: true
- type: textarea
id: target
attributes:
label: 目标功能或模块
description: 你希望优化的具体功能或页面是哪个?
placeholder: 例如:聊天页面的消息加载、设置页面的布局、文件上传功能
validations:
required: true
- type: textarea
id: current-behavior
attributes:
label: 当前表现
description: 描述当前功能的不足之处或存在的问题
placeholder: 例如:消息列表滚动时会出现明显卡顿,加载 100 条消息需要 3 秒
validations:
required: true
- type: textarea
id: improvement
attributes:
label: 优化建议
description: 详细说明你的优化方案和预期效果
placeholder: 例如:建议使用虚拟滚动技术,只渲染可见区域的消息,预计可将加载时间缩短到 0.5 秒以内
validations:
required: true
- type: textarea
id: benefits
attributes:
label: 优化收益
description: 这个优化会带来什么具体好处?
placeholder: 例如:提升 80% 的加载速度、减少 50% 的内存占用、降低用户操作步骤从 5 步到 2 步
validations:
required: true
- type: textarea
id: impact
attributes:
label: 影响范围
description: 这个优化会影响哪些用户或场景?
placeholder: 例如:所有用户在查看历史消息时都会受益,尤其是群聊消息较多的场景
- type: checkboxes
id: contribution
attributes:
label: 参与贡献
options:
- label: 我愿意提交 Pull Request 来实现这个优化
validations:
required: true

View File

@@ -1,85 +0,0 @@
name: "全新功能请求"
description: "提议一个目前项目中完全没有的新特性"
title: "[Feature]: "
labels: ["type: feature"]
body:
- type: markdown
attributes:
value: |
感谢你为项目提供新想法!详细的需求描述能极大提高该功能被采纳的几率。
- type: input
id: confirm-unique
attributes:
label: 确认唯一性
description: 请输入:我已确认没有相同问题出现
placeholder: 我已确认没有相同问题出现
validations:
required: true
- type: input
id: confirm-useful
attributes:
label: 确认实用性
description: 请输入:我认为这会对大部分人都有用
placeholder: 我认为这会对大部分人都有用
validations:
required: true
- type: checkboxes
id: pre-check
attributes:
label: 提交前确认
options:
- label: 这是一个全新的功能,而不是对现有功能的改进
required: true
- type: dropdown
id: priority
attributes:
label: 功能优先级
description: 你认为这个功能有多重要?
options:
- 高优先级(核心功能缺失,严重影响使用体验)
- 中优先级(有助于提升使用体验)
- 低优先级(锦上添花的功能)
validations:
required: true
- type: textarea
id: problem
attributes:
label: 问题或痛点
description: 【为什么需要】你现在做某件事遇到了什么困难?缺少什么能力?
placeholder: 例如:目前无法批量导出聊天记录,每次只能手动复制单条消息,处理 100 条消息需要半小时
validations:
required: true
- type: textarea
id: solution
attributes:
label: 期望的解决方案
description: 【怎么实现】详细描述功能的操作流程、界面位置、可选参数等
placeholder: 例如:在聊天窗口右键菜单添加"导出记录"点击后弹窗可选时间范围、导出格式TXT/JSON、筛选用户最后保存到本地
validations:
required: true
- type: textarea
id: use-case
attributes:
label: 使用场景
description: 【什么时候用】你会在哪些具体情况下使用这个功能?
placeholder: 例如:每周五整理工作讨论记录;保存客户沟通记录作为合同依据;备份重要群聊内容
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: 替代方案
description: 你目前使用什么临时方案?或者有没有考虑过其他实现方式?
placeholder: 例如:目前只能手动截图或逐条复制粘贴
- type: textarea
id: reference
attributes:
label: 参考示例
description: 其他应用中是否有类似功能可以参考?
placeholder: 例如微信的聊天记录导出功能、Telegram 的导出数据功能
- type: checkboxes
id: contribution
attributes:
label: 参与贡献
options:
- label: 我愿意提交 Pull Request 来实现这个功能

View File

@@ -1,77 +0,0 @@
name: "使用答疑"
description: "关于如何配置、如何使用项目的求助"
title: "[Question]: "
labels: ["type: question"]
body:
- type: markdown
attributes:
value: |
在提问之前,请确保你已经仔细阅读过我们的官方文档。
- type: input
id: confirm-unique
attributes:
label: 确认唯一性
description: 请输入:我已确认没有相同问题出现
placeholder: 我已确认没有相同问题出现
validations:
required: true
- type: checkboxes
id: pre-check
attributes:
label: 提交前确认
options:
- label: 我已阅读过相关文档
required: true
- type: dropdown
id: question-type
attributes:
label: 问题类型
description: 你的问题属于哪个方面?
options:
- 安装部署问题
- 配置相关问题
- 功能使用问题
- API 调用问题
- 错误排查问题
- 其他
validations:
required: true
- type: textarea
id: question
attributes:
label: 问题描述
description: 清晰描述你遇到的问题或疑问
placeholder: 例如:我在 Windows 系统上安装后无法启动应用,双击图标没有任何反应
validations:
required: true
- type: textarea
id: attempts
attributes:
label: 已尝试的方法
description: 你已经尝试过哪些解决方法?
placeholder: 例如:我尝试过重新安装、以管理员身份运行、关闭防火墙,但问题依然存在
validations:
required: true
- type: textarea
id: environment
attributes:
label: 运行环境
description: 提供你的系统环境信息
placeholder: |
操作系统Windows 11
应用版本v1.2.3
系统架构x64
validations:
required: true
- type: textarea
id: code-snippet
attributes:
label: 相关配置或代码
description: 如果涉及配置或代码问题,请粘贴相关内容
placeholder: 粘贴你的配置文件或代码片段
render: javascript
- type: textarea
id: screenshots
attributes:
label: 截图或日志
description: 如有必要,请提供截图或错误日志

View File

@@ -1,7 +0,0 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
target-branch: "dev"

View File

@@ -1,237 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
retry_cmd() {
local attempts="$1"
local delay_seconds="$2"
shift 2
local i
local exit_code
for ((i = 1; i <= attempts; i++)); do
if "$@"; then
return 0
fi
exit_code=$?
if [ "$i" -lt "$attempts" ]; then
echo "Command failed (attempt $i/$attempts, exit=$exit_code): $*" >&2
echo "Retrying in ${delay_seconds}s..." >&2
sleep "$delay_seconds"
fi
done
echo "Command failed after $attempts attempts: $*" >&2
return "$exit_code"
}
capture_cmd_with_retry() {
local result_var="$1"
local attempts="$2"
local delay_seconds="$3"
shift 3
local i
local output=""
local exit_code=1
for ((i = 1; i <= attempts; i++)); do
if output="$("$@" 2>/dev/null)"; then
printf -v "$result_var" "%s" "$output"
return 0
fi
exit_code=$?
if [ "$i" -lt "$attempts" ]; then
echo "Capture command failed (attempt $i/$attempts, exit=$exit_code): $*" >&2
echo "Retrying in ${delay_seconds}s..." >&2
sleep "$delay_seconds"
fi
done
echo "Capture command failed after $attempts attempts: $*" >&2
return "$exit_code"
}
wait_for_release_id() {
local repo="$1"
local tag="$2"
local attempts="${3:-12}"
local delay_seconds="${4:-2}"
local i
local release_id
local release_api_url
for ((i = 1; i <= attempts; i++)); do
release_id="$(gh api "repos/$repo/releases/tags/$tag" --jq '.id' 2>/dev/null || true)"
if [[ "$release_id" =~ ^[0-9]+$ ]]; then
echo "$release_id"
return 0
fi
release_id="$(gh release view "$tag" --repo "$repo" --json databaseId --jq '.databaseId // empty' 2>/dev/null || true)"
if [[ "$release_id" =~ ^[0-9]+$ ]]; then
echo "$release_id"
return 0
fi
release_api_url="$(gh release view "$tag" --repo "$repo" --json apiUrl --jq '.apiUrl // empty' 2>/dev/null || true)"
if [[ "$release_api_url" =~ /releases/([0-9]+)$ ]]; then
echo "${BASH_REMATCH[1]}"
return 0
fi
if [ "$i" -lt "$attempts" ]; then
echo "Release id for tag '$tag' is not ready yet (attempt $i/$attempts), retrying in ${delay_seconds}s..." >&2
sleep "$delay_seconds"
fi
done
echo "Unable to fetch release id for tag '$tag' after $attempts attempts." >&2
gh release view "$tag" --repo "$repo" --json databaseId,id,isDraft,isPrerelease,url 2>/dev/null || true
gh api "repos/$repo/releases/tags/$tag" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' 2>/dev/null || true
return 1
}
settle_release_state() {
local repo="$1"
local release_id="$2"
local tag="$3"
local attempts="${4:-12}"
local delay_seconds="${5:-2}"
local endpoint="repos/$repo/releases/tags/$tag"
local i
local draft_state
local prerelease_state
for ((i = 1; i <= attempts; i++)); do
gh release edit "$tag" --repo "$repo" --draft=false --prerelease >/dev/null 2>&1 || true
gh api --method PATCH "repos/$repo/releases/$release_id" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
draft_state="$(gh api "$endpoint" --jq '.draft' 2>/dev/null || gh release view "$tag" --repo "$repo" --json isDraft --jq '.isDraft' 2>/dev/null || echo true)"
prerelease_state="$(gh api "$endpoint" --jq '.prerelease' 2>/dev/null || gh release view "$tag" --repo "$repo" --json isPrerelease --jq '.isPrerelease' 2>/dev/null || echo false)"
if [ "$draft_state" = "false" ] && [ "$prerelease_state" = "true" ]; then
return 0
fi
if [ "$i" -lt "$attempts" ]; then
echo "Release '$tag' state not settled yet (attempt $i/$attempts), retrying in ${delay_seconds}s..." >&2
sleep "$delay_seconds"
fi
done
echo "Failed to settle release state for tag '$tag'." >&2
gh release view "$tag" --repo "$repo" --json isDraft,isPrerelease,url 2>/dev/null || true
gh api "$endpoint" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' 2>/dev/null || true
return 1
}
print_release_state() {
local repo="$1"
local tag="$2"
gh api "repos/$repo/releases/tags/$tag" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}' 2>/dev/null \
|| gh release view "$tag" --repo "$repo" --json isDraft,isPrerelease,url --jq '{isDraft: .isDraft, isPrerelease: .isPrerelease, url: .url}'
}
wait_for_release_absent() {
local repo="$1"
local tag="$2"
local attempts="${3:-12}"
local delay_seconds="${4:-2}"
local i
for ((i = 1; i <= attempts; i++)); do
if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then
if [ "$i" -lt "$attempts" ]; then
echo "Release '$tag' still exists (attempt $i/$attempts), waiting ${delay_seconds}s..." >&2
sleep "$delay_seconds"
fi
continue
fi
return 0
done
echo "Release '$tag' still exists after waiting." >&2
gh release view "$tag" --repo "$repo" --json url,isDraft,isPrerelease 2>/dev/null || true
return 1
}
wait_for_git_tag_absent() {
local repo="$1"
local tag="$2"
local attempts="${3:-12}"
local delay_seconds="${4:-2}"
local i
for ((i = 1; i <= attempts; i++)); do
if gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1; then
if [ "$i" -lt "$attempts" ]; then
echo "Git tag '$tag' still exists (attempt $i/$attempts), waiting ${delay_seconds}s..." >&2
sleep "$delay_seconds"
fi
continue
fi
return 0
done
echo "Git tag '$tag' still exists after waiting." >&2
gh api "repos/$repo/git/ref/tags/$tag" --jq '{ref: .ref, object: .object.sha}' 2>/dev/null || true
return 1
}
recreate_fixed_prerelease() {
local repo="$1"
local tag="$2"
local target_branch="$3"
local release_title="$4"
local release_notes="$5"
if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then
retry_cmd 5 3 gh release delete "$tag" --repo "$repo" --yes --cleanup-tag
fi
wait_for_release_absent "$repo" "$tag" 12 2
if gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1; then
retry_cmd 5 2 gh api --method DELETE "repos/$repo/git/refs/tags/$tag"
fi
wait_for_git_tag_absent "$repo" "$tag" 12 2
local created="false"
local i
for ((i = 1; i <= 6; i++)); do
if gh release create "$tag" --repo "$repo" --title "$release_title" --notes "$release_notes" --prerelease --target "$target_branch"; then
created="true"
break
fi
if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then
echo "Release '$tag' appears to exist after create failure; continue to settle state." >&2
created="true"
break
fi
if [ "$i" -lt 6 ]; then
echo "Create release '$tag' failed (attempt $i/6), retrying in 3s..." >&2
sleep 3
fi
done
if [ "$created" != "true" ]; then
echo "Failed to create release '$tag'." >&2
return 1
fi
local release_id
release_id="$(wait_for_release_id "$repo" "$tag" 12 2)"
settle_release_state "$repo" "$release_id" "$tag" 12 2
}
upload_release_assets_with_retry() {
local repo="$1"
local tag="$2"
shift 2
if [ "$#" -eq 0 ]; then
echo "No release assets provided for upload." >&2
return 1
fi
wait_for_release_id "$repo" "$tag" 12 2 >/dev/null
retry_cmd 5 3 gh release upload "$tag" "$@" --repo "$repo" --clobber
}

View File

@@ -1,134 +0,0 @@
name: Anti-Spam
on:
issues:
types: [opened, edited]
permissions:
issues: write
jobs:
check-spam:
runs-on: ubuntu-latest
steps:
- name: Check for spam
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
const title = (issue.title || '').toLowerCase();
const body = (issue.body || '').toLowerCase();
const text = title + ' ' + body;
// 博彩/赌球类
const gamblingPatterns = [
/世界杯.*买球/, /买球.*世界杯/,
/世界杯.*下注/, /世界杯.*竞猜/,
/世界杯.*投注/, /世界杯.*押注/,
/世界杯.*彩票/, /世界杯.*平台/,
/世界杯.*app/, /世界杯.*软件/,
/世界杯.*网站/, /世界杯.*网址/,
/足球.*买球/, /买球.*足球/,
/足球.*投注/, /足球.*押注/,
/足球.*竞猜/, /足球.*平台/,
/篮球.*买球/, /篮球.*投注/,
/体育.*投注/, /体育.*竞猜/,
/体育.*买球/, /体育.*押注/,
/赌球/, /赌博.*网站/, /赌博.*平台/,
/博彩/, /博彩.*网站/, /博彩.*平台/,
/正规.*买球/, /官方.*买球/,
/买球.*网站/, /买球.*app/,
/买球.*软件/, /买球.*网址/,
/买球.*平台/, /买球.*技巧/,
/投注.*网站/, /投注.*平台/,
/押注.*网站/, /押注.*平台/,
/竞猜.*网站/, /竞猜.*平台/,
/彩票.*网站/, /彩票.*平台/,
/欧洲杯.*买球/, /欧冠.*买球/,
/nba.*买球/, /nba.*投注/,
];
// 色情/交友类
const adultPatterns = [
/约炮/, /一夜情/, /外围/,
/包养/, /援交/, /陪聊/,
/成人.*网站/, /成人.*视频/,
/av.*网站/, /黄色.*网站/,
];
// 贷款/金融诈骗类
const financePatterns = [
/秒到账.*贷款/, /无抵押.*贷款/,
/征信.*贷款/, /黑户.*贷款/,
/快速.*放款/, /私人.*放贷/,
/刷单/, /兼职.*日入/, /兼职.*月入/,
/网赚/, /躺赚/, /被动收入.*平台/,
/虚拟货币.*投资/, /usdt.*投资/,
/炒币.*平台/, /数字货币.*平台/,
];
// 垃圾推广类
const spamPromoPatterns = [
/代刷/, /粉丝.*购买/, /涨粉/,
/seo.*优化/, /快速排名/,
/微商/, /代理.*招募/,
];
// 账号特征检测(新账号 + 无 contribution
const allPatterns = [
...gamblingPatterns,
...adultPatterns,
...financePatterns,
...spamPromoPatterns,
];
const isSpam = allPatterns.some(pattern => pattern.test(text));
// 额外检测:标题超短且含可疑关键词(常见于批量刷单)
const suspiciousShort = title.length < 10 && /(买球|投注|博彩|赌博|下注|押注)/.test(title);
if (isSpam || suspiciousShort) {
// 确保 spam label 存在
try {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'spam',
color: 'e4e669',
description: 'Spam issue'
});
} catch (e) {
// label 已存在,忽略
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: '此 issue 已被自动识别为垃圾内容并关闭。\n\nThis issue has been automatically identified as spam and closed.'
});
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ['spam']
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: 'closed',
state_reason: 'not_planned'
});
await github.rest.issues.lock({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
lock_reason: 'spam'
});
console.log(`Closed spam issue #${issue.number}: ${issue.title}`);
}

View File

@@ -1,389 +0,0 @@
name: Dev Daily
on:
schedule:
# GitHub Actions schedule uses UTC. 16:00 UTC = 北京时间次日 00:00
- cron: "0 16 * * *"
workflow_dispatch:
concurrency:
group: dev-nightly-fixed-release
cancel-in-progress: true
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
FIXED_DEV_TAG: nightly-dev
TARGET_BRANCH: dev
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
dev_version: ${{ steps.meta.outputs.dev_version }}
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
fetch-depth: 0
- name: Install Node.js
uses: actions/setup-node@v5
with:
node-version: 24
cache: "npm"
- name: Generate daily dev version
id: meta
shell: bash
run: |
set -euo pipefail
YEAR_2="$(TZ=Asia/Shanghai date +%y)"
MONTH="$(TZ=Asia/Shanghai date +%-m)"
DAY="$(TZ=Asia/Shanghai date +%-d)"
DEV_VERSION="${YEAR_2}.${MONTH}.${DAY}"
echo "dev_version=$DEV_VERSION" >> "$GITHUB_OUTPUT"
echo "Dev version: $DEV_VERSION"
- name: Recreate fixed prerelease
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
recreate_fixed_prerelease "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "$TARGET_BRANCH" "Daily Dev Build" "开发版发布页"
dev-mac-arm64:
needs: prepare
runs-on: macos-14
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
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: Ensure mac key helpers are executable
shell: bash
run: |
set -euo pipefail
for file in \
resources/key/macos/universal/xkey_helper \
resources/key/macos/universal/image_scan_helper \
resources/key/macos/universal/xkey_helper_macos \
resources/key/macos/universal/libwx_key.dylib
do
if [ -f "$file" ]; then
chmod +x "$file"
ls -l "$file"
fi
done
- name: Set dev version
shell: bash
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
- name: Build Frontend & Type Check
shell: bash
run: |
npx tsc
npx vite build
- name: Package macOS arm64 dev artifacts
shell: bash
run: |
set -euo pipefail
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
if ! npx electron-builder --mac dmg zip --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}'; then
echo "::warning::DMG packaging failed (hdiutil instability on runner). Retrying with ZIP only."
npx electron-builder --mac zip --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}'
fi
- name: Upload macOS arm64 assets to fixed release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
assets=()
while IFS= read -r file; do
assets+=("$file")
done < <(find release -maxdepth 1 -type f | sort)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No release files found in ./release"
exit 1
fi
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
dev-linux:
needs: prepare
runs-on: ubuntu-latest
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
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: Set dev version
shell: bash
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
- name: Build Frontend & Type Check
shell: bash
run: |
npx tsc
npx vite build
- name: Package Linux dev artifacts
run: |
npx electron-builder --linux --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-linux.${ext}'
- name: Upload Linux assets to fixed release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
assets=()
while IFS= read -r file; do
assets+=("$file")
done < <(find release -maxdepth 1 -type f | sort)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No release files found in ./release"
exit 1
fi
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
dev-win-x64:
needs: prepare
runs-on: windows-latest
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
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: Set dev version
shell: bash
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
- name: Build Frontend & Type Check
shell: bash
run: |
npx tsc
npx vite build
- name: Package Windows x64 dev artifacts
run: |
npx electron-builder --win nsis --x64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-x64-Setup.${ext}'
- name: Upload Windows x64 assets to fixed release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
assets=()
while IFS= read -r file; do
assets+=("$file")
done < <(find release -maxdepth 1 -type f | sort)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No release files found in ./release"
exit 1
fi
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
dev-win-arm64:
needs: prepare
runs-on: windows-latest
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
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: Set dev version
shell: bash
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
- name: Build Frontend & Type Check
shell: bash
run: |
npx tsc
npx vite build
- name: Package Windows arm64 dev artifacts
run: |
npx electron-builder --win nsis --arm64 --publish never '--config.publish.channel=dev-arm64' '--config.artifactName=${productName}-dev-arm64-Setup.${ext}'
- name: Upload Windows arm64 assets to fixed release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
assets=()
while IFS= read -r file; do
assets+=("$file")
done < <(find release -maxdepth 1 -type f | sort)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No release files found in ./release"
exit 1
fi
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
update-dev-release-notes:
needs:
- prepare
- dev-mac-arm64
- dev-linux
- dev-win-x64
- dev-win-arm64
if: always() && needs.prepare.result == 'success'
runs-on: ubuntu-latest
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
fetch-depth: 1
- name: Update fixed dev release notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
TAG="${FIXED_DEV_TAG:-}"
if [ -z "$TAG" ]; then
echo "FIXED_DEV_TAG is empty, abort."
exit 1
fi
REPO="$GITHUB_REPOSITORY"
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
echo "Using release tag: $TAG"
if ! gh api "repos/$REPO/releases/tags/$TAG" >/dev/null 2>&1; then
echo "Release $TAG not found, skip notes update."
exit 0
fi
ASSETS_JSON="$(gh api "repos/$REPO/releases/tags/$TAG")"
pick_asset() {
local pattern="$1"
echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""'
}
WINDOWS_ASSET="$(pick_asset "dev-x64-Setup[.]exe$")"
WINDOWS_ARM64_ASSET="$(pick_asset "dev-arm64-Setup[.]exe$")"
MAC_ASSET="$(pick_asset "dev-arm64[.]dmg$")"
if [ -z "$MAC_ASSET" ]; then
MAC_ASSET="$(pick_asset "dev-arm64[.]zip$")"
fi
LINUX_TAR_ASSET="$(pick_asset "dev-linux[.]tar[.]gz$")"
LINUX_APPIMAGE_ASSET="$(pick_asset "dev-linux[.]AppImage$")"
build_link() {
local name="$1"
if [ -n "$name" ]; then
echo "https://github.com/$REPO/releases/download/$TAG/$name"
fi
}
WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
MAC_URL="$(build_link "$MAC_ASSET")"
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
cat > dev_release_notes.md <<EOF
## Daily Dev Build
- 该发布页为 **开发版**。
- 当前构建版本:\`${{ needs.prepare.outputs.dev_version }}\`
- 此版本为每日构建的开发版,包含最新的功能和修复,但可能不够稳定,仅推荐用于测试和验证。
## 下载
- Windows x64: [点击下载](${WINDOWS_URL:-$RELEASE_PAGE})
- Windows arm64: [点击下载](${WINDOWS_ARM64_URL:-$RELEASE_PAGE})
- macOSApple Silicon: [点击下载](${MAC_URL:-$RELEASE_PAGE})
- Linux (.tar.gz): [点击下载](${LINUX_TAR_URL:-$RELEASE_PAGE})
- Linux (.AppImage): [点击下载](${LINUX_APPIMAGE_URL:-$RELEASE_PAGE})
## macOS 安装提示
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
- 执行后重新打开 WeFlow。
## 说明
- 该发布页的同名资源会被后续构建覆盖,请勿将其视作长期归档版本。
- 如某个平台资源暂未生成,请进入[发布页]($RELEASE_PAGE)查看最新状态
EOF
update_release_notes() {
local attempts=5
local delay_seconds=2
local i
for ((i=1; i<=attempts; i++)); do
if gh release edit "$TAG" --repo "$REPO" --title "Daily Dev Build" --notes-file dev_release_notes.md --prerelease >/dev/null 2>&1; then
return 0
fi
if [ "$i" -lt "$attempts" ]; then
echo "Release update failed (attempt $i/$attempts), retry in ${delay_seconds}s..."
sleep "$delay_seconds"
fi
done
return 1
}
update_release_notes
source .github/scripts/release-utils.sh
RELEASE_REST_ID="$(wait_for_release_id "$REPO" "$TAG" 12 2)"
settle_release_state "$REPO" "$RELEASE_REST_ID" "$TAG" 12 2
print_release_state "$REPO" "$TAG"

View File

@@ -1,84 +0,0 @@
name: Issue Auto Assign
on:
issues:
types: [opened, edited, reopened]
permissions:
issues: write
jobs:
assign-by-platform:
runs-on: ubuntu-latest
steps:
- name: Assign issue by selected platform
uses: actions/github-script@v7
env:
ASSIGNEE_WINDOWS: ${{ vars.ISSUE_ASSIGNEE_WINDOWS }}
ASSIGNEE_MACOS: ${{ vars.ISSUE_ASSIGNEE_MACOS }}
ASSIGNEE_LINUX: ${{ vars.ISSUE_ASSIGNEE_LINUX || 'H3CoF6' }}
with:
script: |
const issue = context.payload.issue;
if (!issue) {
core.info("No issue payload.");
return;
}
const labels = (issue.labels || []).map((l) => l.name);
if (!labels.includes("type: bug")) {
core.info("Skip non-bug issue.");
return;
}
const body = issue.body || "";
const match = body.match(/###\s*(?:使用平台|平台|Platform)\s*\r?\n+([^\r\n]+)/i);
if (!match) {
core.info("No platform field found in issue body.");
return;
}
const rawPlatform = match[1].trim().toLowerCase();
let platformKey = null;
if (rawPlatform.includes("windows")) platformKey = "windows";
if (rawPlatform.includes("macos")) platformKey = "macos";
if (rawPlatform.includes("linux")) platformKey = "linux";
if (!platformKey) {
core.info(`Unrecognized platform value: ${rawPlatform}`);
return;
}
const parseAssignees = (value) =>
(value || "")
.split(",")
.map((v) => v.trim())
.filter(Boolean);
const assigneeMap = {
windows: parseAssignees(process.env.ASSIGNEE_WINDOWS),
macos: parseAssignees(process.env.ASSIGNEE_MACOS),
linux: parseAssignees(process.env.ASSIGNEE_LINUX),
};
const candidates = assigneeMap[platformKey] || [];
if (candidates.length === 0) {
core.info(`No assignee configured for platform: ${platformKey}`);
return;
}
const existing = new Set((issue.assignees || []).map((a) => a.login));
const toAdd = candidates.filter((u) => !existing.has(u));
if (toAdd.length === 0) {
core.info("All configured assignees already assigned.");
return;
}
await github.rest.issues.addAssignees({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
assignees: toAdd,
});
core.info(`Assigned issue #${issue.number} to: ${toAdd.join(", ")}`);

View File

@@ -1,432 +0,0 @@
name: Preview Nightly
on:
schedule:
# GitHub Actions schedule uses UTC. 16:00 UTC = 北京时间次日 00:00
- cron: "0 16 * * *"
workflow_dispatch:
concurrency:
group: preview-nightly-fixed-release
cancel-in-progress: true
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
FIXED_PREVIEW_TAG: nightly-preview
TARGET_BRANCH: main
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
should_build: ${{ steps.meta.outputs.should_build }}
preview_version: ${{ steps.meta.outputs.preview_version }}
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
fetch-depth: 0
- name: Install Node.js
uses: actions/setup-node@v5
with:
node-version: 24
cache: "npm"
- name: Decide whether to build and generate preview version
id: meta
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
git fetch origin main --depth=1
COMMITS_24H="$(git rev-list --count --since='24 hours ago' origin/main)"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
SHOULD_BUILD=true
elif [ "$COMMITS_24H" -gt 0 ]; then
SHOULD_BUILD=true
else
SHOULD_BUILD=false
fi
YEAR_2="$(TZ=Asia/Shanghai date +%y)"
YEARLY_RUN_COUNT=1
LAST_VERSION="$(gh release view "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --json body --jq '.body' 2>/dev/null | grep -Eo '0\.[0-9]{2}\.[0-9]+' | head -n 1 || true)"
if [[ "$LAST_VERSION" =~ ^0\.([0-9]{2})\.([0-9]+)$ ]]; then
LAST_YEAR="${BASH_REMATCH[1]}"
LAST_COUNT="${BASH_REMATCH[2]}"
if [ "$LAST_YEAR" = "$YEAR_2" ]; then
YEARLY_RUN_COUNT=$((LAST_COUNT + 1))
fi
fi
PREVIEW_VERSION="0.${YEAR_2}.${YEARLY_RUN_COUNT}"
echo "should_build=$SHOULD_BUILD" >> "$GITHUB_OUTPUT"
echo "preview_version=$PREVIEW_VERSION" >> "$GITHUB_OUTPUT"
echo "Preview version: $PREVIEW_VERSION (commits in last 24h on main: $COMMITS_24H, yearly count: $YEARLY_RUN_COUNT)"
- name: Recreate fixed preview prerelease
if: steps.meta.outputs.should_build == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
recreate_fixed_prerelease "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "$TARGET_BRANCH" "Preview Nightly Build" "预览版发布页"
preview-mac-arm64:
needs: prepare
if: needs.prepare.outputs.should_build == 'true'
runs-on: macos-14
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
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: Ensure mac key helpers are executable
shell: bash
run: |
set -euo pipefail
for file in \
resources/key/macos/universal/xkey_helper \
resources/key/macos/universal/image_scan_helper \
resources/key/macos/universal/xkey_helper_macos \
resources/key/macos/universal/libwx_key.dylib
do
if [ -f "$file" ]; then
chmod +x "$file"
ls -l "$file"
fi
done
- name: Set preview version
shell: bash
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
- name: Build Frontend & Type Check
shell: bash
run: |
npx tsc
npx vite build
- name: Package macOS arm64 preview artifacts
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
shell: bash
run: |
set -euo pipefail
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
if ! npx electron-builder --mac dmg zip --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}'; then
echo "::warning::DMG packaging failed (hdiutil instability on runner). Retrying with ZIP only."
npx electron-builder --mac zip --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}'
fi
- name: Upload macOS arm64 assets to fixed preview release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
assets=()
while IFS= read -r file; do
assets+=("$file")
done < <(find release -maxdepth 1 -type f | sort)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No release files found in ./release"
exit 1
fi
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
preview-linux:
needs: prepare
if: needs.prepare.outputs.should_build == 'true'
runs-on: ubuntu-latest
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
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: Set preview version
shell: bash
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
- name: Build Frontend & Type Check
shell: bash
run: |
npx tsc
npx vite build
- name: Package Linux preview artifacts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
npx electron-builder --linux --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-linux.${ext}'
- name: Upload Linux assets to fixed preview release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
assets=()
while IFS= read -r file; do
assets+=("$file")
done < <(find release -maxdepth 1 -type f | sort)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No release files found in ./release"
exit 1
fi
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
preview-win-x64:
needs: prepare
if: needs.prepare.outputs.should_build == 'true'
runs-on: windows-latest
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
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: Set preview version
shell: bash
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
- name: Build Frontend & Type Check
shell: bash
run: |
npx tsc
npx vite build
- name: Package Windows x64 preview artifacts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
npx electron-builder --win nsis --x64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-x64-Setup.${ext}'
- name: Upload Windows x64 assets to fixed preview release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
assets=()
while IFS= read -r file; do
assets+=("$file")
done < <(find release -maxdepth 1 -type f | sort)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No release files found in ./release"
exit 1
fi
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
preview-win-arm64:
needs: prepare
if: needs.prepare.outputs.should_build == 'true'
runs-on: windows-latest
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
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: Set preview version
shell: bash
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
- name: Build Frontend & Type Check
shell: bash
run: |
npx tsc
npx vite build
- name: Package Windows arm64 preview artifacts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
npx electron-builder --win nsis --arm64 --publish never '--config.publish.channel=preview-arm64' '--config.artifactName=${productName}-preview-arm64-Setup.${ext}'
- name: Upload Windows arm64 assets to fixed preview release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
assets=()
while IFS= read -r file; do
assets+=("$file")
done < <(find release -maxdepth 1 -type f | sort)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No release files found in ./release"
exit 1
fi
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
update-preview-release-notes:
needs:
- prepare
- preview-mac-arm64
- preview-linux
- preview-win-x64
- preview-win-arm64
if: needs.prepare.outputs.should_build == 'true' && always()
runs-on: ubuntu-latest
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
fetch-depth: 1
- name: Update preview release notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
TAG="${FIXED_PREVIEW_TAG:-}"
if [ -z "$TAG" ]; then
echo "FIXED_PREVIEW_TAG is empty, abort."
exit 1
fi
CURRENT_PREVIEW_VERSION="${{ needs.prepare.outputs.preview_version }}"
REPO="$GITHUB_REPOSITORY"
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
echo "Using release tag: $TAG"
if ! gh api "repos/$REPO/releases/tags/$TAG" >/dev/null 2>&1; then
echo "Release $TAG not found (possibly all publish jobs failed), skip notes update."
exit 0
fi
ASSETS_JSON="$(gh api "repos/$REPO/releases/tags/$TAG")"
pick_asset() {
local pattern="$1"
echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""'
}
WINDOWS_ASSET="$(pick_asset "x64.*[.]exe$")"
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="$(pick_asset "arm64.*[.]exe$")"
MAC_ASSET="$(pick_asset "[.]dmg$")"
if [ -z "$MAC_ASSET" ]; then
MAC_ASSET="$(pick_asset "[.]zip$")"
fi
LINUX_TAR_ASSET="$(pick_asset "[.]tar[.]gz$")"
LINUX_APPIMAGE_ASSET="$(pick_asset "[.]AppImage$")"
build_link() {
local name="$1"
if [ -n "$name" ]; then
echo "https://github.com/$REPO/releases/download/$TAG/$name"
fi
}
WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
MAC_URL="$(build_link "$MAC_ASSET")"
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
cat > preview_release_notes.md <<EOF
## Preview Nightly 说明
- 该版本为 **预览版**,用于提前体验即将发布的功能与修复。
- 可能包含尚未完全稳定的改动,不建议长期使用
- 当前版本号:\`$CURRENT_PREVIEW_VERSION\`
## 下载
- Windows x64: [点击下载](${WINDOWS_URL:-$RELEASE_PAGE})
- Windows arm64: [点击下载](${WINDOWS_ARM64_URL:-$RELEASE_PAGE})
- macOSApple Silicon: [点击下载](${MAC_URL:-$RELEASE_PAGE})
- Linux (.tar.gz): [点击下载](${LINUX_TAR_URL:-$RELEASE_PAGE})
- Linux (.AppImage): [点击下载](${LINUX_APPIMAGE_URL:-$RELEASE_PAGE})
## macOS 安装提示
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
- 执行后重新打开 WeFlow。
> 如某个平台链接暂未生成,请前往[发布页]($RELEASE_PAGE)查看最新资源
EOF
update_release_notes() {
local attempts=5
local delay_seconds=2
local i
for ((i=1; i<=attempts; i++)); do
if gh release edit "$TAG" --repo "$REPO" --title "Preview Nightly Build" --notes-file preview_release_notes.md --prerelease >/dev/null 2>&1; then
return 0
fi
if [ "$i" -lt "$attempts" ]; then
echo "Release update failed (attempt $i/$attempts), retry in ${delay_seconds}s..."
sleep "$delay_seconds"
fi
done
return 1
}
update_release_notes
source .github/scripts/release-utils.sh
RELEASE_REST_ID="$(wait_for_release_id "$REPO" "$TAG" 12 2)"
settle_release_state "$REPO" "$RELEASE_REST_ID" "$TAG" 12 2
print_release_state "$REPO" "$TAG"

View File

@@ -1,391 +0,0 @@
name: Build and Release
on:
push:
tags:
- "v*"
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
jobs:
release-mac-arm64:
runs-on: macos-14
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 --ignore-scripts
- name: Ensure mac key helpers are executable
shell: bash
run: |
set -euo pipefail
for file in \
resources/key/macos/universal/xkey_helper \
resources/key/macos/universal/image_scan_helper \
resources/key/macos/universal/xkey_helper_macos \
resources/key/macos/universal/libwx_key.dylib
do
if [ -f "$file" ]; then
chmod +x "$file"
ls -l "$file"
fi
done
- name: Sync version with tag
shell: bash
run: |
VERSION=${GITHUB_REF_NAME#v}
echo "Syncing package.json version to $VERSION"
node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
- name: Build Frontend & Type Check
shell: bash
run: |
npx tsc
npx vite build
- name: Package and Publish macOS arm64 (unsigned DMG + ZIP)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_IDENTITY_AUTO_DISCOVERY: "false"
shell: bash
run: |
set -euo pipefail
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
if ! npx electron-builder --mac dmg zip --arm64 --publish always '--config.npmRebuild=false' '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}'; then
echo "::warning::DMG packaging failed (hdiutil instability on runner). Retrying with ZIP only."
npx electron-builder --mac zip --arm64 --publish always '--config.npmRebuild=false' '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}'
fi
- name: Inject minimumVersion into latest yml
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
TAG=${GITHUB_REF_NAME}
REPO=${{ github.repository }}
MINIMUM_VERSION="4.1.7"
wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null
for YML_FILE in latest-mac.yml latest-arm64-mac.yml; do
if ! retry_cmd 5 3 gh release download "$TAG" --repo "$REPO" --pattern "$YML_FILE" --output "/tmp/$YML_FILE"; then
echo "Skip $YML_FILE because download failed after retries."
continue
fi
if ! grep -q 'minimumVersion' "/tmp/$YML_FILE"; then
echo "minimumVersion: $MINIMUM_VERSION" >> "/tmp/$YML_FILE"
fi
retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" "/tmp/$YML_FILE" --clobber
done
release-linux:
runs-on: ubuntu-latest
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: Ensure linux key helper is executable
shell: bash
run: |
[ -f "resources/key/linux/x64/xkey_helper" ] && chmod +x "resources/key/linux/x64/xkey_helper" || echo "File not found"
- name: Sync version with tag
shell: bash
run: |
VERSION=${GITHUB_REF_NAME#v}
echo "Syncing package.json version to $VERSION"
node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
- name: Build Frontend & Type Check
shell: bash
run: |
npx tsc
npx vite build
- name: Package and Publish Linux
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npx electron-builder --linux --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}'
- name: Inject minimumVersion into latest yml
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
TAG=${GITHUB_REF_NAME}
REPO=${{ github.repository }}
MINIMUM_VERSION="4.1.7"
wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null
retry_cmd 5 3 gh release download "$TAG" --repo "$REPO" --pattern "latest-linux.yml" --output "/tmp/latest-linux.yml" || true
if [ -f /tmp/latest-linux.yml ] && ! grep -q 'minimumVersion' /tmp/latest-linux.yml; then
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-linux.yml
retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" /tmp/latest-linux.yml --clobber
fi
release:
runs-on: windows-latest
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"
node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
- name: Build Frontend & Type Check
shell: bash
run: |
npx tsc
npx vite build
- name: Package and Publish
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npx electron-builder --win nsis --x64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}' '--config.artifactName=${productName}-${version}-x64-Setup.${ext}'
- name: Inject minimumVersion into latest yml
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
TAG=${GITHUB_REF_NAME}
REPO=${{ github.repository }}
MINIMUM_VERSION="4.1.7"
wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null
retry_cmd 5 3 gh release download "$TAG" --repo "$REPO" --pattern "latest.yml" --output "/tmp/latest.yml" || true
if [ -f /tmp/latest.yml ] && ! grep -q 'minimumVersion' /tmp/latest.yml; then
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest.yml
retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" /tmp/latest.yml --clobber
fi
release-windows-arm64:
runs-on: windows-latest
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"
node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
- name: Build Frontend & Type Check
shell: bash
run: |
npx tsc
npx vite build
- name: Package and Publish Windows arm64
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npx electron-builder --win nsis --arm64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}' '--config.publish.channel=latest-arm64' '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}'
- name: Inject minimumVersion into latest yml
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
TAG=${GITHUB_REF_NAME}
REPO=${{ github.repository }}
MINIMUM_VERSION="4.1.7"
wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null
retry_cmd 5 3 gh release download "$TAG" --repo "$REPO" --pattern "latest-arm64.yml" --output "/tmp/latest-arm64.yml" || true
if [ -f /tmp/latest-arm64.yml ] && ! grep -q 'minimumVersion' /tmp/latest-arm64.yml; then
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-arm64.yml
retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" /tmp/latest-arm64.yml --clobber
fi
update-release-notes:
runs-on: ubuntu-latest
needs:
- release-mac-arm64
- release-linux
- release
- release-windows-arm64
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Generate release notes with platform download links
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
TAG="$GITHUB_REF_NAME"
REPO="$GITHUB_REPOSITORY"
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null
capture_cmd_with_retry ASSETS_JSON 8 3 gh release view "$TAG" --repo "$REPO" --json assets
pick_asset() {
local pattern="$1"
echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""'
}
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$")"
if [ -z "$MAC_ASSET" ]; then
MAC_ASSET="$(pick_asset "arm64\\.zip$")"
fi
LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")"
LINUX_APPIMAGE_ASSET="$(pick_asset "\\.AppImage$")"
build_link() {
local name="$1"
if [ -n "$name" ]; then
echo "https://github.com/$REPO/releases/download/$TAG/$name"
fi
}
WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
MAC_URL="$(build_link "$MAC_ASSET")"
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
cat > release_notes.md <<EOF
## 更新日志
修复了一些已知问题
## 查看更多日志/获取最新动态
[点击加入 Telegram 频道](https://t.me/weflow_cc)
## 下载
- Windows x64Win10+: [点击下载](${WINDOWS_URL:-$RELEASE_PAGE})
- Windows arm64: [点击下载](${WINDOWS_ARM64_URL:-$RELEASE_PAGE})
- macOSM系列芯片: [点击下载](${MAC_URL:-$RELEASE_PAGE})
- Linux (.tar.gz): [点击下载](${LINUX_TAR_URL:-$RELEASE_PAGE})
- Linux (.AppImage): [点击下载](${LINUX_APPIMAGE_URL:-$RELEASE_PAGE})
## macOS 安装提示
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
- 执行后重新打开 WeFlow。
> 如果某个平台链接暂时未生成,可进入[完整发布页]($RELEASE_PAGE)查看全部资源
EOF
retry_cmd 5 3 gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md
deploy-aur:
runs-on: ubuntu-latest
needs: [release-linux]
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Check AUR credentials
id: aur-credentials
shell: bash
env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
run: |
if [ -z "${AUR_SSH_PRIVATE_KEY}" ]; then
echo "::notice::AUR_SSH_PRIVATE_KEY is not configured; skipping AUR publish."
echo "enabled=false" >> "$GITHUB_OUTPUT"
else
echo "enabled=true" >> "$GITHUB_OUTPUT"
fi
- name: Checkout code
if: steps.aur-credentials.outputs.enabled == 'true'
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Update PKGBUILD version
if: steps.aur-credentials.outputs.enabled == 'true'
run: |
NEW_VER=$(echo "${{ github.ref_name }}" | sed 's/^v//')
sed -i "s/^pkgver=.*/pkgver=${NEW_VER}/" resources/installer/linux/PKGBUILD
sed -i "s/^pkgrel=.*/pkgrel=1/" resources/installer/linux/PKGBUILD
- name: Publish AUR package
if: steps.aur-credentials.outputs.enabled == 'true'
uses: KSXGitHub/github-actions-deploy-aur@master
with:
pkgname: weflow
pkgbuild: resources/installer/linux/PKGBUILD
updpkgsums: true
assets: |
resources/installer/linux/weflow.desktop
resources/installer/linux/icon.png
resources/installer/linux/.gitignore
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
commit_username: H3CoF6
commit_email: h3cof6@gmail.com
ssh_keyscan_types: ed25519

80
.gitignore vendored
View File

@@ -1,80 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Dependencies
node_modules
dist
dist-electron
dist-ssr
*.local
test/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Build output
out
release
# Database
*.db
*.db-shm
*.db-wal
# Environment
.env
.env.local
.env.production
# OS
Thumbs.db
# Electron dev cache
.electron/
.cache/
# 忽略 Visual Studio 临时文件夹
.vs/
# 忽略 IntelliSense 缓存文件
*.ipch
*.aps
wcdb/
!resources/wcdb/
!resources/wcdb/**
xkey/
server/
*info
chatlab-format.md
*.bak
AGENTS.md
AGENT.md
.claude/
CLAUDE.md
.agents/
resources/wx_send
概述.md
pnpm-lock.yaml
/pnpm-workspace.yaml
wechat-research-site
.codex
weflow-web-offical
/Wedecrypt
/scripts/syncwcdb.py
/scripts/syncWedecrypt.py

View File

@@ -1,23 +0,0 @@
title = "Gitleaks Config"
[extend]
# 继承默认规则
useDefault = true
# 排除误报路径
[[rules]]
id = "curl-auth-header"
[rules.allowlist]
paths = [
'''docs/HTTP-API\.md'''
]
regexes = [
'''YOUR_TOKEN'''
]
[[rules]]
id = "generic-api-key"
[rules.allowlist]
paths = [
'''src/pages/ChatPage\.tsx'''
]

1
.npmrc
View File

@@ -1 +0,0 @@
registry=https://registry.npmjs.org

141
LICENSE
View File

@@ -1,141 +0,0 @@
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
Section 1 Definitions.
a. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image.
b. Adapter's License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License.
c. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.
d. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements.
e. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material.
f. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License.
g. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license.
h. Licensor means the individual(s) or entity(ies) granting rights under this Public License.
i. NonCommercial means not intended for or directed towards commercial advantage or monetary compensation. For purposes of this Public License, the exchange of the Licensed Material for other material subject to Copyright and Similar Rights by digital file-sharing or similar means is NonCommercial provided there is no payment of monetary compensation in connection with the exchange.
j. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them.
k. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world.
l. You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning.
Section 2 Scope.
a. License grant.
1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to:
A. reproduce and Share the Licensed Material, in whole or in part, for NonCommercial purposes only; and
B. produce, reproduce, and Share Adapted Material for NonCommercial purposes only.
2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions.
3. Term. The term of this Public License is specified in Section 6(a).
4. Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material.
5. Downstream recipients.
A. Offer from the Licensor Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License.
B. Additional offer from the Licensor Adapted Material. Every recipient of Adapted Material from You automatically receives an offer from the Licensor to exercise the Licensed Rights in the Adapted Material under the conditions of the Adapter's License You apply.
C. No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material.
6. No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i).
b. Other rights.
1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this Public License.
3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties, including when the Licensed Material is used for other than NonCommercial purposes.
Section 3 License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the following conditions.
a. Attribution.
1. If You Share the Licensed Material (including in modified form), You must:
A. retain the following if it is supplied by the Licensor with the Licensed Material:
i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated);
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of warranties;
v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable;
B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and
C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License.
2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information.
3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable.
4. If You Share Adapted Material You produce, the Adapter's License You apply must not prevent recipients of the Adapted Material from complying with this Public License.
b. ShareAlike.
In addition to the conditions in Section 3(a), if You Share Adapted Material You produce, the following conditions also apply.
1. The Adapter's License You apply must be a Creative Commons license with the same License Elements, this version or later, or a BY-NC-SA Compatible License.
2. You must include the text of, or the URI or hyperlink to, the Adapter's License You apply. You may satisfy this condition in any reasonable manner based on the medium, means, and context in which You Share Adapted Material.
3. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, Adapted Material that restrict exercise of the rights granted under the Adapter's License You apply.
Section 4 Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database for NonCommercial purposes only;
b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material; and
c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights and Sui Generis Database Rights apply to Your use of the Licensed Material.
Section 5 Disclaimer of Warranties and Limitation of Liability.
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability.
Section 6 Term and Termination.
a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically.
b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or
2. upon express reinstatement by the Licensor.
For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.
c. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License.
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public License.
Section 7 Other Terms and Conditions.
a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed.
b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License.
Section 8 Interpretation.
a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License.
b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions.
c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor.
d. Nothing in this Public License constitutes or may be interpreted as a limitation upon or waiver of any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority.

View File

@@ -2,6 +2,8 @@
WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告。
> 📢 [加入电报群](https://t.me/weflow_cc)以下载和获取最新信息
---
**WeFlow** is a fully local tool for viewing, analyzing, and exporting WeChat chat history in real time. It generates unique analysis reports based on your chat history.
@@ -16,7 +18,6 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
<a href="https://github.com/hicccc77/WeFlow/issues"><img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat&label=Issues&labelColor=1F2937&color=D97706" alt="Issues"></a>
<a href="https://github.com/hicccc77/WeFlow/releases"><img src="https://img.shields.io/github/downloads/hicccc77/WeFlow/total?style=flat&label=Downloads&labelColor=1F2937&color=059669" alt="Downloads"></a>
<br><br>
<a href="https://t.me/weflow_cc"><img src="https://img.shields.io/badge/Telegram-频道-1D9BF0?style=flat&logo=telegram&logoColor=white&labelColor=1F2937&color=1D9BF0" alt="Telegram Channel" style="height: 22px; vertical-align: middle;"></a>
<a href="https://www.star-history.com/hicccc77/weflow"><img src="https://api.star-history.com/badge?repo=hicccc77/WeFlow&theme=dark" alt="Star History Rank" style="height: 32px; vertical-align: middle;"></a>
</p>

View File

@@ -1,804 +0,0 @@
# WeFlow HTTP API / Push 文档
WeFlow 提供本地 HTTP API已支持GET 和 POST请求便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件。
## 启用方式
在应用设置页启用 `API 服务`
- 默认监听地址:`127.0.0.1`
- 默认端口:`5031`
- 基础地址:`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|POST /health`
- `GET|POST /api/v1/health`
- `GET|POST /api/v1/push/messages`
- `GET|POST /api/v1/messages`
- `GET|POST /api/v1/sessions`
- `GET /api/v1/sessions/:id/messages` (ChatLab Pull)
- `GET|POST /api/v1/contacts`
- `GET|POST /api/v1/group-members`
- `GET|POST /api/v1/media/*`
---
## 1. 健康检查
**请求**
```http
GET /health
```
```http
GET /api/v1/health
```
**响应**
```json
{
"status": "ok"
}
```
---
## 2. 主动推送
通过 SSE 长连接接收新消息事件,端口与 HTTP API 共用。
**请求**
```http
GET /api/v1/push/messages
```
### 说明
- 需要先在设置页开启 `HTTP API 服务`
- 同时需要开启 `主动推送`
- 响应类型为 `text/event-stream`
- 事件名包含 `message.new``message.revoke`
- 建议接收端按 `event + rawid` 去重
### 事件字段
- `event`
- `sessionId`
- `rawid`
- `avatarUrl`
- `sourceName`
- `groupName`(仅群聊)
- `content`
- `timestamp`(消息时间,秒级 Unix 时间戳)
### 示例
```bash
curl -N "http://127.0.0.1:5031/api/v1/push/messages?access_token=YOUR_TOKEN
```
示例事件:
```text
event: message.new
data: {"event":"message.new","sessionId":"xxx@chatroom","sessionType":"group","rawid":"1234567890123456789","avatarUrl":"https://example.com/group.jpg","sourceName":"李四","groupName":"项目群","content":"[图片]","timestamp":1760000123}
```
撤回事件示例:
```text
event: message.revoke
data: {"event":"message.revoke","sessionId":"wxid_xxx","sessionType":"other","rawid":"1234567890123456789","avatarUrl":"https://example.com/avatar.jpg","sourceName":"张三","content":"对方撤回了一条消息rawid1234567890123456789 内容为“你好”","timestamp":1760000180}
```
---
## 3. 获取消息
> 当使用 POST 时,请将参数放在 JSON Body 中Content-Type: application/json
读取指定会话的消息,支持原始 JSON 和 ChatLab 格式。
**请求**
```http
GET /api/v1/messages
```
### 参数
| 参数 | 类型 | 必填 | 说明 |
| --------- | ------ | ---- | ----------------------------------------------------- |
| `talker` | string | 是 | 会话 ID。私聊通常是对方 `wxid`,群聊是 `xxx@chatroom` |
| `limit` | number | 否 | 返回条数,默认 `100`,范围 `1~10000` |
| `offset` | number | 否 | 分页偏移,默认 `0` |
| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或时间戳 |
| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或时间戳 |
| `keyword` | string | 否 | 基于消息显示文本过滤 |
| `chatlab` | string | 否 | `1/true` 时输出 ChatLab 格式 |
| `format` | string | 否 | `json``chatlab` |
| `media` | string | 否 | `1/true` 时导出媒体并返回媒体地址,兼容别名 `meiti` |
| `image` | string | 否 | 在 `media=1` 时控制图片导出,兼容别名 `tupian` |
| `voice` | string | 否 | 在 `media=1` 时控制语音导出,兼容别名 `vioce` |
| `video` | string | 否 | 在 `media=1` 时控制视频导出 |
| `emoji` | string | 否 | 在 `media=1` 时控制表情导出 |
### 示例
```bash
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=20"
curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&chatlab=1"
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=20260131"
curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&voice=0&video=0&emoji=0"
```
### JSON 响应字段
顶层字段:
- `success`
- `talker`
- `count`
- `hasMore`
- `media.enabled`
- `media.exportPath`
- `media.count`
- `messages`
单条消息字段:
- `localId`
- `serverId`
- `localType`
- `createTime`
- `isSend`
- `senderUsername`
- `content`
- `rawContent`
- `parsedContent`
- `replyToMessageId`(引用回复目标消息的 `serverId`,仅引用消息返回)
- `quote`(引用消息快照,包含被引用消息的 ID、发送者、内容和类型
- `mediaType`
- `mediaFileName`
- `mediaUrl`
- `mediaLocalPath`
**示例响应**
```json
{
"success": true,
"talker": "xxx@chatroom",
"count": 3,
"hasMore": true,
"media": {
"enabled": true,
"exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
"count": 1
},
"messages": [
{
"localId": 123,
"serverId": "6116895530414915131",
"localType": 1,
"createTime": 1738713600,
"isSend": 0,
"senderUsername": "wxid_member",
"content": "你好",
"rawContent": "你好",
"parsedContent": "你好"
},
{
"localId": 125,
"serverId": "6116895530414915133",
"localType": 244813135921,
"createTime": 1738713700,
"isSend": 0,
"senderUsername": "wxid_member",
"content": "收到",
"rawContent": "<msg>...</msg>",
"parsedContent": "收到",
"replyToMessageId": "6116895530414915131",
"quote": {
"platformMessageId": "6116895530414915131",
"sender": "wxid_other",
"accountName": "张三",
"content": "你好",
"type": 0
}
},
{
"localId": 124,
"localType": 3,
"createTime": 1738713660,
"isSend": 0,
"senderUsername": "wxid_member",
"content": "[图片]",
"mediaType": "image",
"mediaFileName": "abc123.jpg",
"mediaUrl": "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/images/abc123.jpg",
"mediaLocalPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\xxx@chatroom\\images\\abc123.jpg"
}
]
}
```
### ChatLab 响应
`chatlab=1``format=chatlab` 时,返回 ChatLab 结构:
- `chatlab.version`
- `chatlab.exportedAt`
- `chatlab.generator`
- `meta.name`
- `meta.platform`
- `meta.type`
- `meta.groupId`
- `meta.groupAvatar`
- `meta.ownerId`
- `members[].platformId`
- `members[].accountName`
- `members[].groupNickname`
- `members[].avatar`
- `messages[].sender`
- `messages[].accountName`
- `messages[].groupNickname`
- `messages[].timestamp`
- `messages[].type`
- `messages[].content`
- `messages[].platformMessageId`
- `messages[].replyToMessageId`
- `messages[].mediaPath`
群聊里 `groupNickname` 会优先来自群成员群昵称;若源数据缺失,则回退为空或展示名。
---
## 4. 获取会话列表
> 当使用 POST 时,请将参数放在 JSON Body 中Content-Type: application/json
**请求**
```http
GET /api/v1/sessions
```
### 参数
| 参数 | 类型 | 必填 | 说明 |
| --------- | ------ | ---- | -------------------------------- |
| `keyword` | string | 否 | 匹配 `username``displayName` |
| `limit` | number | 否 | 默认 `100` |
### 响应字段
- `success`
- `count`
- `sessions[].username`
- `sessions[].displayName`
- `sessions[].type`
- `sessions[].lastTimestamp`
- `sessions[].unreadCount`
**示例响应**
```json
{
"success": true,
"count": 1,
"sessions": [
{
"username": "xxx@chatroom",
"displayName": "项目群",
"type": 2,
"lastTimestamp": 1738713600,
"unreadCount": 0
}
]
}
```
---
## 4.1 获取会话列表ChatLab 格式)
`format=chatlab` 时,返回 ChatLab Pull 协议兼容格式,可直接作为 ChatLab 远程数据源。
**请求**
```http
GET /api/v1/sessions?format=chatlab
```
### 参数
| 参数 | 类型 | 必填 | 说明 |
| --------- | ------ | ---- | -------------------------------- |
| `format` | string | 是 | 设为 `chatlab` |
| `keyword` | string | 否 | 匹配 `username``displayName` |
| `limit` | number | 否 | 默认 `100` |
### 响应
```json
{
"sessions": [
{
"id": "xxx@chatroom",
"name": "项目群",
"platform": "wechat",
"type": "group",
"messageCount": 58000,
"lastMessageAt": 1738713600
}
]
}
```
| 字段 | 说明 |
| --------------- | ----------------------------------- |
| `id` | 会话 ID微信 username |
| `name` | 会话显示名称 |
| `platform` | 固定 `wechat` |
| `type` | `group`(群聊)或 `private`(私聊) |
| `messageCount` | 消息数量(估算值,可能不精确) |
| `lastMessageAt` | 最后消息的秒级 Unix 时间戳 |
---
## 4.2 拉取会话消息ChatLab Pull
返回 ChatLab 标准格式的聊天数据,支持增量拉取和分页。
**请求**
```http
GET /api/v1/sessions/:id/messages
```
### 参数
| 参数 | 类型 | 必填 | 说明 |
| -------- | ------ | ---- | ---------------------------------------- |
| `:id` | string | 是 | 会话 IDPath 参数) |
| `since` | number | 否 | 秒级 Unix 时间戳,仅返回此时间之后的消息 |
| `end` | number | 否 | 秒级 Unix 时间戳,时间上界 |
| `limit` | number | 否 | 单次返回上限,默认且最大 `5000` |
| `offset` | number | 否 | 分页偏移,默认 `0` |
### 响应
返回 ChatLab 标准 JSON 格式,外加 `sync` 分页块:
```json
{
"chatlab": {
"version": "0.0.2",
"exportedAt": 1738713600,
"generator": "WeFlow"
},
"meta": {
"name": "项目群",
"platform": "wechat",
"type": "group",
"groupId": "xxx@chatroom",
"ownerId": "wxid_xxx"
},
"members": [
{
"platformId": "wxid_a",
"accountName": "张三",
"groupNickname": "产品",
"avatar": "https://example.com/avatar.jpg"
}
],
"messages": [
{
"sender": "wxid_a",
"accountName": "张三",
"timestamp": 1738713600,
"type": 0,
"content": "你好",
"platformMessageId": "123456"
}
],
"sync": {
"hasMore": true,
"nextSince": 1738713600,
"nextOffset": 5000,
"watermark": 1738714000
}
}
```
### sync 块
| 字段 | 说明 |
| ------------ | -------------------------------- |
| `hasMore` | 是否还有更多数据 |
| `nextSince` | 下次请求的 `since` 值 |
| `nextOffset` | 下次请求的 `offset` 值 |
| `watermark` | 本次拉取的时间上界(秒级时间戳) |
**ChatLab 对接方式**:在 ChatLab 设置中添加远程数据源,`baseUrl``http://127.0.0.1:5031/api/v1`Token 填 WeFlow 中配置的 API Token。
---
## 5. 获取联系人列表
> 当使用 POST 时,请将参数放在 JSON Body 中Content-Type: application/json
**请求**
```http
GET /api/v1/contacts
```
### 参数
| 参数 | 类型 | 必填 | 说明 |
| --------- | ------ | ---- | ---------------------------------------------------- |
| `keyword` | string | 否 | 匹配 `username``nickname``remark``displayName` |
| `limit` | number | 否 | 默认 `100` |
### 响应字段
- `success`
- `count`
- `contacts[].username`
- `contacts[].displayName`
- `contacts[].remark`
- `contacts[].nickname`
- `contacts[].alias`
- `contacts[].avatarUrl`
- `contacts[].type`
**示例响应**
```json
{
"success": true,
"count": 1,
"contacts": [
{
"username": "wxid_xxx",
"displayName": "张三",
"remark": "客户张三",
"nickname": "张三",
"alias": "zhangsan",
"avatarUrl": "https://example.com/avatar.jpg",
"type": "friend"
}
]
}
```
---
## 6. 获取群成员列表
> 当使用 POST 时,请将参数放在 JSON Body 中Content-Type: application/json
返回群成员的 `wxid`、群昵称、备注、微信号等信息。
**请求**
```http
GET /api/v1/group-members
```
### 参数
| 参数 | 类型 | 必填 | 说明 |
| ---------------------- | ------ | ---- | ------------------------------- |
| `chatroomId` | string | 是 | 群 ID兼容使用 `talker` 传入 |
| `includeMessageCounts` | string | 否 | `1/true` 时附带成员发言数 |
| `withCounts` | string | 否 | `includeMessageCounts` 的别名 |
| `forceRefresh` | string | 否 | `1/true` 时跳过内存缓存强制刷新 |
### 响应字段
- `success`
- `chatroomId`
- `count`
- `fromCache`
- `updatedAt`
- `members[].wxid`
- `members[].displayName`
- `members[].nickname`
- `members[].remark`
- `members[].alias`
- `members[].groupNickname`
- `members[].avatarUrl`
- `members[].isOwner`
- `members[].isFriend`
- `members[].messageCount`
**示例请求**
```bash
curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom"
curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1&forceRefresh=1"
```
**示例响应**
```json
{
"success": true,
"chatroomId": "xxx@chatroom",
"count": 2,
"fromCache": false,
"updatedAt": 1760000000000,
"members": [
{
"wxid": "wxid_member_a",
"displayName": "客户A",
"nickname": "阿甲",
"remark": "客户A",
"alias": "kehua",
"groupNickname": "甲方",
"avatarUrl": "https://example.com/a.jpg",
"isOwner": true,
"isFriend": true,
"messageCount": 128
},
{
"wxid": "wxid_member_b",
"displayName": "李四",
"nickname": "李四",
"remark": "",
"alias": "",
"groupNickname": "",
"avatarUrl": "",
"isOwner": false,
"isFriend": false,
"messageCount": 0
}
]
}
```
说明:
- `displayName` 是当前应用内的主展示名。
- `groupNickname` 是成员在该群里的群昵称。
- `remark` 是你对该联系人的备注。
- `alias` 是微信号。
- 当微信源数据里没有群昵称时,`groupNickname` 会为空。
---
## 7. 朋友圈接口
### 7.1 获取朋友圈时间线
```http
GET /api/v1/sns/timeline
```
参数:
| 参数 | 类型 | 必填 | 说明 |
| ----------- | ------ | ---- | ------------------------------------------------------------ |
| `limit` | number | 否 | 返回数量,默认 20范围 `1~200` |
| `offset` | number | 否 | 偏移量,默认 0 |
| `usernames` | string | 否 | 发布者过滤,逗号分隔,如 `wxid_a,wxid_b` |
| `keyword` | string | 否 | 关键词过滤(正文) |
| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 |
| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 |
| `media` | number | 否 | 是否返回可直接访问的媒体地址,默认 `1` |
| `replace` | number | 否 | `media=1` 时,是否用解析地址覆盖 `media.url/thumb`,默认 `1` |
| `inline` | number | 否 | `media=1` 时,是否内联返回 `data:` URL默认 `0` |
示例:
```bash
curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=5"
curl "http://127.0.0.1:5031/api/v1/sns/timeline?usernames=wxid_a,wxid_b&keyword=旅行"
curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=3&media=1&replace=1"
curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=3&media=1&inline=1"
```
媒体字段说明(`media=1`
- `media[].url/thumb`:你应该优先直接使用的字段。
- `replace=1`(默认)时,`media[].url/thumb` 会直接被替换成可访问地址,等价于 `resolvedUrl/resolvedThumbUrl`
- `replace=0` 时,`media[].url/thumb` 仍保留微信原始地址;这时再结合下面的 `raw/proxy/resolved` 字段自己决定用哪一个。
- `media[].rawUrl/rawThumb`:原始朋友圈地址
- `media[].proxyUrl/proxyThumbUrl`:可直接访问的代理地址
- `media[].resolvedUrl/resolvedThumbUrl`:最终可用地址(`inline=1` 时可能是 `data:` URL
- `media[].token/key/encIdx`:微信源数据里的访问/解密参数。通常不需要你自己处理;如果你手动调用 `/api/v1/sns/media/proxy`,把当前条目的 `url``key` 原样传回即可。
- `media[].livePhoto`:实况图的视频部分。外层 `media[].url/thumb` 仍是封面图,`livePhoto` 内部会再提供一组自己的 `url/thumb/raw*/proxy*/resolved*` 字段。
- `media=0` 时,不会补充 `raw*/proxy*/resolved*`,接口只返回原始 `url/thumb` 以及源字段(如 `key/token/encIdx`)。
### 7.2 获取朋友圈发布者
```http
GET /api/v1/sns/usernames
```
### 7.3 获取朋友圈导出统计
```http
GET /api/v1/sns/export/stats
```
参数:
| 参数 | 类型 | 必填 | 说明 |
| ------ | ------ | ---- | ---------------------------- |
| `fast` | number | 否 | `1` 使用快速统计(优先缓存) |
### 7.4 朋友圈媒体代理
```http
GET /api/v1/sns/media/proxy
```
参数:
| 参数 | 类型 | 必填 | 说明 |
| ----- | ------------- | ---- | ------------------------ |
| `url` | string | 是 | 媒体原始 URL |
| `key` | string/number | 否 | 解密 key部分资源需要 |
### 7.5 导出朋友圈
```http
POST /api/v1/sns/export
Content-Type: application/json
```
Body 示例:
```json
{
"outputDir": "C:\\Users\\Alice\\Desktop\\sns-export",
"format": "json",
"usernames": "wxid_a,wxid_b",
"keyword": "旅行",
"exportMedia": true,
"exportImages": true,
"exportLivePhotos": true,
"exportVideos": true,
"start": "20250101",
"end": "20251231"
}
```
`format` 支持:`json``html``arkmejson`(兼容写法:`arkme-json`)。
### 7.6 朋友圈防删开关
```http
GET /api/v1/sns/block-delete/status
POST /api/v1/sns/block-delete/install
POST /api/v1/sns/block-delete/uninstall
```
### 7.7 删除单条朋友圈
```http
DELETE /api/v1/sns/post/{postId}
```
---
## 8. 访问导出媒体
> 当使用 POST 时,请将参数放在 JSON Body 中Content-Type: application/json
通过消息接口启用 `media=1` 后,接口会先把图片、语音、视频、表情导出到本地缓存目录,再返回可访问的 HTTP 地址。
**请求**
```http
GET /api/v1/media/{relativePath}
```
### 示例
```bash
curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/images/abc123.jpg"
curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/voices/voice_100.wav"
curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/videos/video_200.mp4"
curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif"
```
### 支持的 Content-Type
| 扩展名 | Content-Type |
| ---------------- | ------------ |
| `.png` | `image/png` |
| `.jpg` / `.jpeg` | `image/jpeg` |
| `.gif` | `image/gif` |
| `.webp` | `image/webp` |
| `.wav` | `audio/wav` |
| `.mp3` | `audio/mpeg` |
| `.mp4` | `video/mp4` |
常见错误响应:
```json
{
"error": "Media not found"
}
```
---
## 9. 使用示例
### PowerShell
```powershell
$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
# 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
```python
import requests
BASE_URL = "http://127.0.0.1:5031"
headers = {"Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json"}
# 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},
headers=headers
).json()
```
---
## 10. 注意事项
1. API 仅监听本机 `127.0.0.1`,不对外网开放。
2. 使用前需要先在 WeFlow 中完成数据库连接。
3. `start``end` 支持 `YYYYMMDD` 与时间戳;纯 `YYYYMMDD``end` 会扩展到当天 `23:59:59`
4. 群成员的 `groupNickname` 依赖微信源数据;源数据缺失时不会自动补出。
5. 媒体访问链接只有在对应消息已经通过 `media=1` 导出后才可访问。

View File

@@ -1,54 +0,0 @@
# macOS 微信密钥自动获取失败排障指南
如果你在 macOS 系统下,遇到了 WeFlow 自动获取微信数据库密钥失败的问题,这篇指南或许可以帮到你。
### 请立刻停止连续重试
当你看到下面这些报错时,请务必暂停操作,不要再去反复点击获取:
- SCAN_FAILED通常伴随 No suitable module found 或 Sink pattern not found
- HOOK_FAILED 或 Native Hook Failed
- patch_breakpoint_failed
- thread_get_state_failed
现在的 macOS 系统和微信防护机制非常敏锐。连续的重试动作不仅无法解决问题,反而容易被判定为异常行为,进而触发微信的安全模式或系统级的内存保护。
### 可能的尝试流程
根据大量社区用户的反馈,如果你已经遇到了获取失败的情况,按照下面的步骤顺序操作,通常都能顺利解决问题:
1. **降级微信版本**。找一个经过大家验证、兼容性更好的老版本,目前最推荐先退回到 4.1.7.57 或者 4.1.8.100。
2. **彻底退出微信**。请使用快捷键 Command + Q 或在活动监视器中结束进程,而不仅仅是关闭窗口。
3. **重启你的 Mac**。这一步极其关键,必须是真正的重新启动。注销或睡眠唤醒无法清除系统底层的拦截状态。
4. **重新打开微信**。随便点击几下保持它在最前台,并且确保它是未登录的状态。
5. **回到 WeFlow**。仅仅尝试一次“自动获取密钥”。
6. **输入密码并登录**。先在弹窗中输入你的系统密码后,确认页面弹出允许登录了再登录微信
7. **恢复日常使用**。只要成功拿到了密钥,你就可以放心地把微信更新回你平时爱用的最新版本。
### 常见报错与应对方法
为了方便排查,这里列出了几类最常见的报错及其背后的原因和对策:
**SCAN_FAILED: No suitable module found**
这意味着微信的内存布局并不标准,或者目标模块没有被命中。你可以先确保微信完整启动并保持在前台。如果还是不行,请直接执行上面提到的“降级、重启电脑、获取、再升级”的完整流程。
**SCAN_FAILED: Sink pattern not found**
这说明 WeFlow 还没有适配你当前正在使用的微信版本特征。最快的解决办法是直接降级到微信 4.1.7 或 4.1.8.100 版本再试。
**patch_breakpoint_failed 或 thread_get_state_failed**
这类错误大多是因为调试断点注入或线程状态读取被 macOS 系统的安全机制拦截了。此时继续尝试毫无意义,彻底退出微信并重启电脑再试。
**task_for_pid:5**
这是进程附加权限被系统拒绝的提示。请确保你使用的是打包好的 WeFlow.app同时检查系统的签名与调试权限是否已经正确配置。
### 关于推荐版本的补充说明
截至 2026 年 4 月,综合社区的反馈来看,微信 4.1.7 和 4.1.8.100 版本在密钥获取流程中的表现最为稳定,成功率最高。
这并不意味着其他新版本绝对无法获取,只是作为当前的排障参考。未来 WeFlow 也会在后续的更新中逐步适配新版微信的特征,建议大家多留意项目的 Release 动态。
### 最后的几点建议
首次失败后,首要任务是排查原因,切忌盲目地连续点击自动获取。如果你在看到这篇文档前已经失败了好几次,最好的做法是直接清零重来:彻底退出微信,重启电脑,然后再进行下一次尝试。
最后,如果尝试了上述所有方法依然无法解决,请记得保存完整的报错文本,特别是 SCAN_FAILED 或 HOOK_FAILED 后面跟着的英文细节。把这些信息提交到[issue](https://github.com/hicccc77/WeFlow/issues/745),会大大加快定位和修复兼容性问题的速度。

View File

@@ -1,43 +0,0 @@
import { parentPort, workerData } from 'worker_threads'
import { wcdbService } from './services/wcdbService'
import { annualReportService } from './services/annualReportService'
interface WorkerConfig {
year: number
dbPath: string
decryptKey: string
myWxid: string
resourcesPath?: string
userDataPath?: string
logEnabled?: boolean
}
const config = workerData as WorkerConfig
process.env.WEFLOW_WORKER = '1'
if (config.resourcesPath) {
process.env.WCDB_RESOURCES_PATH = config.resourcesPath
}
wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '')
wcdbService.setLogEnabled(config.logEnabled === true)
async function run() {
const result = await annualReportService.generateReportWithConfig({
year: config.year,
dbPath: config.dbPath,
decryptKey: config.decryptKey,
wxid: config.myWxid,
onProgress: (status: string, progress: number) => {
parentPort?.postMessage({
type: 'annualReport:progress',
data: { status, progress }
})
}
})
parentPort?.postMessage({ type: 'annualReport:result', data: result })
}
run().catch((err) => {
parentPort?.postMessage({ type: 'annualReport:error', error: String(err) })
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,47 +0,0 @@
import { parentPort, workerData } from 'worker_threads'
import { wcdbService } from './services/wcdbService'
import { dualReportService } from './services/dualReportService'
interface WorkerConfig {
year: number
friendUsername: string
dbPath: string
decryptKey: string
myWxid: string
resourcesPath?: string
userDataPath?: string
logEnabled?: boolean
excludeWords?: string[]
}
const config = workerData as WorkerConfig
process.env.WEFLOW_WORKER = '1'
if (config.resourcesPath) {
process.env.WCDB_RESOURCES_PATH = config.resourcesPath
}
wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '')
wcdbService.setLogEnabled(config.logEnabled === true)
async function run() {
const result = await dualReportService.generateReportWithConfig({
year: config.year,
friendUsername: config.friendUsername,
dbPath: config.dbPath,
decryptKey: config.decryptKey,
wxid: config.myWxid,
excludeWords: config.excludeWords,
onProgress: (status: string, progress: number) => {
parentPort?.postMessage({
type: 'dualReport:progress',
data: { status, progress }
})
}
})
parentPort?.postMessage({ type: 'dualReport:result', data: result })
}
run().catch((err) => {
parentPort?.postMessage({ type: 'dualReport:error', error: String(err) })
})

View File

@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.debugger</key>
<true/>
<key>com.apple.security.get-task-allow</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>

View File

@@ -1,223 +0,0 @@
import { parentPort, workerData } from 'worker_threads'
interface ExportWorkerConfig {
mode?: 'sessions' | 'single' | 'contacts'
sessionIds?: string[]
sessionId?: string
outputDir?: string
outputPath?: string
options?: any
taskId?: string
dbPath?: string
decryptKey?: string
myWxid?: string
imageXorKey?: unknown
imageAesKey?: string
resourcesPath?: string
userDataPath?: string
logEnabled?: boolean
isPackaged?: boolean
}
const config = workerData as ExportWorkerConfig
const controlState = {
pauseRequested: false,
stopRequested: false
}
const CREATED_PATH_FLUSH_INTERVAL_MS = 200
const CREATED_PATH_BATCH_LIMIT = 256
const PROGRESS_POST_INTERVAL_MS = 180
let queuedCreatedFiles: string[] = []
let queuedCreatedDirs: string[] = []
let createdPathFlushTimer: ReturnType<typeof setTimeout> | null = null
let pendingProgress: any = null
let progressPostTimer: ReturnType<typeof setTimeout> | null = null
let lastProgressPostedAt = 0
function flushCreatedPaths() {
if (createdPathFlushTimer) {
clearTimeout(createdPathFlushTimer)
createdPathFlushTimer = null
}
const filePaths = queuedCreatedFiles
const dirPaths = queuedCreatedDirs
queuedCreatedFiles = []
queuedCreatedDirs = []
if (!parentPort) return
if (filePaths.length > 0) {
parentPort.postMessage({ type: 'export:createdFiles', filePaths })
}
if (dirPaths.length > 0) {
parentPort.postMessage({ type: 'export:createdDirs', dirPaths })
}
}
function scheduleCreatedPathFlush() {
if (createdPathFlushTimer) return
createdPathFlushTimer = setTimeout(flushCreatedPaths, CREATED_PATH_FLUSH_INTERVAL_MS)
}
function queueCreatedFile(filePath: string) {
const normalized = String(filePath || '').trim()
if (!normalized) return
queuedCreatedFiles.push(normalized)
if (queuedCreatedFiles.length + queuedCreatedDirs.length >= CREATED_PATH_BATCH_LIMIT) {
flushCreatedPaths()
} else {
scheduleCreatedPathFlush()
}
}
function queueCreatedDir(dirPath: string) {
const normalized = String(dirPath || '').trim()
if (!normalized) return
queuedCreatedDirs.push(normalized)
if (queuedCreatedFiles.length + queuedCreatedDirs.length >= CREATED_PATH_BATCH_LIMIT) {
flushCreatedPaths()
} else {
scheduleCreatedPathFlush()
}
}
function flushProgress() {
if (!pendingProgress) return
if (progressPostTimer) {
clearTimeout(progressPostTimer)
progressPostTimer = null
}
parentPort?.postMessage({
type: 'export:progress',
data: pendingProgress
})
pendingProgress = null
lastProgressPostedAt = Date.now()
}
function queueProgress(progress: any) {
pendingProgress = progress
if (progress?.phase === 'complete') {
flushProgress()
return
}
const now = Date.now()
const elapsed = now - lastProgressPostedAt
if (elapsed >= PROGRESS_POST_INTERVAL_MS) {
flushProgress()
return
}
if (progressPostTimer) return
progressPostTimer = setTimeout(flushProgress, PROGRESS_POST_INTERVAL_MS - elapsed)
}
parentPort?.on('message', (message: any) => {
if (!message || typeof message.type !== 'string') return
if (message.type === 'export:pause') {
controlState.pauseRequested = true
return
}
if (message.type === 'export:resume') {
controlState.pauseRequested = false
return
}
if (message.type === 'export:cancel') {
controlState.stopRequested = true
controlState.pauseRequested = false
}
})
process.env.WEFLOW_WORKER = '1'
if (config.resourcesPath) {
process.env.WCDB_RESOURCES_PATH = config.resourcesPath
}
if (config.userDataPath) {
process.env.WEFLOW_USER_DATA_PATH = config.userDataPath
process.env.WEFLOW_CONFIG_CWD = config.userDataPath
}
process.env.WEFLOW_PROJECT_NAME = process.env.WEFLOW_PROJECT_NAME || 'WeFlow'
async function run() {
const [{ wcdbService }, { exportService }] = await Promise.all([
import('./services/wcdbService'),
import('./services/exportService')
])
wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '')
wcdbService.setLogEnabled(config.logEnabled === true)
exportService.setRuntimeConfig({
dbPath: config.dbPath,
decryptKey: config.decryptKey,
myWxid: config.myWxid,
imageXorKey: config.imageXorKey,
imageAesKey: config.imageAesKey,
resourcesPath: config.resourcesPath,
appPath: config.resourcesPath ? require('path').dirname(config.resourcesPath) : __dirname,
isPackaged: config.isPackaged
})
const onProgress = (progress: any) => queueProgress(progress)
const taskControl = config.taskId
? {
shouldPause: () => controlState.pauseRequested,
shouldStop: () => controlState.stopRequested,
recordCreatedFile: queueCreatedFile,
recordCreatedDir: queueCreatedDir
}
: undefined
let result: any
if (config.mode === 'contacts') {
const [{ contactExportService }, { chatService }] = await Promise.all([
import('./services/contactExportService'),
import('./services/chatService')
])
chatService.setRuntimeConfig({
dbPath: config.dbPath,
decryptKey: config.decryptKey,
myWxid: config.myWxid,
resourcesPath: config.resourcesPath,
appPath: config.resourcesPath ? require('path').dirname(config.resourcesPath) : __dirname,
isPackaged: config.isPackaged
})
result = await contactExportService.exportContacts(
String(config.outputDir || ''),
config.options || {}
)
} else if (config.mode === 'single') {
result = await exportService.exportSessionToChatLab(
String(config.sessionId || '').trim(),
String(config.outputPath || '').trim(),
config.options || { format: 'chatlab' },
onProgress,
taskControl
)
} else {
result = await exportService.exportSessions(
Array.isArray(config.sessionIds) ? config.sessionIds : [],
String(config.outputDir || ''),
config.options || { format: 'json' },
onProgress,
taskControl
)
}
flushProgress()
flushCreatedPaths()
parentPort?.postMessage({
type: 'export:result',
data: result
})
}
run().catch((error) => {
flushProgress()
flushCreatedPaths()
parentPort?.postMessage({
type: 'export:error',
error: String(error)
})
})

View File

@@ -1,174 +0,0 @@
import { parentPort, workerData } from 'worker_threads'
import { readdirSync, statSync } from 'fs'
import { join } from 'path'
type WorkerPayload = {
root: string
datName: string
maxDepth: number
allowThumbnail: boolean
thumbOnly: boolean
}
type Candidate = { score: number; path: string; isThumb: boolean }
const payload = workerData as WorkerPayload
function looksLikeMd5(value: string): boolean {
return /^[a-fA-F0-9]{16,32}$/.test(value)
}
function stripDatVariantSuffix(base: string): string {
const lower = base.toLowerCase()
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_b', '.b', '_w', '.w', '_t', '.t', '_c', '.c']
for (const suffix of suffixes) {
if (lower.endsWith(suffix)) {
return lower.slice(0, -suffix.length)
}
}
if (/[._][a-z]$/.test(lower)) {
return lower.slice(0, -2)
}
return lower
}
function hasXVariant(baseLower: string): boolean {
return stripDatVariantSuffix(baseLower) !== baseLower
}
function hasImageVariantSuffix(baseLower: string): boolean {
return stripDatVariantSuffix(baseLower) !== baseLower
}
function normalizeDatBase(name: string): string {
let base = name.toLowerCase()
if (base.endsWith('.dat') || base.endsWith('.jpg')) {
base = base.slice(0, -4)
}
while (true) {
const stripped = stripDatVariantSuffix(base)
if (stripped === base) {
return base
}
base = stripped
}
}
function isLikelyImageDatBase(baseLower: string): boolean {
return hasImageVariantSuffix(baseLower) || looksLikeMd5(normalizeDatBase(baseLower))
}
function matchesDatName(fileName: string, datName: string): boolean {
const lower = fileName.toLowerCase()
const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
const normalizedBase = normalizeDatBase(base)
const normalizedTarget = normalizeDatBase(datName.toLowerCase())
if (normalizedBase === normalizedTarget) return true
return lower.endsWith('.dat') && lower.includes(normalizedTarget)
}
function scoreDatName(fileName: string): number {
const lower = fileName.toLowerCase()
const baseLower = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 550
if (baseLower.endsWith('_b') || baseLower.endsWith('.b')) return 520
if (baseLower.endsWith('_w') || baseLower.endsWith('.w')) return 510
if (!hasXVariant(baseLower)) return 500
if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400
if (isThumbnailDat(lower)) return 100
return 350
}
function isThumbnailDat(fileName: string): boolean {
const lower = fileName.toLowerCase()
return lower.includes('.t.dat') || lower.includes('_t.dat') || lower.includes('_thumb.dat')
}
function walkForDat(
root: string,
datName: string,
maxDepth = 4,
allowThumbnail = true,
thumbOnly = false
): { path: string | null; matchedBases: string[] } {
const stack: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }]
const candidates: Candidate[] = []
const matchedBases = new Set<string>()
while (stack.length) {
const current = stack.pop() as { dir: string; depth: number }
let entries: string[]
try {
entries = readdirSync(current.dir)
} catch {
continue
}
for (const entry of entries) {
const entryPath = join(current.dir, entry)
let stat
try {
stat = statSync(entryPath)
} catch {
continue
}
if (stat.isDirectory()) {
if (current.depth < maxDepth) {
stack.push({ dir: entryPath, depth: current.depth + 1 })
}
continue
}
const lower = entry.toLowerCase()
if (!lower.endsWith('.dat')) continue
const baseLower = lower.slice(0, -4)
if (!isLikelyImageDatBase(baseLower)) continue
if (!matchesDatName(lower, datName)) continue
matchedBases.add(baseLower)
const isThumb = isThumbnailDat(lower)
if (!allowThumbnail && isThumb) continue
if (thumbOnly && !isThumb) continue
candidates.push({
score: scoreDatName(lower),
path: entryPath,
isThumb
})
}
}
if (!candidates.length) {
return { path: null, matchedBases: Array.from(matchedBases).slice(0, 20) }
}
const nonThumb = candidates.filter((item) => !item.isThumb)
const finalPool = thumbOnly ? candidates : (nonThumb.length ? nonThumb : candidates)
let best: { score: number; path: string } | null = null
for (const item of finalPool) {
if (!best || item.score > best.score) {
best = { score: item.score, path: item.path }
}
}
return { path: best?.path ?? null, matchedBases: Array.from(matchedBases).slice(0, 20) }
}
function run() {
const result = walkForDat(
payload.root,
payload.datName,
payload.maxDepth,
payload.allowThumbnail,
payload.thumbOnly
)
parentPort?.postMessage({
type: 'done',
path: result.path,
root: payload.root,
datName: payload.datName,
matchedBases: result.matchedBases
})
}
try {
run()
} catch (err) {
parentPort?.postMessage({ type: 'error', error: String(err) })
}

File diff suppressed because it is too large Load Diff

24
electron/nodert.d.ts vendored
View File

@@ -1,24 +0,0 @@
declare module '@nodert-win10-rs4/windows.security.credentials.ui' {
export enum UserConsentVerificationResult {
Verified = 0,
DeviceNotPresent = 1,
NotConfiguredForUser = 2,
DisabledByPolicy = 3,
DeviceBusy = 4,
RetriesExhausted = 5,
Canceled = 6
}
export enum UserConsentVerifierAvailability {
Available = 0,
DeviceNotPresent = 1,
NotConfiguredForUser = 2,
DisabledByPolicy = 3,
DeviceBusy = 4
}
export class UserConsentVerifier {
static checkAvailabilityAsync(callback: (err: Error | null, availability: UserConsentVerifierAvailability) => void): void;
static requestVerificationAsync(message: string, callback: (err: Error | null, result: UserConsentVerificationResult) => void): void;
}
}

View File

@@ -1,39 +0,0 @@
import { join, dirname } from 'path'
/**
* 强制将本地资源目录添加到 PATH 最前端,确保优先加载本地 DLL
* 解决系统中存在冲突版本的数据服务导致的应用崩溃问题
*/
function enforceLocalDllPriority() {
const isDev = !!process.env.VITE_DEV_SERVER_URL
const sep = process.platform === 'win32' ? ';' : ':'
let possiblePaths: string[] = []
if (isDev) {
// 开发环境
possiblePaths.push(join(process.cwd(), 'resources'))
} else {
// 生产环境
possiblePaths.push(dirname(process.execPath))
if (process.resourcesPath) {
possiblePaths.push(process.resourcesPath)
}
}
const dllPaths = possiblePaths.join(sep)
if (process.env.PATH) {
process.env.PATH = dllPaths + sep + process.env.PATH
} else {
process.env.PATH = dllPaths
}
}
try {
enforceLocalDllPriority()
} catch (e) {
console.error('[WeFlow] Failed to enforce local service priority:', e)
}

View File

@@ -1,654 +0,0 @@
import { contextBridge, ipcRenderer } from 'electron'
type CloseConfirmPayload = {
canMinimizeToTray: boolean
restoreMethod?: 'tray' | 'dock'
}
// 暴露给渲染进程的 API
contextBridge.exposeInMainWorld('electronAPI', {
// 配置
config: {
get: (key: string) => ipcRenderer.invoke('config:get', key),
set: (key: string, value: any) => ipcRenderer.invoke('config:set', key, value),
clear: () => ipcRenderer.invoke('config:clear')
},
// 通知
notification: {
show: (data: any) => ipcRenderer.invoke('notification:show', data),
close: () => ipcRenderer.invoke('notification:close'),
click: (payload: any) => ipcRenderer.send('notification-clicked', payload),
ready: () => ipcRenderer.send('notification:ready'),
resize: (width: number, height: number) => ipcRenderer.send('notification:resize', { width, height }),
onShow: (callback: (event: any, data: any) => void) => {
ipcRenderer.on('notification:show', callback)
return () => ipcRenderer.removeAllListeners('notification:show')
}, // 监听原本发送出来的navigate-to-session事件跳转到具体的会话
onNavigateToSession: (callback: (sessionId: string) => void) => {
const listener = (_: any, sessionId: string) => callback(sessionId)
ipcRenderer.on('navigate-to-session', listener)
return () => ipcRenderer.removeListener('navigate-to-session', listener)
},
onNavigateToRoute: (callback: (route: string) => void) => {
const listener = (_: any, route: string) => callback(route)
ipcRenderer.on('navigate-to-route', listener)
return () => ipcRenderer.removeListener('navigate-to-route', listener)
}
},
// 认证
auth: {
hello: (message?: string) => ipcRenderer.invoke('auth:hello', message),
verifyEnabled: () => ipcRenderer.invoke('auth:verifyEnabled'),
unlock: (password: string) => ipcRenderer.invoke('auth:unlock', password),
enableLock: (password: string) => ipcRenderer.invoke('auth:enableLock', password),
disableLock: (password: string) => ipcRenderer.invoke('auth:disableLock', password),
changePassword: (oldPassword: string, newPassword: string) => ipcRenderer.invoke('auth:changePassword', oldPassword, newPassword),
setHelloSecret: (password: string) => ipcRenderer.invoke('auth:setHelloSecret', password),
clearHelloSecret: () => ipcRenderer.invoke('auth:clearHelloSecret'),
isLockMode: () => ipcRenderer.invoke('auth:isLockMode')
},
// 对话框
dialog: {
openFile: (options: any) => ipcRenderer.invoke('dialog:openFile', options),
openDirectory: (options: any) => ipcRenderer.invoke('dialog:openDirectory', options),
saveFile: (options: any) => ipcRenderer.invoke('dialog:saveFile', options)
},
// Shell
shell: {
openPath: (path: string) => ipcRenderer.invoke('shell:openPath', path),
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url)
},
// App
app: {
getDownloadsPath: () => ipcRenderer.invoke('app:getDownloadsPath'),
getVersion: () => ipcRenderer.invoke('app:getVersion'),
getLaunchAtStartupStatus: () => ipcRenderer.invoke('app:getLaunchAtStartupStatus'),
setLaunchAtStartup: (enabled: boolean) => ipcRenderer.invoke('app:setLaunchAtStartup', enabled),
checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'),
downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'),
ignoreUpdate: (version: string) => ipcRenderer.invoke('app:ignoreUpdate', version),
onDownloadProgress: (callback: (progress: any) => void) => {
ipcRenderer.on('app:downloadProgress', (_, progress) => callback(progress))
return () => ipcRenderer.removeAllListeners('app:downloadProgress')
},
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => {
ipcRenderer.on('app:updateAvailable', (_, info) => callback(info))
return () => ipcRenderer.removeAllListeners('app:updateAvailable')
},
},
// 日志
log: {
getPath: () => ipcRenderer.invoke('log:getPath'),
read: () => ipcRenderer.invoke('log:read'),
clear: () => ipcRenderer.invoke('log:clear'),
debug: (data: any) => ipcRenderer.send('log:debug', data)
},
diagnostics: {
getExportCardLogs: (options?: { limit?: number }) =>
ipcRenderer.invoke('diagnostics:getExportCardLogs', options),
clearExportCardLogs: () =>
ipcRenderer.invoke('diagnostics:clearExportCardLogs'),
exportExportCardLogs: (payload: { filePath: string; frontendLogs?: unknown[] }) =>
ipcRenderer.invoke('diagnostics:exportExportCardLogs', payload)
},
// 窗口控制
window: {
minimize: () => ipcRenderer.send('window:minimize'),
maximize: () => ipcRenderer.send('window:maximize'),
isMaximized: () => ipcRenderer.invoke('window:isMaximized'),
onMaximizeStateChanged: (callback: (isMaximized: boolean) => void) => {
const listener = (_: unknown, isMaximized: boolean) => callback(isMaximized)
ipcRenderer.on('window:maximizeStateChanged', listener)
return () => ipcRenderer.removeListener('window:maximizeStateChanged', listener)
},
close: () => ipcRenderer.send('window:close'),
onCloseConfirmRequested: (callback: (payload: CloseConfirmPayload) => void) => {
const listener = (_: unknown, payload: CloseConfirmPayload) => callback(payload)
ipcRenderer.on('window:confirmCloseRequested', listener)
return () => ipcRenderer.removeListener('window:confirmCloseRequested', listener)
},
respondCloseConfirm: (action: 'tray' | 'quit' | 'cancel') =>
ipcRenderer.invoke('window:respondCloseConfirm', action),
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
openOnboardingWindow: (options?: { mode?: 'add-account' }) => ipcRenderer.invoke('window:openOnboardingWindow', options),
setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options),
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) =>
ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
resizeToFitVideo: (videoWidth: number, videoHeight: number) =>
ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight),
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) =>
ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath),
openChatHistoryWindow: (sessionId: string, messageId: number) =>
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId),
openChatHistoryPayloadWindow: (payload: { sessionId: string; title?: string; recordList: any[] }) =>
ipcRenderer.invoke('window:openChatHistoryPayloadWindow', payload),
getChatHistoryPayload: (payloadId: string) =>
ipcRenderer.invoke('window:getChatHistoryPayload', payloadId),
openSessionChatWindow: (
sessionId: string,
options?: {
source?: 'chat' | 'export'
initialDisplayName?: string
initialAvatarUrl?: string
initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
}
) =>
ipcRenderer.invoke('window:openSessionChatWindow', sessionId, options)
},
// 数据库路径
dbPath: {
autoDetect: () => ipcRenderer.invoke('dbpath:autoDetect'),
scanWxids: (rootPath: string) => ipcRenderer.invoke('dbpath:scanWxids', rootPath),
scanWxidCandidates: (rootPath: string) => ipcRenderer.invoke('dbpath:scanWxidCandidates', rootPath),
getDefault: () => ipcRenderer.invoke('dbpath:getDefault')
},
// WCDB 数据库
wcdb: {
testConnection: (dbPath: string, hexKey: string, wxid: string) =>
ipcRenderer.invoke('wcdb:testConnection', dbPath, hexKey, wxid),
open: (dbPath: string, hexKey: string, wxid: string) =>
ipcRenderer.invoke('wcdb:open', dbPath, hexKey, wxid),
close: () => ipcRenderer.invoke('wcdb:close'),
},
backup: {
create: (payload: { outputPath: string; options?: { includeImages?: boolean; includeVideos?: boolean; includeFiles?: boolean } }) => ipcRenderer.invoke('backup:create', payload),
inspect: (payload: { archivePath: string }) => ipcRenderer.invoke('backup:inspect', payload),
restore: (payload: { archivePath: string }) => ipcRenderer.invoke('backup:restore', payload),
onProgress: (callback: (progress: any) => void) => {
const listener = (_: unknown, progress: any) => callback(progress)
ipcRenderer.on('backup:progress', listener)
return () => ipcRenderer.removeListener('backup:progress', listener)
}
},
// 密钥获取
key: {
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
autoGetImageKey: (manualDir?: string, wxid?: string) => ipcRenderer.invoke('key:autoGetImageKey', manualDir, wxid),
scanImageKeyFromMemory: (userDir: string) => ipcRenderer.invoke('key:scanImageKeyFromMemory', userDir),
onDbKeyStatus: (callback: (payload: { message: string; level: number }) => void) => {
ipcRenderer.on('key:dbKeyStatus', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('key:dbKeyStatus')
},
onImageKeyStatus: (callback: (payload: { message: string }) => void) => {
ipcRenderer.on('key:imageKeyStatus', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('key:imageKeyStatus')
}
},
// 聊天
chat: {
connect: () => ipcRenderer.invoke('chat:connect'),
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
markAllSessionsRead: () => ipcRenderer.invoke('chat:markAllSessionsRead'),
getAntiRevokeSessions: () => ipcRenderer.invoke('chat:getAntiRevokeSessions'),
getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames),
getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'),
getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'),
getSessionMessageCounts: (sessionIds: string[], options?: { preferHintCache?: boolean; bypassSessionCache?: boolean }) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds, options),
enrichSessionsContactInfo: (
usernames: string[],
options?: { skipDisplayName?: boolean; onlyMissingAvatar?: boolean }
) => ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames, options),
getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) =>
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending),
getLatestMessages: (sessionId: string, limit?: number) =>
ipcRenderer.invoke('chat:getLatestMessages', sessionId, limit),
getNewMessages: (sessionId: string, minTime: number, limit?: number) =>
ipcRenderer.invoke('chat:getNewMessages', sessionId, minTime, limit),
getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username),
getContactAvatar: (username: string) => ipcRenderer.invoke('chat:getContactAvatar', username),
updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) =>
ipcRenderer.invoke('chat:updateMessage', sessionId, localId, createTime, newContent),
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) =>
ipcRenderer.invoke('chat:deleteMessage', sessionId, localId, createTime, dbPathHint),
checkAntiRevokeTriggers: (sessionIds: string[]) =>
ipcRenderer.invoke('chat:checkAntiRevokeTriggers', sessionIds),
installAntiRevokeTriggers: (sessionIds: string[]) =>
ipcRenderer.invoke('chat:installAntiRevokeTriggers', sessionIds),
uninstallAntiRevokeTriggers: (sessionIds: string[]) =>
ipcRenderer.invoke('chat:uninstallAntiRevokeTriggers', sessionIds),
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) =>
ipcRenderer.invoke('chat:resolveTransferDisplayNames', chatroomId, payerUsername, receiverUsername),
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5),
getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId),
clearCurrentAccountData: (options: { clearCache?: boolean; clearExports?: boolean }) =>
ipcRenderer.invoke('chat:clearCurrentAccountData', options),
close: () => ipcRenderer.invoke('chat:close'),
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
getSessionDetailFast: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailFast', sessionId),
getSessionDetailExtra: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailExtra', sessionId),
getExportSessionStats: (
sessionIds: string[],
options?: {
includeRelations?: boolean
forceRefresh?: boolean
allowStaleCache?: boolean
preferAccurateSpecialTypes?: boolean
cacheOnly?: boolean
beginTimestamp?: number
endTimestamp?: number
}
) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options),
getGroupMyMessageCountHint: (chatroomId: string) =>
ipcRenderer.invoke('chat:getGroupMyMessageCountHint', chatroomId),
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),
getAllVoiceMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId),
getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId),
getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId),
getMessageDateCounts: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDateCounts', sessionId),
getResourceMessages: (options?: {
sessionId?: string
types?: Array<'image' | 'video' | 'voice' | 'file'>
beginTimestamp?: number
endTimestamp?: number
limit?: number
offset?: number
}) => ipcRenderer.invoke('chat:getResourceMessages', options),
getMediaStream: (options?: {
sessionId?: string
mediaType?: 'image' | 'video' | 'all'
beginTimestamp?: number
endTimestamp?: number
limit?: number
offset?: number
}) => ipcRenderer.invoke('chat:getMediaStream', options),
resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId),
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
onVoiceTranscriptPartial: (callback: (payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => {
const listener = (_: any, payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => callback(payload)
ipcRenderer.on('chat:voiceTranscriptPartial', listener)
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
},
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) =>
ipcRenderer.invoke('chat:searchMessages', keyword, sessionId, limit, offset, beginTimestamp, endTimestamp),
getMyFootprintStats: (
beginTimestamp: number,
endTimestamp: number,
options?: {
myWxid?: string
privateSessionIds?: string[]
groupSessionIds?: string[]
mentionLimit?: number
privateLimit?: number
mentionMode?: 'text_at_me' | string
}
) => ipcRenderer.invoke('chat:getMyFootprintStats', beginTimestamp, endTimestamp, options),
exportMyFootprint: (
beginTimestamp: number,
endTimestamp: number,
format: 'csv' | 'json',
filePath: string
) => ipcRenderer.invoke('chat:exportMyFootprint', beginTimestamp, endTimestamp, format, filePath),
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => {
ipcRenderer.on('wcdb-change', callback)
return () => ipcRenderer.removeListener('wcdb-change', callback)
}
},
// 图片解密
image: {
decrypt: (payload: {
sessionId?: string
imageMd5?: string
imageDatName?: string
createTime?: number
force?: boolean
preferFilePath?: boolean
hardlinkOnly?: boolean
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
suppressEvents?: boolean
}) =>
ipcRenderer.invoke('image:decrypt', payload),
resolveCache: (payload: {
sessionId?: string
imageMd5?: string
imageDatName?: string
createTime?: number
preferFilePath?: boolean
hardlinkOnly?: boolean
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
suppressEvents?: boolean
}) =>
ipcRenderer.invoke('image:resolveCache', payload),
resolveCacheBatch: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean; hardlinkOnly?: boolean }>,
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean; suppressEvents?: boolean }
) => ipcRenderer.invoke('image:resolveCacheBatch', payloads, options),
preload: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>,
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
) => ipcRenderer.invoke('image:preload', payloads, options),
preloadHardlinkMd5s: (md5List: string[]) =>
ipcRenderer.invoke('image:preloadHardlinkMd5s', md5List),
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload)
ipcRenderer.on('image:updateAvailable', listener)
return () => ipcRenderer.removeListener('image:updateAvailable', listener)
},
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => {
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => callback(payload)
ipcRenderer.on('image:cacheResolved', listener)
return () => ipcRenderer.removeListener('image:cacheResolved', listener)
},
onDecryptProgress: (callback: (payload: {
cacheKey: string
imageMd5?: string
imageDatName?: string
stage: 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed'
progress: number
status: 'running' | 'done' | 'error'
message?: string
}) => void) => {
const listener = (_: unknown, payload: {
cacheKey: string
imageMd5?: string
imageDatName?: string
stage: 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed'
progress: number
status: 'running' | 'done' | 'error'
message?: string
}) => callback(payload)
ipcRenderer.on('image:decryptProgress', listener)
return () => ipcRenderer.removeListener('image:decryptProgress', listener)
},
startAutoDownload: (whitelist: string[] | string) => ipcRenderer.invoke('image:startAutoDownload', whitelist),
stopAutoDownload: () => ipcRenderer.invoke('image:stopAutoDownload'),
getAutoDownloadStatus: () => ipcRenderer.invoke('image:getAutoDownloadStatus')
},
// 视频
video: {
getVideoInfo: (videoMd5: string, options?: { includePoster?: boolean; posterFormat?: 'dataUrl' | 'fileUrl' }) => ipcRenderer.invoke('video:getVideoInfo', videoMd5, options),
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
},
process: {
platform: process.platform,
arch: process.arch
},
// 数据分析
analytics: {
getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force),
getContactRankings: (limit?: number, beginTimestamp?: number, endTimestamp?: number) =>
ipcRenderer.invoke('analytics:getContactRankings', limit, beginTimestamp, endTimestamp),
getTimeDistribution: () => ipcRenderer.invoke('analytics:getTimeDistribution'),
getSelfSentDailyDistribution: (beginTimestamp?: number, endTimestamp?: number, force?: boolean) =>
ipcRenderer.invoke('analytics:getSelfSentDailyDistribution', beginTimestamp, endTimestamp, force),
getExcludedUsernames: () => ipcRenderer.invoke('analytics:getExcludedUsernames'),
setExcludedUsernames: (usernames: string[]) => ipcRenderer.invoke('analytics:setExcludedUsernames', usernames),
getExcludeCandidates: () => ipcRenderer.invoke('analytics:getExcludeCandidates'),
onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
ipcRenderer.on('analytics:progress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('analytics:progress')
}
},
// 缓存管理
cache: {
clearAnalytics: () => ipcRenderer.invoke('cache:clearAnalytics'),
clearImages: () => ipcRenderer.invoke('cache:clearImages'),
clearAll: () => ipcRenderer.invoke('cache:clearAll')
},
// 群聊分析
groupAnalytics: {
getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'),
getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId),
getGroupMembersPanelData: (
chatroomId: string,
options?: { forceRefresh?: boolean; includeMessageCounts?: boolean }
) => ipcRenderer.invoke('groupAnalytics:getGroupMembersPanelData', chatroomId, options),
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,
options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number }
) => ipcRenderer.invoke('groupAnalytics:getGroupMemberMessages', chatroomId, memberUsername, options),
exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath),
exportGroupMemberMessages: (chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) =>
ipcRenderer.invoke('groupAnalytics:exportGroupMemberMessages', chatroomId, memberUsername, outputPath, startTime, endTime)
},
// 年度报告
annualReport: {
getAvailableYears: () => ipcRenderer.invoke('annualReport:getAvailableYears'),
startAvailableYearsLoad: () => ipcRenderer.invoke('annualReport:startAvailableYearsLoad'),
cancelAvailableYearsLoad: (taskId: string) => ipcRenderer.invoke('annualReport:cancelAvailableYearsLoad', taskId),
generateReport: (year: number) => ipcRenderer.invoke('annualReport:generateReport', year),
exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) =>
ipcRenderer.invoke('annualReport:exportImages', payload),
captureCurrentWindow: () => ipcRenderer.invoke('annualReport:captureCurrentWindow'),
onAvailableYearsProgress: (callback: (payload: {
taskId: string
years?: number[]
done: boolean
error?: string
canceled?: boolean
strategy?: 'cache' | 'native' | 'hybrid'
phase?: 'cache' | 'native' | 'scan' | 'done'
statusText?: string
nativeElapsedMs?: number
scanElapsedMs?: number
totalElapsedMs?: number
switched?: boolean
nativeTimedOut?: boolean
}) => void) => {
ipcRenderer.on('annualReport:availableYearsProgress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('annualReport:availableYearsProgress')
},
onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
ipcRenderer.on('annualReport:progress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('annualReport:progress')
}
},
dualReport: {
generateReport: (payload: { friendUsername: string; year: number }) =>
ipcRenderer.invoke('dualReport:generateReport', payload),
onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
ipcRenderer.on('dualReport:progress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('dualReport:progress')
}
},
// 导出
export: {
getExportStats: (sessionIds: string[], options: any) =>
ipcRenderer.invoke('export:getExportStats', sessionIds, options),
exportSessions: (sessionIds: string[], outputDir: string, options: any, controlOptions?: { taskId?: string }) =>
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options, controlOptions),
pauseTask: (taskId: string) =>
ipcRenderer.invoke('export:pauseTask', taskId),
resumeTask: (taskId: string) =>
ipcRenderer.invoke('export:resumeTask', taskId),
cancelTask: (taskId: string) =>
ipcRenderer.invoke('export:cancelTask', taskId),
exportSession: (sessionId: string, outputPath: string, options: any) =>
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
exportContacts: (outputDir: string, options: any) =>
ipcRenderer.invoke('export:exportContacts', outputDir, options),
onProgress: (callback: (payload: {
current: number
total: number
currentSession: string
currentSessionId?: string
phase: string
phaseProgress?: number
phaseTotal?: number
phaseLabel?: string
collectedMessages?: number
exportedMessages?: number
estimatedTotalMessages?: number
writtenFiles?: number
}) => void) => {
ipcRenderer.on('export:progress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('export:progress')
}
},
whisper: {
downloadModel: () =>
ipcRenderer.invoke('whisper:downloadModel'),
getModelStatus: () =>
ipcRenderer.invoke('whisper:getModelStatus'),
onDownloadProgress: (callback: (payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => void) => {
ipcRenderer.on('whisper:downloadProgress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('whisper:downloadProgress')
}
},
// 朋友圈
sns: {
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'),
getUserPostCounts: () => ipcRenderer.invoke('sns:getUserPostCounts'),
getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'),
getExportStats: () => ipcRenderer.invoke('sns:getExportStats'),
getUserPostStats: (username: string) => ipcRenderer.invoke('sns:getUserPostStats', username),
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload),
downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload),
exportTimeline: (options: any) => ipcRenderer.invoke('sns:exportTimeline', options),
onExportProgress: (callback: (payload: any) => void) => {
ipcRenderer.on('sns:exportProgress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('sns:exportProgress')
},
selectExportDir: () => ipcRenderer.invoke('sns:selectExportDir'),
installBlockDeleteTrigger: () => ipcRenderer.invoke('sns:installBlockDeleteTrigger'),
uninstallBlockDeleteTrigger: () => ipcRenderer.invoke('sns:uninstallBlockDeleteTrigger'),
checkBlockDeleteTrigger: () => ipcRenderer.invoke('sns:checkBlockDeleteTrigger'),
deleteSnsPost: (postId: string) => ipcRenderer.invoke('sns:deleteSnsPost', postId),
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params),
getCacheMigrationStatus: () => ipcRenderer.invoke('sns:getCacheMigrationStatus'),
startCacheMigration: () => ipcRenderer.invoke('sns:startCacheMigration'),
onCacheMigrationProgress: (callback: (payload: any) => void) => {
const listener = (_event: unknown, payload: any) => callback(payload)
ipcRenderer.on('sns:cacheMigrationProgress', listener)
return () => ipcRenderer.removeListener('sns:cacheMigrationProgress', listener)
}
},
biz: {
listAccounts: (account?: string) => ipcRenderer.invoke('biz:listAccounts', account),
listMessages: (username: string, account?: string, limit?: number, offset?: number) =>
ipcRenderer.invoke('biz:listMessages', username, account, limit, offset),
listPayRecords: (account?: string, limit?: number, offset?: number) =>
ipcRenderer.invoke('biz:listPayRecords', account, limit, offset)
},
// 数据收集
cloud: {
init: () => ipcRenderer.invoke('cloud:init'),
recordPage: (pageName: string) => ipcRenderer.invoke('cloud:recordPage', pageName),
getLogs: () => ipcRenderer.invoke('cloud:getLogs')
},
// HTTP API 服务
http: {
start: (port?: number, host?: string) => ipcRenderer.invoke('http:start', port, host),
stop: () => ipcRenderer.invoke('http:stop'),
status: () => ipcRenderer.invoke('http:status')
},
// AI 见解
insight: {
testConnection: () => ipcRenderer.invoke('insight:testConnection'),
getTodayStats: () => ipcRenderer.invoke('insight:getTodayStats'),
listRecords: (filters?: any) => ipcRenderer.invoke('insight:listRecords', filters),
getRecord: (id: string) => ipcRenderer.invoke('insight:getRecord', id),
markRecordRead: (id: string) => ipcRenderer.invoke('insight:markRecordRead', id),
clearRecords: (filters?: any) => ipcRenderer.invoke('insight:clearRecords', filters),
triggerTest: () => ipcRenderer.invoke('insight:triggerTest'),
triggerSessionInsight: (payload: {
sessionId: string
displayName?: string
avatarUrl?: string
}) => ipcRenderer.invoke('insight:triggerSessionInsight', payload),
listProfileStatuses: (sessionIds: string[]) => ipcRenderer.invoke('insight:listProfileStatuses', sessionIds),
generateProfile: (payload: {
sessionId: string
displayName?: string
avatarUrl?: string
}) => ipcRenderer.invoke('insight:generateProfile', payload),
cancelProfile: (sessionId?: string) => ipcRenderer.invoke('insight:cancelProfile', sessionId),
generateFootprintInsight: (payload: {
rangeLabel: string
summary: {
private_inbound_people?: number
private_replied_people?: number
private_outbound_people?: number
private_reply_rate?: number
mention_count?: number
mention_group_count?: number
}
privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }>
mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }>
}) => ipcRenderer.invoke('insight:generateFootprintInsight', payload),
generateMessageInsight: (payload: {
sessionId: string
displayName?: string
avatarUrl?: string
targetLocalId?: number
targetCreateTime?: number
targetMessageKey?: string
targetText: string
targetSenderName?: string
contextCount?: number
forceRefresh?: boolean
}) => ipcRenderer.invoke('insight:generateMessageInsight', payload)
},
groupSummary: {
listRecords: (filters?: any) => ipcRenderer.invoke('groupSummary:listRecords', filters),
getRecord: (id: string) => ipcRenderer.invoke('groupSummary:getRecord', id),
triggerManual: (payload: {
sessionId: string
displayName?: string
avatarUrl?: string
startTime: number
endTime: number
}) => ipcRenderer.invoke('groupSummary:triggerManual', payload),
triggerDay: (payload: {
sessionId: string
displayName?: string
avatarUrl?: string
date: string
}) => ipcRenderer.invoke('groupSummary:triggerDay', payload)
},
social: {
saveWeiboCookie: (rawInput: string) => ipcRenderer.invoke('social:saveWeiboCookie', rawInput),
validateWeiboUid: (uid: string) => ipcRenderer.invoke('social:validateWeiboUid', uid)
}
})

View File

@@ -1,181 +0,0 @@
/**
* 账号目录解析器Worker 线程 / 主进程通用)
*
* 职责:在 dbPath 根目录下,根据传入的 wxid找出微信"实际写入数据"
* 的那个账号子目录,例如:
* dbPath = <微信数据根目录>
* wxid = customwxid_abcd 或 customwxid
* 期望返回 <微信数据根目录>/customwxid_abcd带后缀、有 session.db 的那个)
*
* 与 ConfigService.getAccountDir 行为保持一致;二者实现独立是因为本文件
* 也会在 Worker 线程中被加载,无法依赖 electron-store。
*/
import { existsSync, readdirSync, statSync } from 'fs'
import { join } from 'path'
// 解析结果缓存(进程内,避免重复 IO。key = `${dbPath}|${cleanedWxid}`
const accountDirCache = new Map<string, string>()
/**
* 把 wxid 字符串"标准化"为目录前缀。
* - wxid_xxx_yyyy → wxid_xxx wxid_ 后只取第一段)
* - 自定义微信号_后缀(4 位) → 自定义微信号 (例如 customwxid_abcd → customwxid
* - 其他形式 → 原样返回
*
* 注意:清洗只是为了得到"前缀"用于扫描匹配,并不代表清洗结果就是真实目录名。
* 真实目录名仍需在 dbPath 下按"前缀 + 任意后缀"扫描得出。
*/
const cleanAccountDirName = (dirName: string): string => {
const trimmed = dirName.trim()
if (!trimmed) return trimmed
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
if (match) return match[1]
return trimmed
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
if (suffixMatch) return suffixMatch[1]
return trimmed
}
const isDirectory = (path: string): boolean => {
try {
return statSync(path).isDirectory()
} catch {
return false
}
}
/**
* 解析账号目录的真实绝对路径。
*
* ## 修复 #996错误码 -3001未找到数据库目录
*
* ### 旧实现存在的两处严重缺陷
* 1. **对 wxid_ 开头的目录强制要求"带后缀"**
* 未自定义微信号的普通用户,目录就叫 `wxid_X`(无任何后缀),
* 旧逻辑会因为段数不足而把它过滤掉,导致这类用户根本匹配不到。
*
* 2. **对非 wxid_ 开头(自定义微信号)走短路返回,且不校验目录有效性**
* 旧实现写法是
* ```
* if (!lowerWxid.startsWith('wxid_')) {
* const direct = join(root, cleanedWxid)
* if (existsSync(direct)) return direct // ← 直接返回,没校验里面有没有 db_storage
* }
* ```
* 叠加 `cleanAccountDirName` 会把 `<自定义号>_<4位后缀>` 清洗成
* `<自定义号>`,于是无论用户存的是哪个 wxid都会命中旧的、无后缀的
* 空目录(它真实存在但里面没有 db_storage最终触发 -3001。
*
* ### 修复后的统一匹配流程
* 1. 扫描 dbPath 下所有子目录;
* 2. 同时接受**精确匹配**(`entry == cleanedWxid`) 与
* **后缀匹配**(`entry.startsWith(cleanedWxid + '_')`) 两种命中方式;
* 3. 用 {@link accountDirLooksValid} 过滤掉"看起来根本不像账号目录"的项
* (没有 db_storage 也没有 FileStorage/Image[2]
* 4. 在剩余候选中按以下优先级排序,取最优:
* - **有 session.db** > 没有:区分"真正写入数据"与"残留空目录"
* - **后缀匹配** > 精确匹配:与微信 4.x 实际写入目录的命名习惯一致;
* - **修改时间更新** > 更旧:兜底。
*/
export const resolveAccountDir = (dbPath?: string, wxid?: string): string | null => {
if (!dbPath || !wxid) return null
const cleanedWxid = cleanAccountDirName(wxid)
const normalized = dbPath.replace(/[\\/]+$/, '')
const cacheKey = `${normalized}|${cleanedWxid.toLowerCase()}`
// 命中缓存且目标仍存在则直接返回;目标已被删除的过期缓存项会被剔除
const cached = accountDirCache.get(cacheKey)
if (cached && existsSync(cached)) return cached
if (cached && !existsSync(cached)) {
accountDirCache.delete(cacheKey)
}
const lowerWxid = cleanedWxid.toLowerCase()
try {
const entries = readdirSync(normalized)
type Candidate = { entryPath: string; isExact: boolean; hasSession: boolean; mtime: number }
const candidates: Candidate[] = []
for (const entry of entries) {
const entryPath = join(normalized, entry)
if (!isDirectory(entryPath)) continue
const lowerEntry = entry.toLowerCase()
const isExactMatch = lowerEntry === lowerWxid
const isSuffixMatch = lowerEntry.startsWith(`${lowerWxid}_`)
// 既不是精确命中、也不是前缀命中 → 与本 wxid 无关,跳过
if (!isExactMatch && !isSuffixMatch) continue
// 看起来不像账号目录(连 db_storage 与 FileStorage/Image 都没有)→ 跳过
// 这一步是修复 #996 的关键:自定义微信号场景下旧的、无后缀空目录
// 会在这里被过滤掉,避免后续 wcdbCore.open 误判为真实账号目录。
if (!accountDirLooksValid(entryPath)) continue
let mtime = 0
try { mtime = statSync(entryPath).mtimeMs } catch { /* 忽略 stat 异常 */ }
candidates.push({
entryPath,
isExact: isExactMatch,
hasSession: accountDirHasSessionDb(entryPath),
mtime,
})
}
if (candidates.length > 0) {
candidates.sort((a, b) => {
// 1) 优先选有 session.db 的(真实写入数据的目录)
if (a.hasSession !== b.hasSession) return a.hasSession ? -1 : 1
// 2) 其次优先选"带后缀"的(更接近微信 4.x 实际写入目录)
if (a.isExact !== b.isExact) return a.isExact ? 1 : -1
// 3) 最后按修改时间倒序(最新的优先)
return b.mtime - a.mtime
})
const best = candidates[0].entryPath
accountDirCache.set(cacheKey, best)
return best
}
} catch { /* 扫描目录失败时直接 fallthrough 返回 null */ }
return null
}
/**
* 浅层判定一个目录"看起来像不像账号目录"
* 存在 db_storage 子目录,或存在 FileStorage/Image[2] 子目录之一即认为是。
*
* 用于在候选阶段剔除"同名但实际无数据"的残留空目录
*(例如自定义微信号后遗留下来的旧 wxid 主目录)。
*/
const accountDirLooksValid = (entryPath: string): boolean => {
return (
existsSync(join(entryPath, 'db_storage')) ||
existsSync(join(entryPath, 'FileStorage', 'Image')) ||
existsSync(join(entryPath, 'FileStorage', 'Image2'))
)
}
/**
* 检测账号目录下是否存在 session.db。
*
* 是排序优先级里"区分真实写入数据 vs 仅有空 db_storage 骨架"的关键判据,
* 同时兼容微信 4.x 两种已知布局:
* - db_storage/session/session.db (新版本嵌套布局)
* - db_storage/session.db (部分版本扁平布局)
*/
const accountDirHasSessionDb = (entryPath: string): boolean => {
const candidates = [
join(entryPath, 'db_storage', 'session', 'session.db'),
join(entryPath, 'db_storage', 'session.db'),
]
for (const candidate of candidates) {
if (existsSync(candidate)) return true
}
return false
}

View File

@@ -1,833 +0,0 @@
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
import { join } from 'path'
import { readFile, writeFile, rm } from 'fs/promises'
import { app } from 'electron'
import { createHash } from 'crypto'
export interface ChatStatistics {
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>
}
export interface TimeDistribution {
hourlyDistribution: Record<number, number>
weekdayDistribution: Record<number, number>
monthlyDistribution: Record<string, number>
}
export interface SelfSentDailyDistribution {
unit: 'day'
dailyDistribution: Record<string, number>
totalMessages: number
firstMessageTime: number | null
lastMessageTime: number | null
beginTimestamp: number
endTimestamp: number
}
export interface ContactRanking {
username: string
displayName: string
avatarUrl?: string
wechatId?: string
messageCount: number
sentCount: number
receivedCount: number
lastMessageTime: number | null
}
class AnalyticsService {
private configService: ConfigService
private fallbackAggregateCache: { key: string; data: any; updatedAt: number } | null = null
private aggregateCache: { key: string; data: any; updatedAt: number } | null = null
private selfSentDailyCache: { key: string; data: SelfSentDailyDistribution; updatedAt: number } | null = null
private aggregatePromise: { key: string; promise: Promise<{ success: boolean; data?: any; source?: string; error?: string }> } | null = null
constructor() {
this.configService = new ConfigService()
}
private normalizeUsername(username: string): string {
return username.trim().toLowerCase()
}
private normalizeExcludedUsernames(value: unknown): string[] {
if (!Array.isArray(value)) return []
const normalized = value
.map((item) => typeof item === 'string' ? item.trim().toLowerCase() : '')
.filter((item) => item.length > 0)
return Array.from(new Set(normalized))
}
private getExcludedUsernamesList(): string[] {
return this.normalizeExcludedUsernames(this.configService.get('analyticsExcludedUsernames'))
}
private getExcludedUsernamesSet(): Set<string> {
return new Set(this.getExcludedUsernamesList())
}
private async getAliasMap(usernames: string[]): Promise<Record<string, string>> {
const map: Record<string, string> = {}
if (usernames.length === 0) return map
const result = await wcdbService.getContactAliasMap(usernames)
if (!result.success || !result.map) return map
for (const [username, alias] of Object.entries(result.map)) {
if (username && alias) map[username] = alias
}
return map
}
private cleanAccountDirName(name: string): string {
const trimmed = name.trim()
if (!trimmed) return trimmed
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
if (match) return match[1]
return trimmed
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
return cleaned
}
private isPrivateSession(username: string, cleanedWxid: string): boolean {
if (!username) return false
if (username.toLowerCase() === cleanedWxid.toLowerCase()) return false
if (username.includes('@chatroom')) return false
if (username === 'filehelper') return false
if (username.startsWith('gh_')) return false
if (username.toLowerCase() === 'weixin') return false
const excludeList = [
'qqmail', 'fmessage', 'medianote', 'floatbottle',
'newsapp', 'brandsessionholder', 'brandservicesessionholder',
'notifymessage', 'opencustomerservicemsg', 'notification_messages',
'userexperience_alarm', 'helper_folders', 'placeholder_foldgroup',
'@helper_folders', '@placeholder_foldgroup'
]
for (const prefix of excludeList) {
if (username.startsWith(prefix) || username === prefix) return false
}
if (username.includes('@kefu.openim') || username.includes('@openim')) return false
if (username.includes('service_')) return false
return true
}
private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> {
const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath')
const decryptKey = this.configService.get('decryptKey')
if (!wxid) return { success: false, error: '未配置微信ID' }
if (!dbPath) return { success: false, error: '未配置数据库路径' }
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
const accountDir = this.configService.getAccountDir(dbPath, wxid)
if (!accountDir) return { success: false, error: '未找到账号目录' }
const ok = await wcdbService.open(accountDir, decryptKey)
if (!ok) return { success: false, error: 'WCDB 打开失败' }
const cleanedWxid = this.cleanAccountDirName(wxid)
return { success: true, cleanedWxid }
}
private async getPrivateSessions(
cleanedWxid: string,
excludedUsernames?: Set<string>
): Promise<{ usernames: string[]; numericIds: string[] }> {
const sessionResult = await wcdbService.getSessions()
if (!sessionResult.success || !sessionResult.sessions) {
return { usernames: [], numericIds: [] }
}
const rows = sessionResult.sessions as Record<string, any>[]
const excluded = excludedUsernames ?? this.getExcludedUsernamesSet()
const sample = rows[0]
void sample
const sessions = rows.map((row) => {
const username = row.username || row.user_name || row.userName || ''
const idValue =
row.id ??
row.session_id ??
row.sessionId ??
row.sid ??
row.local_id ??
row.user_id ??
row.userId ??
row.chatroom_id ??
row.chatroomId ??
null
return { username, idValue }
})
const usernames = sessions.map((s) => s.username)
const privateSessions = sessions.filter((s) => {
if (!this.isPrivateSession(s.username, cleanedWxid)) return false
if (excluded.size === 0) return true
return !excluded.has(this.normalizeUsername(s.username))
})
const privateUsernames = privateSessions.map((s) => s.username)
const numericIds = privateSessions
.map((s) => s.idValue)
.filter((id) => typeof id === 'number' || (typeof id === 'string' && /^\d+$/.test(id)))
.map((id) => String(id))
return { usernames: privateUsernames, numericIds }
}
private async iterateSessionMessages(
sessionId: string,
onRow: (row: Record<string, any>) => void,
beginTimestamp = 0,
endTimestamp = 0,
lite = false
): Promise<void> {
const cursorResult = lite
? await wcdbService.openMessageCursorLite(sessionId, 500, true, beginTimestamp, endTimestamp)
: await wcdbService.openMessageCursor(sessionId, 500, true, beginTimestamp, endTimestamp)
if (!cursorResult.success || !cursorResult.cursor) return
try {
let hasMore = true
let batchCount = 0
while (hasMore) {
const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
if (!batch.success || !batch.rows) break
for (const row of batch.rows) {
onRow(row)
}
hasMore = batch.hasMore === true
// 每处理完一个批次,如果已经处理了较多数据,暂时让出执行权
batchCount++
if (batchCount % 10 === 0) {
await new Promise(resolve => setImmediate(resolve))
}
}
} finally {
await wcdbService.closeMessageCursor(cursorResult.cursor)
}
}
private getRowCreateTime(row: Record<string, any>): number {
const raw = row.create_time ?? row.createTime ?? row.create_time_ms ?? '0'
const parsed = parseInt(String(raw), 10)
if (!Number.isFinite(parsed) || parsed <= 0) return 0
return parsed > 1e12 ? Math.floor(parsed / 1000) : parsed
}
private isRowSentByMe(row: Record<string, any>, cleanedWxid: string): boolean {
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend
const normalized = String(isSendRaw).trim().toLowerCase()
let isSend = isSendRaw === 1 || isSendRaw === true || normalized === '1' || normalized === 'true'
if (isSendRaw === undefined || isSendRaw === null) {
const senderUsername = row.sender_username || row.senderUsername || row.sender
if (senderUsername && cleanedWxid) {
const senderLower = String(senderUsername).toLowerCase()
const myWxidLower = cleanedWxid.toLowerCase()
isSend = senderLower === myWxidLower || senderLower.startsWith(`${myWxidLower}_`)
}
}
return isSend
}
private formatDayKey(timestamp: number): string {
const date = new Date(timestamp * 1000)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
private sortDailyDistribution(daily: Record<string, number>): Record<string, number> {
const sorted: Record<string, number> = {}
for (const key of Object.keys(daily).sort()) {
sorted[key] = daily[key]
}
return sorted
}
private completeDailyDistribution(
daily: Record<string, number>,
firstTimestamp: number,
lastTimestamp: number
): Record<string, number> {
if (!firstTimestamp || !lastTimestamp || lastTimestamp < firstTimestamp) {
return this.sortDailyDistribution(daily)
}
const start = new Date(firstTimestamp * 1000)
const end = new Date(lastTimestamp * 1000)
start.setHours(0, 0, 0, 0)
end.setHours(0, 0, 0, 0)
const roughDays = Math.floor((end.getTime() - start.getTime()) / 86400000) + 1
if (roughDays <= 0 || roughDays > 5000) {
return this.sortDailyDistribution(daily)
}
const completed: Record<string, number> = {}
const cursor = new Date(start)
while (cursor.getTime() <= end.getTime()) {
const key = `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}-${String(cursor.getDate()).padStart(2, '0')}`
completed[key] = daily[key] || 0
cursor.setDate(cursor.getDate() + 1)
}
return completed
}
private setProgress(window: any, status: string, progress: number) {
if (window && !window.isDestroyed()) {
window.webContents.send('analytics:progress', { status, progress })
}
}
private buildAggregateCacheKey(sessionIds: string[], beginTimestamp: number, endTimestamp: number): string {
if (sessionIds.length === 0) {
return `${beginTimestamp}-${endTimestamp}-0-empty`
}
const normalized = Array.from(new Set(sessionIds.map((id) => String(id)))).sort()
const hash = createHash('sha1').update(normalized.join('|')).digest('hex').slice(0, 12)
return `${beginTimestamp}-${endTimestamp}-${normalized.length}-${hash}`
}
private async computeAggregateByCursor(sessionIds: string[], beginTimestamp = 0, endTimestamp = 0): Promise<any> {
const cleanedWxid = this.configService.getMyWxidCleaned() || ''
const aggregate = {
total: 0,
sent: 0,
received: 0,
firstTime: 0,
lastTime: 0,
typeCounts: {} as Record<number, number>,
hourly: {} as Record<number, number>,
weekday: {} as Record<number, number>,
daily: {} as Record<string, number>,
sentDaily: {} as Record<string, number>,
monthly: {} as Record<string, number>,
sessions: {} as Record<string, { total: number; sent: number; received: number; lastTime: number }>,
idMap: {}
}
for (const sessionId of sessionIds) {
const sessionStat = { total: 0, sent: 0, received: 0, lastTime: 0 }
await this.iterateSessionMessages(sessionId, (row) => {
const createTime = this.getRowCreateTime(row)
if (!createTime) return
if (beginTimestamp > 0 && createTime < beginTimestamp) return
if (endTimestamp > 0 && createTime > endTimestamp) return
const localType = parseInt(row.local_type || row.type || '1', 10)
const isSend = this.isRowSentByMe(row, cleanedWxid)
aggregate.total += 1
sessionStat.total += 1
aggregate.typeCounts[localType] = (aggregate.typeCounts[localType] || 0) + 1
if (isSend) {
aggregate.sent += 1
sessionStat.sent += 1
} else {
aggregate.received += 1
sessionStat.received += 1
}
if (aggregate.firstTime === 0 || createTime < aggregate.firstTime) {
aggregate.firstTime = createTime
}
if (createTime > aggregate.lastTime) {
aggregate.lastTime = createTime
}
if (createTime > sessionStat.lastTime) {
sessionStat.lastTime = createTime
}
const date = new Date(createTime * 1000)
const hour = date.getHours()
const weekday = date.getDay()
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
const dayKey = `${monthKey}-${String(date.getDate()).padStart(2, '0')}`
aggregate.hourly[hour] = (aggregate.hourly[hour] || 0) + 1
aggregate.weekday[weekday] = (aggregate.weekday[weekday] || 0) + 1
aggregate.monthly[monthKey] = (aggregate.monthly[monthKey] || 0) + 1
aggregate.daily[dayKey] = (aggregate.daily[dayKey] || 0) + 1
if (isSend) {
aggregate.sentDaily[dayKey] = (aggregate.sentDaily[dayKey] || 0) + 1
}
}, beginTimestamp, endTimestamp)
if (sessionStat.total > 0) {
aggregate.sessions[sessionId] = sessionStat
}
}
return aggregate
}
private async computeSelfSentDailyDistribution(
sessionIds: string[],
cleanedWxid: string,
beginTimestamp = 0,
endTimestamp = 0
): Promise<SelfSentDailyDistribution> {
const dailyDistribution: Record<string, number> = {}
let totalMessages = 0
let firstMessageTime = 0
let lastMessageTime = 0
for (const sessionId of sessionIds) {
await this.iterateSessionMessages(sessionId, (row) => {
const createTime = this.getRowCreateTime(row)
if (!createTime) return
if (beginTimestamp > 0 && createTime < beginTimestamp) return
if (endTimestamp > 0 && createTime > endTimestamp) return
if (!this.isRowSentByMe(row, cleanedWxid)) return
const dayKey = this.formatDayKey(createTime)
dailyDistribution[dayKey] = (dailyDistribution[dayKey] || 0) + 1
totalMessages += 1
if (firstMessageTime === 0 || createTime < firstMessageTime) {
firstMessageTime = createTime
}
if (createTime > lastMessageTime) {
lastMessageTime = createTime
}
}, beginTimestamp, endTimestamp, true)
}
return {
unit: 'day',
dailyDistribution: this.completeDailyDistribution(dailyDistribution, firstMessageTime, lastMessageTime),
totalMessages,
firstMessageTime: firstMessageTime || null,
lastMessageTime: lastMessageTime || null,
beginTimestamp,
endTimestamp
}
}
private async getAggregateWithFallback(
sessionIds: string[],
beginTimestamp = 0,
endTimestamp = 0,
window?: any,
force = false
): Promise<{ success: boolean; data?: any; source?: string; error?: string }> {
const cacheKey = this.buildAggregateCacheKey(sessionIds, beginTimestamp, endTimestamp)
if (force) {
if (this.aggregateCache) this.aggregateCache = null
if (this.fallbackAggregateCache) this.fallbackAggregateCache = null
}
if (!force && this.aggregateCache && this.aggregateCache.key === cacheKey) {
if (Date.now() - this.aggregateCache.updatedAt < 5 * 60 * 1000) {
return { success: true, data: this.aggregateCache.data, source: 'cache' }
}
}
// 尝试从文件加载缓存
if (!force) {
const fileCache = await this.loadCacheFromFile()
if (fileCache && fileCache.key === cacheKey) {
this.aggregateCache = fileCache
return { success: true, data: fileCache.data, source: 'file-cache' }
}
}
if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) {
return this.aggregatePromise.promise
}
const promise = (async () => {
const result = await wcdbService.getAggregateStats(sessionIds, beginTimestamp, endTimestamp)
if (result.success && result.data && result.data.total > 0) {
this.aggregateCache = { key: cacheKey, data: result.data, updatedAt: Date.now() }
return { success: true, data: result.data, source: 'dll' }
}
if (this.fallbackAggregateCache && this.fallbackAggregateCache.key === cacheKey) {
if (Date.now() - this.fallbackAggregateCache.updatedAt < 5 * 60 * 1000) {
return { success: true, data: this.fallbackAggregateCache.data, source: 'cursor-cache' }
}
}
if (window) {
this.setProgress(window, '原生聚合为0使用游标统计...', 45)
}
const data = await this.computeAggregateByCursor(sessionIds, beginTimestamp, endTimestamp)
this.fallbackAggregateCache = { key: cacheKey, data, updatedAt: Date.now() }
this.aggregateCache = { key: cacheKey, data, updatedAt: Date.now() }
return { success: true, data, source: 'cursor' }
})()
this.aggregatePromise = { key: cacheKey, promise }
try {
const result = await promise
// 如果计算成功,同时写入此文件缓存
if (result.success && result.data && result.source !== 'cache') {
this.saveCacheToFile({ key: cacheKey, data: this.aggregateCache?.data, updatedAt: Date.now() })
}
return result
} finally {
if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) {
this.aggregatePromise = null
}
}
}
private getCacheFilePath(): string {
return join(app.getPath('documents'), 'WeFlow', 'analytics_cache.json')
}
private async loadCacheFromFile(): Promise<{ key: string; data: any; updatedAt: number } | null> {
try {
const raw = await readFile(this.getCacheFilePath(), 'utf-8')
return JSON.parse(raw)
} catch { return null }
}
private async saveCacheToFile(data: any) {
try {
await writeFile(this.getCacheFilePath(), JSON.stringify(data))
} catch (e) {
console.error('保存统计缓存失败:', e)
}
}
private normalizeAggregateSessions(
sessions: Record<string, any> | undefined,
idMap: Record<string, string> | undefined
): Record<string, any> {
if (!sessions) return {}
if (!idMap) return sessions
const keys = Object.keys(sessions)
if (keys.length === 0) return sessions
const numericKeys = keys.every((k) => /^\d+$/.test(k))
if (!numericKeys) return sessions
const remapped: Record<string, any> = {}
for (const [id, stat] of Object.entries(sessions)) {
const username = idMap[id] || id
remapped[username] = stat
}
return remapped
}
private async logAggregateDiagnostics(sessionIds: string[]): Promise<void> {
const samples = sessionIds.slice(0, 5)
const results = await Promise.all(samples.map(async (sessionId) => {
const countResult = await wcdbService.getMessageCount(sessionId)
return { sessionId, success: countResult.success, count: countResult.count, error: countResult.error }
}))
void results
}
async getExcludedUsernames(): Promise<{ success: boolean; data?: string[]; error?: string }> {
try {
return { success: true, data: this.getExcludedUsernamesList() }
} catch (e) {
return { success: false, error: String(e) }
}
}
async setExcludedUsernames(usernames: string[]): Promise<{ success: boolean; data?: string[]; error?: string }> {
try {
const normalized = this.normalizeExcludedUsernames(usernames)
this.configService.set('analyticsExcludedUsernames', normalized)
await this.clearCache()
return { success: true, data: normalized }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getExcludeCandidates(): Promise<{ success: boolean; data?: Array<{ username: string; displayName: string; avatarUrl?: string; wechatId?: string }>; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const excluded = this.getExcludedUsernamesSet()
const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid, new Set())
const usernames = new Set<string>(sessionInfo.usernames)
for (const name of excluded) usernames.add(name)
if (usernames.size === 0) {
return { success: true, data: [] }
}
const usernameList = Array.from(usernames)
const [displayNames, avatarUrls, aliasMap] = await Promise.all([
wcdbService.getDisplayNames(usernameList),
wcdbService.getAvatarUrls(usernameList),
this.getAliasMap(usernameList)
])
const entries = usernameList.map((username) => {
const displayName = displayNames.success && displayNames.map
? (displayNames.map[username] || username)
: username
const avatarUrl = avatarUrls.success && avatarUrls.map
? avatarUrls.map[username]
: undefined
const alias = aliasMap[username]
const wechatId = alias || (!username.startsWith('wxid_') ? username : '')
return { username, displayName, avatarUrl, wechatId }
})
return { success: true, data: entries }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getOverallStatistics(force = false): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid)
if (sessionInfo.usernames.length === 0) {
return { success: false, error: '未找到消息会话' }
}
const { BrowserWindow } = require('electron')
const win = BrowserWindow.getAllWindows()[0]
this.setProgress(win, '正在执行原生数据聚合...', 30)
const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0, win, force)
if (!result.success || !result.data) {
return { success: false, error: result.error || '聚合统计失败' }
}
this.setProgress(win, '同步分析结果...', 90)
const d = result.data
if (d.total === 0 && sessionInfo.usernames.length > 0) {
await this.logAggregateDiagnostics(sessionInfo.usernames)
}
const textTypes = [1, 244813135921]
let textMessages = 0
for (const t of textTypes) textMessages += (d.typeCounts[t] || 0)
const imageMessages = d.typeCounts[3] || 0
const voiceMessages = d.typeCounts[34] || 0
const videoMessages = d.typeCounts[43] || 0
const emojiMessages = d.typeCounts[47] || 0
const otherMessages = d.total - textMessages - imageMessages - voiceMessages - videoMessages - emojiMessages
// 估算活跃天数(按月分布估算或从日期列表中提取,由于 C++ 只返回了月份映射,
// 我们这里暂时返回月份数作为参考,或者如果需要精确天数,原生层需要返回 Set 大小)
// 为了性能,我们先用月份数,或者后续再优化 C++ 返回 activeDays 计数。
// 当前 C++ 逻辑中 gs.monthly.size() 就是活跃月份。
const activeMonths = Object.keys(d.monthly).length
return {
success: true,
data: {
totalMessages: d.total,
textMessages,
imageMessages,
voiceMessages,
videoMessages,
emojiMessages,
otherMessages: Math.max(0, otherMessages),
sentMessages: d.sent,
receivedMessages: d.received,
firstMessageTime: d.firstTime || null,
lastMessageTime: d.lastTime || null,
activeDays: activeMonths * 20, // 粗略估算,或改为返回活跃月份
messageTypeCounts: d.typeCounts
}
}
} catch (e) {
return { success: false, error: String(e) }
}
}
async getContactRankings(
limit: number = 20,
beginTimestamp: number = 0,
endTimestamp: number = 0
): Promise<{ success: boolean; data?: ContactRanking[]; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid)
if (sessionInfo.usernames.length === 0) {
return { success: false, error: '未找到消息会话' }
}
const result = await this.getAggregateWithFallback(sessionInfo.usernames, beginTimestamp, endTimestamp)
if (!result.success || !result.data) {
return { success: false, error: result.error || '聚合统计失败' }
}
const d = result.data
const sessions = this.normalizeAggregateSessions(d.sessions, d.idMap)
const usernames = Object.keys(sessions)
const [displayNames, avatarUrls, aliasMap] = await Promise.all([
wcdbService.getDisplayNames(usernames),
wcdbService.getAvatarUrls(usernames),
this.getAliasMap(usernames)
])
const rankings: ContactRanking[] = usernames
.map((username) => {
const stat = sessions[username]
const displayName = displayNames.success && displayNames.map
? (displayNames.map[username] || username)
: username
const avatarUrl = avatarUrls.success && avatarUrls.map
? avatarUrls.map[username]
: undefined
const alias = aliasMap[username] || ''
const wechatId = alias || (!username.startsWith('wxid_') ? username : '')
return {
username,
displayName,
avatarUrl,
wechatId,
messageCount: stat.total,
sentCount: stat.sent,
receivedCount: stat.received,
lastMessageTime: stat.lastTime || null
}
})
.sort((a, b) => b.messageCount - a.messageCount)
.slice(0, limit)
return { success: true, data: rankings }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getTimeDistribution(): Promise<{ success: boolean; data?: TimeDistribution; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid)
if (sessionInfo.usernames.length === 0) {
return { success: false, error: '未找到消息会话' }
}
const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0)
if (!result.success || !result.data) {
return { success: false, error: result.error || '聚合统计失败' }
}
const d = result.data
// SQLite strftime('%w') 返回 0=周日, 1=周一...6=周六
// 前端期望 1=周一...7=周日
const weekdayDistribution: Record<number, number> = {}
for (const [w, count] of Object.entries(d.weekday)) {
const sqliteW = parseInt(w, 10)
const jsW = sqliteW === 0 ? 7 : sqliteW
weekdayDistribution[jsW] = count as number
}
// 补全 24 小时
const hourlyDistribution: Record<number, number> = {}
for (let i = 0; i < 24; i++) {
hourlyDistribution[i] = d.hourly[i] || 0
}
return {
success: true,
data: {
hourlyDistribution,
weekdayDistribution,
monthlyDistribution: d.monthly
}
}
} catch (e) {
return { success: false, error: String(e) }
}
}
async getSelfSentDailyDistribution(
beginTimestamp: number = 0,
endTimestamp: number = 0,
force = false
): Promise<{ success: boolean; data?: SelfSentDailyDistribution; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid)
if (sessionInfo.usernames.length === 0) {
return { success: false, error: '未找到消息会话' }
}
const cacheKey = `self-sent-daily-${this.buildAggregateCacheKey(sessionInfo.usernames, beginTimestamp, endTimestamp)}`
if (force) this.selfSentDailyCache = null
if (!force && this.selfSentDailyCache && this.selfSentDailyCache.key === cacheKey) {
if (Date.now() - this.selfSentDailyCache.updatedAt < 5 * 60 * 1000) {
return { success: true, data: this.selfSentDailyCache.data }
}
}
const data = await this.computeSelfSentDailyDistribution(
sessionInfo.usernames,
conn.cleanedWxid,
beginTimestamp,
endTimestamp
)
this.selfSentDailyCache = { key: cacheKey, data, updatedAt: Date.now() }
return { success: true, data }
} catch (e) {
return { success: false, error: String(e) }
}
}
async clearCache(): Promise<{ success: boolean; error?: string }> {
this.aggregateCache = null
this.fallbackAggregateCache = null
this.selfSentDailyCache = null
this.aggregatePromise = null
try {
await rm(this.getCacheFilePath(), { force: true })
return { success: true }
} catch (e) {
return { success: false, error: String(e) }
}
}
}
export const analyticsService = new AnalyticsService()

File diff suppressed because it is too large Load Diff

View File

@@ -1,219 +0,0 @@
import https from "https";
import http, { IncomingMessage } from "http";
import { promises as fs } from "fs";
import { join } from "path";
import { ConfigService } from "./config";
// 头像文件缓存服务 - 复用项目已有的缓存目录结构
export class AvatarFileCacheService {
private static instance: AvatarFileCacheService | null = null;
// 头像文件缓存目录
private readonly cacheDir: string;
// 头像URL -> 本地文件路径的内存缓存(仅追踪正在下载的)
private readonly pendingDownloads: Map<string, Promise<string | null>> =
new Map();
// LRU 追踪:文件路径->最后访问时间
private readonly lruOrder: string[] = [];
private readonly maxCacheFiles = 100;
private constructor() {
const basePath = ConfigService.getInstance().getCacheBasePath();
this.cacheDir = join(basePath, "avatar-files");
this.ensureCacheDir();
this.loadLruOrder();
}
public static getInstance(): AvatarFileCacheService {
if (!AvatarFileCacheService.instance) {
AvatarFileCacheService.instance = new AvatarFileCacheService();
}
return AvatarFileCacheService.instance;
}
private ensureCacheDir(): void {
// 同步确保目录存在(构造函数调用)
try {
fs.mkdir(this.cacheDir, { recursive: true }).catch(() => {});
} catch {}
}
private async ensureCacheDirAsync(): Promise<void> {
try {
await fs.mkdir(this.cacheDir, { recursive: true });
} catch {}
}
private getFilePath(url: string): string {
// 使用URL的hash作为文件名避免特殊字符问题
const hash = this.hashString(url);
return join(this.cacheDir, `avatar_${hash}.png`);
}
private hashString(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // 转换为32位整数
}
return Math.abs(hash).toString(16);
}
private async loadLruOrder(): Promise<void> {
try {
const entries = await fs.readdir(this.cacheDir);
// 按修改时间排序(旧的在前)
const filesWithTime: { file: string; mtime: number }[] = [];
for (const entry of entries) {
if (!entry.startsWith("avatar_") || !entry.endsWith(".png")) continue;
try {
const stat = await fs.stat(join(this.cacheDir, entry));
filesWithTime.push({ file: entry, mtime: stat.mtimeMs });
} catch {}
}
filesWithTime.sort((a, b) => a.mtime - b.mtime);
this.lruOrder.length = 0;
this.lruOrder.push(...filesWithTime.map((f) => f.file));
} catch {}
}
private updateLru(fileName: string): void {
const index = this.lruOrder.indexOf(fileName);
if (index > -1) {
this.lruOrder.splice(index, 1);
}
this.lruOrder.push(fileName);
}
private async evictIfNeeded(): Promise<void> {
while (this.lruOrder.length >= this.maxCacheFiles) {
const oldest = this.lruOrder.shift();
if (oldest) {
try {
await fs.rm(join(this.cacheDir, oldest));
console.log(`[AvatarFileCache] Evicted: ${oldest}`);
} catch {}
}
}
}
private async downloadAvatar(url: string): Promise<string | null> {
const localPath = this.getFilePath(url);
// 检查文件是否已存在
try {
await fs.access(localPath);
const fileName = localPath.split("/").pop()!;
this.updateLru(fileName);
return localPath;
} catch {}
await this.ensureCacheDirAsync();
await this.evictIfNeeded();
return new Promise<string | null>((resolve) => {
const options = {
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351",
Referer: "https://servicewechat.com/",
Accept:
"image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "zh-CN,zh;q=0.9",
Connection: "keep-alive",
},
};
const callback = (res: IncomingMessage) => {
if (res.statusCode !== 200) {
resolve(null);
return;
}
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => chunks.push(chunk));
res.on("end", async () => {
try {
const buffer = Buffer.concat(chunks);
await fs.writeFile(localPath, buffer);
const fileName = localPath.split("/").pop()!;
this.updateLru(fileName);
console.log(
`[AvatarFileCache] Downloaded: ${url.substring(0, 50)}... -> ${localPath}`,
);
resolve(localPath);
} catch {
resolve(null);
}
});
res.on("error", () => resolve(null));
};
const req = url.startsWith("https")
? https.get(url, options, callback)
: http.get(url, options, callback);
req.on("error", () => resolve(null));
req.setTimeout(10000, () => {
req.destroy();
resolve(null);
});
});
}
/**
* 获取头像本地文件路径,如果需要会下载
* 同一URL并发调用会复用同一个下载任务
*/
async getAvatarPath(url: string): Promise<string | null> {
if (!url) return null;
// 检查是否有正在进行的下载
const pending = this.pendingDownloads.get(url);
if (pending) {
return pending;
}
// 发起新下载
const downloadPromise = this.downloadAvatar(url);
this.pendingDownloads.set(url, downloadPromise);
try {
const result = await downloadPromise;
return result;
} finally {
this.pendingDownloads.delete(url);
}
}
// 清理所有缓存文件App退出时调用
async clearCache(): Promise<void> {
try {
const entries = await fs.readdir(this.cacheDir);
for (const entry of entries) {
if (entry.startsWith("avatar_") && entry.endsWith(".png")) {
try {
await fs.rm(join(this.cacheDir, entry));
} catch {}
}
}
this.lruOrder.length = 0;
console.log("[AvatarFileCache] Cache cleared");
} catch {}
}
// 获取当前缓存的文件数量
async getCacheCount(): Promise<number> {
try {
const entries = await fs.readdir(this.cacheDir);
return entries.filter(
(e) => e.startsWith("avatar_") && e.endsWith(".png"),
).length;
} catch {
return 0;
}
}
}
export const avatarFileCache = AvatarFileCacheService.getInstance();

File diff suppressed because it is too large Load Diff

View File

@@ -1,250 +0,0 @@
import { join } from 'path'
import { readdirSync, existsSync } from 'fs'
import { wcdbService } from './wcdbService'
import { ConfigService } from './config'
import { chatService, Message } from './chatService'
import { ipcMain } from 'electron'
import { createHash } from 'crypto'
export interface BizAccount {
username: string
name: string
avatar: string
type: number
last_time: number
formatted_last_time: string
unread_count?: number
}
export interface BizMessage {
local_id: number
create_time: number
title: string
des: string
url: string
cover: string
content_list: any[]
}
export interface BizPayRecord {
local_id: number
create_time: number
title: string
description: string
merchant_name: string
merchant_icon: string
timestamp: number
formatted_time: string
}
export class BizService {
private configService: ConfigService
constructor() {
this.configService = new ConfigService()
}
private extractXmlValue(xml: string, tagName: string): string {
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)</${tagName}>`, 'i')
const match = regex.exec(xml)
if (match) {
return match[1].replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
}
return ''
}
private parseBizContentList(xmlStr: string): any[] {
if (!xmlStr) return []
const contentList: any[] = []
try {
const itemRegex = /<item>([\s\S]*?)<\/item>/gi
let match: RegExpExecArray | null
while ((match = itemRegex.exec(xmlStr)) !== null) {
const itemXml = match[1]
const itemStruct = {
title: this.extractXmlValue(itemXml, 'title'),
url: this.extractXmlValue(itemXml, 'url'),
cover: this.extractXmlValue(itemXml, 'cover') || this.extractXmlValue(itemXml, 'thumburl'),
summary: this.extractXmlValue(itemXml, 'summary') || this.extractXmlValue(itemXml, 'digest')
}
if (itemStruct.title) contentList.push(itemStruct)
}
} catch (e) { }
return contentList
}
private parsePayXml(xmlStr: string): any {
if (!xmlStr) return null
try {
const title = this.extractXmlValue(xmlStr, 'title')
const description = this.extractXmlValue(xmlStr, 'des')
const merchantName = this.extractXmlValue(xmlStr, 'display_name') || '微信支付'
const merchantIcon = this.extractXmlValue(xmlStr, 'icon_url')
const pubTime = parseInt(this.extractXmlValue(xmlStr, 'pub_time') || '0')
if (!title && !description) return null
return { title, description, merchant_name: merchantName, merchant_icon: merchantIcon, timestamp: pubTime }
} catch (e) { return null }
}
async listAccounts(account?: string): Promise<BizAccount[]> {
try {
// 1. 获取公众号联系人列表
const contactsResult = await chatService.getContacts({ lite: true })
if (!contactsResult.success || !contactsResult.contacts) return []
const officialContacts = contactsResult.contacts.filter(c => c.type === 'official')
const usernames = officialContacts.map(c => c.username)
// 获取头像和昵称等补充信息
const enrichment = await chatService.enrichSessionsContactInfo(usernames)
const contactInfoMap = enrichment.success && enrichment.contacts ? enrichment.contacts : {}
const root = this.configService.get('dbPath')
const myWxid = this.configService.getMyWxidCleaned()
const accountWxid = account || myWxid
if (!root || !accountWxid) return []
const bizLatestTime: Record<string, number> = {}
const bizUnreadCount: Record<string, number> = {}
try {
const sessionsRes = await chatService.getSessions()
if (sessionsRes.success && sessionsRes.sessions) {
for (const session of sessionsRes.sessions) {
const uname = session.username || session.strUsrName || session.userName || session.id
// 适配日志中发现的字段,注意转为整型数字
const timeStr = session.lastTimestamp || session.sortTimestamp || session.last_timestamp || session.sort_timestamp || session.nTime || session.timestamp || '0'
const time = parseInt(timeStr.toString(), 10)
if (usernames.includes(uname) && time > 0) {
bizLatestTime[uname] = time
}
if (usernames.includes(uname)) {
const unread = Number(session.unreadCount ?? session.unread_count ?? 0)
bizUnreadCount[uname] = Number.isFinite(unread) ? Math.max(0, Math.floor(unread)) : 0
}
}
}
} catch (e) {
console.error('获取 Sessions 失败:', e)
}
// 3. 格式化时间显示
const formatBizTime = (ts: number) => {
if (!ts) return ''
const date = new Date(ts * 1000)
const now = new Date()
const isToday = date.toDateString() === now.toDateString()
if (isToday) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
const yesterday = new Date(now)
yesterday.setDate(now.getDate() - 1)
if (date.toDateString() === yesterday.toDateString()) return '昨天'
const isThisYear = date.getFullYear() === now.getFullYear()
if (isThisYear) return `${date.getMonth() + 1}/${date.getDate()}`
return `${date.getFullYear().toString().slice(-2)}/${date.getMonth() + 1}/${date.getDate()}`
}
// 4. 组装数据
const result: BizAccount[] = officialContacts.map(contact => {
const uname = contact.username
const info = contactInfoMap[uname]
const lastTime = bizLatestTime[uname] || 0
return {
username: uname,
name: info?.displayName || contact.displayName || uname,
avatar: info?.avatarUrl || '',
type: 0,
last_time: lastTime,
formatted_last_time: formatBizTime(lastTime),
unread_count: bizUnreadCount[uname] || 0
}
})
// 5. 补充公众号类型 (订阅号/服务号)
const contactDbPath = join(root, accountWxid, 'db_storage', 'contact', 'contact.db')
if (existsSync(contactDbPath)) {
const bizInfoRes = await wcdbService.execQuery('contact', contactDbPath, 'SELECT username, type FROM biz_info')
if (bizInfoRes.success && bizInfoRes.rows) {
const typeMap: Record<string, number> = {}
for (const r of bizInfoRes.rows) typeMap[r.username] = r.type
for (const acc of result) if (typeMap[acc.username] !== undefined) acc.type = typeMap[acc.username]
}
}
// 6. 排序输出
return result
.filter(acc => !acc.name.includes('广告'))
.sort((a, b) => {
if (a.username === 'gh_3dfda90e39d6') return -1 // 微信支付置顶
if (b.username === 'gh_3dfda90e39d6') return 1
return b.last_time - a.last_time // 按最新时间降序排列
})
} catch (e) {
console.error('获取账号列表发生错误:', e)
return []
}
}
async listMessages(username: string, account?: string, limit: number = 20, offset: number = 0): Promise<BizMessage[]> {
try {
// 仅保留核心路径:利用 chatService 的自动路由能力
const res = await chatService.getMessages(username, offset, limit)
if (!res.success || !res.messages) return []
return res.messages.map(msg => {
const bizMsg: BizMessage = {
local_id: msg.localId,
create_time: msg.createTime,
title: msg.linkTitle || msg.parsedContent || '',
des: msg.appMsgDesc || '',
url: msg.linkUrl || '',
cover: msg.linkThumb || msg.appMsgThumbUrl || '',
content_list: []
}
if (msg.rawContent) {
bizMsg.content_list = this.parseBizContentList(msg.rawContent)
if (bizMsg.content_list.length > 0 && !bizMsg.title) {
bizMsg.title = bizMsg.content_list[0].title
bizMsg.cover = bizMsg.cover || bizMsg.content_list[0].cover
}
}
return bizMsg
})
} catch (e) { return [] }
}
async listPayRecords(account?: string, limit: number = 20, offset: number = 0): Promise<BizPayRecord[]> {
const username = 'gh_3dfda90e39d6'
try {
const res = await chatService.getMessages(username, offset, limit)
if (!res.success || !res.messages) return []
const records: BizPayRecord[] = []
for (const msg of res.messages) {
if (!msg.rawContent) continue
const parsedData = this.parsePayXml(msg.rawContent)
if (parsedData) {
records.push({
local_id: msg.localId,
create_time: msg.createTime,
...parsedData,
timestamp: parsedData.timestamp || msg.createTime,
formatted_time: new Date((parsedData.timestamp || msg.createTime) * 1000).toLocaleString()
})
}
}
return records
} catch (e) { return [] }
}
registerHandlers() {
ipcMain.handle('biz:listAccounts', (_, account) => this.listAccounts(account))
ipcMain.handle('biz:listMessages', (_, username, account, limit, offset) => this.listMessages(username, account, limit, offset))
ipcMain.handle('biz:listPayRecords', (_, account, limit, offset) => this.listPayRecords(account, limit, offset))
}
}
export const bizService = new BizService()

File diff suppressed because it is too large Load Diff

View File

@@ -1,247 +0,0 @@
import { app } from 'electron'
import { wcdbService } from './wcdbService'
interface UsageStats {
appVersion: string
platform: string
deviceId: string
timestamp: number
online: boolean
pages: string[]
}
class CloudControlService {
private deviceId: string = ''
private timer: NodeJS.Timeout | null = null
private pages: Set<string> = new Set()
private platformVersionCache: string | null = null
private pendingReports: UsageStats[] = []
private flushInProgress = false
private retryDelayMs = 5_000
private consecutiveFailures = 0
private circuitOpenedAt = 0
private nextDelayOverrideMs: number | null = null
private initialized = false
private static readonly BASE_FLUSH_MS = 300_000
private static readonly JITTER_MS = 30_000
private static readonly MAX_BUFFER_REPORTS = 200
private static readonly MAX_BATCH_REPORTS = 20
private static readonly MAX_RETRY_MS = 120_000
private static readonly CIRCUIT_FAIL_THRESHOLD = 5
private static readonly CIRCUIT_COOLDOWN_MS = 120_000
async init() {
if (this.initialized) return
this.initialized = true
this.deviceId = this.getDeviceId()
await wcdbService.cloudInit(300)
this.enqueueCurrentReport()
await this.flushQueue(true)
this.scheduleNextFlush(this.nextDelayOverrideMs ?? undefined)
this.nextDelayOverrideMs = null
}
private getDeviceId(): string {
const crypto = require('crypto')
const os = require('os')
const machineId = os.hostname() + os.platform() + os.arch()
return crypto.createHash('md5').update(machineId).digest('hex')
}
private buildCurrentReport(): UsageStats {
return {
appVersion: app.getVersion(),
platform: this.getPlatformVersion(),
deviceId: this.deviceId,
timestamp: Date.now(),
online: true,
pages: Array.from(this.pages)
}
}
private enqueueCurrentReport() {
const report = this.buildCurrentReport()
this.pendingReports.push(report)
if (this.pendingReports.length > CloudControlService.MAX_BUFFER_REPORTS) {
this.pendingReports.splice(0, this.pendingReports.length - CloudControlService.MAX_BUFFER_REPORTS)
}
this.pages.clear()
}
private isCircuitOpen(nowMs: number): boolean {
if (this.circuitOpenedAt <= 0) return false
return nowMs-this.circuitOpenedAt < CloudControlService.CIRCUIT_COOLDOWN_MS
}
private scheduleNextFlush(delayMs?: number) {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
const jitter = Math.floor(Math.random() * CloudControlService.JITTER_MS)
const nextDelay = Math.max(1_000, Number(delayMs) > 0 ? Number(delayMs) : CloudControlService.BASE_FLUSH_MS + jitter)
this.timer = setTimeout(() => {
this.enqueueCurrentReport()
this.flushQueue(false).finally(() => {
this.scheduleNextFlush(this.nextDelayOverrideMs ?? undefined)
this.nextDelayOverrideMs = null
})
}, nextDelay)
}
private async flushQueue(force: boolean) {
if (this.flushInProgress) return
if (this.pendingReports.length === 0) return
const now = Date.now()
if (!force && this.isCircuitOpen(now)) {
return
}
this.flushInProgress = true
try {
while (this.pendingReports.length > 0) {
const batch = this.pendingReports.slice(0, CloudControlService.MAX_BATCH_REPORTS)
const result = await wcdbService.cloudReport(JSON.stringify(batch))
if (!result || result.success !== true) {
this.consecutiveFailures += 1
this.retryDelayMs = Math.min(CloudControlService.MAX_RETRY_MS, this.retryDelayMs * 2)
if (this.consecutiveFailures >= CloudControlService.CIRCUIT_FAIL_THRESHOLD) {
this.circuitOpenedAt = Date.now()
}
this.nextDelayOverrideMs = this.retryDelayMs
return
}
this.pendingReports.splice(0, batch.length)
this.consecutiveFailures = 0
this.retryDelayMs = 5_000
this.circuitOpenedAt = 0
}
} finally {
this.flushInProgress = false
}
}
private getPlatformVersion(): string {
if (this.platformVersionCache) {
return this.platformVersionCache
}
const os = require('os')
const fs = require('fs')
const platform = process.platform
if (platform === 'win32') {
const release = os.release()
const parts = release.split('.')
const major = parseInt(parts[0])
const minor = parseInt(parts[1] || '0')
const build = parseInt(parts[2] || '0')
// Windows 11 是 10.0.22000+,且主版本必须是 10.0
if (major === 10 && minor === 0 && build >= 22000) {
this.platformVersionCache = 'Windows 11'
return this.platformVersionCache
} else if (major === 10) {
this.platformVersionCache = 'Windows 10'
return this.platformVersionCache
}
this.platformVersionCache = `Windows ${release}`
return this.platformVersionCache
}
if (platform === 'darwin') {
// `os.release()` returns Darwin kernel version (e.g. 25.3.0),
// while cloud reporting expects the macOS product version (e.g. 26.3).
const macVersion = typeof process.getSystemVersion === 'function' ? process.getSystemVersion() : os.release()
this.platformVersionCache = `macOS ${macVersion}`
return this.platformVersionCache
}
if (platform === 'linux') {
try {
const osReleasePaths = ['/etc/os-release', '/usr/lib/os-release']
for (const filePath of osReleasePaths) {
if (!fs.existsSync(filePath)) {
continue
}
const content = fs.readFileSync(filePath, 'utf8')
const values: Record<string, string> = {}
for (const line of content.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) {
continue
}
const separatorIndex = trimmed.indexOf('=')
if (separatorIndex <= 0) {
continue
}
const key = trimmed.slice(0, separatorIndex)
let value = trimmed.slice(separatorIndex + 1).trim()
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
value = value.slice(1, -1)
}
values[key] = value
}
if (values.PRETTY_NAME) {
this.platformVersionCache = values.PRETTY_NAME
return this.platformVersionCache
}
if (values.NAME && values.VERSION_ID) {
this.platformVersionCache = `${values.NAME} ${values.VERSION_ID}`
return this.platformVersionCache
}
if (values.NAME) {
this.platformVersionCache = values.NAME
return this.platformVersionCache
}
}
} catch (error) {
console.warn('[CloudControl] Failed to detect Linux distro version:', error)
}
this.platformVersionCache = `Linux ${os.release()}`
return this.platformVersionCache
}
this.platformVersionCache = platform
return this.platformVersionCache
}
recordPage(pageName: string) {
this.pages.add(pageName)
}
async stop(): Promise<void> {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
this.pendingReports = []
this.flushInProgress = false
this.retryDelayMs = 5_000
this.consecutiveFailures = 0
this.circuitOpenedAt = 0
this.nextDelayOverrideMs = null
this.initialized = false
if (wcdbService.isReady()) {
try {
await wcdbService.cloudStop()
} catch {
// 忽略停止失败,避免阻塞主进程退出
}
}
}
async getLogs() {
return wcdbService.getLogs()
}
}
export const cloudControlService = new CloudControlService()

File diff suppressed because it is too large Load Diff

View File

@@ -1,93 +0,0 @@
import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
import { app } from 'electron'
import { ConfigService } from './config'
export interface ContactCacheEntry {
displayName?: string
avatarUrl?: string
updatedAt: number
}
export class ContactCacheService {
private readonly cacheFilePath: string
private cache: Record<string, ContactCacheEntry> = {}
constructor(cacheBasePath?: string) {
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
? cacheBasePath
: ConfigService.getInstance().getCacheBasePath()
this.cacheFilePath = join(basePath, 'contacts.json')
this.ensureCacheDir()
this.loadCache()
}
private ensureCacheDir() {
const dir = dirname(this.cacheFilePath)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
}
private loadCache() {
if (!existsSync(this.cacheFilePath)) return
try {
const raw = readFileSync(this.cacheFilePath, 'utf8')
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object') {
// 清除无效的头像数据hex 格式而非正确的 base64
for (const key of Object.keys(parsed)) {
const entry = parsed[key]
if (entry?.avatarUrl && entry.avatarUrl.includes('base64,ffd8')) {
// 这是错误的 hex 格式,清除它
entry.avatarUrl = undefined
}
}
this.cache = parsed
}
} catch (error) {
console.error('ContactCacheService: 载入缓存失败', error)
this.cache = {}
}
}
get(username: string): ContactCacheEntry | undefined {
return this.cache[username]
}
getAllEntries(): Record<string, ContactCacheEntry> {
return { ...this.cache }
}
setEntries(entries: Record<string, ContactCacheEntry>): void {
if (Object.keys(entries).length === 0) return
let changed = false
for (const [username, entry] of Object.entries(entries)) {
const existing = this.cache[username]
if (!existing || entry.updatedAt >= existing.updatedAt) {
this.cache[username] = entry
changed = true
}
}
if (changed) {
this.persist()
}
}
private persist() {
try {
writeFileSync(this.cacheFilePath, JSON.stringify(this.cache), 'utf8')
} catch (error) {
console.error('ContactCacheService: 保存缓存失败', error)
}
}
clear(): void {
this.cache = {}
try {
rmSync(this.cacheFilePath, { force: true })
} catch (error) {
console.error('ContactCacheService: 清理缓存失败', error)
}
}
}

View File

@@ -1,175 +0,0 @@
import * as fs from 'fs'
import * as path from 'path'
import { chatService } from './chatService'
interface ContactExportOptions {
format: 'json' | 'csv' | 'vcf'
exportAvatars: boolean
contactTypes: {
friends: boolean
groups: boolean
officials: boolean
}
selectedUsernames?: string[]
}
/**
* 联系人导出服务
*/
class ContactExportService {
/**
* 导出联系人
*/
async exportContacts(
outputDir: string,
options: ContactExportOptions
): Promise<{ success: boolean; successCount?: number; error?: string }> {
try {
// 获取所有联系人
const contactsResult = await chatService.getContacts()
if (!contactsResult.success || !contactsResult.contacts) {
return { success: false, error: contactsResult.error || '获取联系人失败' }
}
let contacts = contactsResult.contacts
// 根据类型过滤
contacts = contacts.filter(c => {
if (c.type === 'friend' && !options.contactTypes.friends) return false
if (c.type === 'group' && !options.contactTypes.groups) return false
if (c.type === 'official' && !options.contactTypes.officials) return false
return true
})
if (Array.isArray(options.selectedUsernames) && options.selectedUsernames.length > 0) {
const selectedSet = new Set(options.selectedUsernames)
contacts = contacts.filter(c => selectedSet.has(c.username))
}
if (contacts.length === 0) {
return { success: false, error: '没有符合条件的联系人' }
}
// 确保输出目录存在
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true })
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)
let outputPath: string
switch (options.format) {
case 'json':
outputPath = path.join(outputDir, `contacts_${timestamp}.json`)
await this.exportToJSON(contacts, outputPath)
break
case 'csv':
outputPath = path.join(outputDir, `contacts_${timestamp}.csv`)
await this.exportToCSV(contacts, outputPath)
break
case 'vcf':
outputPath = path.join(outputDir, `contacts_${timestamp}.vcf`)
await this.exportToVCF(contacts, outputPath)
break
default:
return { success: false, error: '不支持的导出格式' }
}
return { success: true, successCount: contacts.length }
} catch (e) {
return { success: false, error: String(e) }
}
}
/**
* 导出为JSON格式
*/
private async exportToJSON(contacts: any[], outputPath: string): Promise<void> {
const data = {
exportedAt: new Date().toISOString(),
count: contacts.length,
contacts: contacts.map(c => ({
username: c.username,
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
}))
}
fs.writeFileSync(outputPath, JSON.stringify(data, null, 2), 'utf-8')
}
/**
* 导出为CSV格式
*/
private async exportToCSV(contacts: any[], outputPath: string): Promise<void> {
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)
])
const csvContent = [
headers.join(','),
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
].join('\n')
fs.writeFileSync(outputPath, '\uFEFF' + csvContent, 'utf-8') // 添加BOM以支持Excel
}
/**
* 导出为VCF格式vCard
*/
private async exportToVCF(contacts: any[], outputPath: string): Promise<void> {
const vcards = contacts
.filter(c => c.type === 'friend') // VCF通常只用于个人联系人
.map(c => {
const lines = ['BEGIN:VCARD', 'VERSION:3.0']
// 全名
lines.push(`FN:${c.displayName || c.username}`)
// 昵称
if (c.nickname) {
lines.push(`NICKNAME:${c.nickname}`)
}
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
lines.push(`X-WECHAT-ID:${c.username}`)
lines.push('END:VCARD')
return lines.join('\r\n')
})
fs.writeFileSync(outputPath, vcards.join('\r\n\r\n'), 'utf-8')
}
private getTypeLabel(type: string): string {
switch (type) {
case 'friend': return '好友'
case 'group': return '群聊'
case 'official': return '公众号'
default: return '其他'
}
}
}
export const contactExportService = new ContactExportService()

File diff suppressed because it is too large Load Diff

View File

@@ -1,452 +0,0 @@
import { join, basename } from 'path'
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
import { homedir } from 'os'
import { createDecipheriv } from 'crypto'
import { expandHomePath } from '../utils/pathUtils'
export interface WxidInfo {
wxid: string
modifiedTime: number
nickname?: string
avatarUrl?: string
}
export class DbPathService {
private readVarint(buf: Buffer, offset: number): { value: number, length: number } {
let value = 0;
let length = 0;
let shift = 0;
while (offset < buf.length && shift < 32) {
const b = buf[offset++];
value |= (b & 0x7f) << shift;
length++;
if ((b & 0x80) === 0) break;
shift += 7;
}
return { value, length };
}
private extractMmkvString(buf: Buffer, keyName: string): string {
const keyBuf = Buffer.from(keyName, 'utf8');
const idx = buf.indexOf(keyBuf);
if (idx === -1) return '';
try {
let offset = idx + keyBuf.length;
const v1 = this.readVarint(buf, offset);
offset += v1.length;
const v2 = this.readVarint(buf, offset);
offset += v2.length;
// 合理性检查
if (v2.value > 0 && v2.value <= 10000 && offset + v2.value <= buf.length) {
return buf.toString('utf8', offset, offset + v2.value);
}
} catch { }
return '';
}
private parseGlobalConfig(rootPath: string): { wxid: string, nickname: string, avatarUrl: string } | null {
try {
const configPath = join(rootPath, 'all_users', 'config', 'global_config');
if (!existsSync(configPath)) return null;
const fullData = readFileSync(configPath);
if (fullData.length <= 4) return null;
const encryptedData = fullData.subarray(4);
const key = Buffer.alloc(16, 0);
Buffer.from('xwechat_crypt_key').copy(key); // 直接硬编码iv更是不重要
const iv = Buffer.alloc(16, 0);
const decipher = createDecipheriv('aes-128-cfb', key, iv);
decipher.setAutoPadding(false);
const decrypted = Buffer.concat([decipher.update(encryptedData), decipher.final()]);
const wxid = this.extractMmkvString(decrypted, 'mmkv_key_user_name');
const nickname = this.extractMmkvString(decrypted, 'mmkv_key_nick_name');
let avatarUrl = this.extractMmkvString(decrypted, 'mmkv_key_head_img_url');
if (!avatarUrl && decrypted.includes('http')) {
const httpIdx = decrypted.indexOf('http');
const nullIdx = decrypted.indexOf(0x00, httpIdx);
if (nullIdx !== -1) {
avatarUrl = decrypted.toString('utf8', httpIdx, nullIdx);
}
}
if (wxid || nickname) {
return { wxid, nickname, avatarUrl };
}
return null;
} catch (e) {
console.error('解析 global_config 失败:', e);
return null;
}
}
/**
* 自动检测微信数据库根目录
*/
async autoDetect(): Promise<{ success: boolean; path?: string; error?: string }> {
try {
const possiblePaths: string[] = []
const home = homedir()
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)) continue
// 检查是否有有效的账号目录,或本身就是账号目录
const accounts = this.findAccountDirs(path)
if (accounts.length > 0) {
return { success: true, path }
}
// 如果该目录本身就是账号目录(直接包含 db_storage 等)
if (this.isAccountDir(path)) {
return { success: true, path }
}
}
return { success: false, error: '未能自动检测到微信数据库目录' }
} catch (e) {
return { success: false, error: String(e) }
}
}
/**
* 查找 dbPath 根目录下所有"看起来像账号目录"的子目录名。
*
* ## 修复 #996错误码 -3001未找到数据库目录
*
* ### 旧实现的过滤逻辑及缺陷
* 旧实现对名字以 `wxid_` 开头的目录额外加了一道判断:
* "段数(按下划线切分)必须 ≥ 3否则跳过"
* 也就是 `wxid_X_<suffix>` 才算合法、`wxid_X` 一律忽略。
*
* 这种粗暴过滤会**误伤未自定义微信号的普通用户**——他们的真实账号目录
* 就叫 `wxid_X`(没有任何数字后缀),结果在欢迎页扫描时压根看不到自己。
*
* ### 修复策略
* 1. **不再依据"段数"过滤**:先按是否真的是账号目录(含 db_storage 或
* FileStorage/Image[2])一视同仁地收集所有候选;
* 2. **用 {@link dedupeAccountDirs} 做更精准的去重**:仅当 `wxid_X` 和
* `wxid_X_<suffix>` 同时存在时(这是自定义微信号后微信遗留旧空目录
* 的典型场景),才二选一保留"更像微信实际在用"的那个,避免下拉框里
* 出现两个看起来一样但只有一个能用的混乱选项。
*/
findAccountDirs(rootPath: string): string[] {
const resolvedRootPath = expandHomePath(rootPath)
const accounts: string[] = []
try {
const entries = readdirSync(resolvedRootPath)
for (const entry of entries) {
const entryPath = join(resolvedRootPath, entry)
let stat: ReturnType<typeof statSync>
try {
stat = statSync(entryPath)
} catch {
continue
}
if (stat.isDirectory()) {
if (!this.isPotentialAccountName(entry)) continue
// 检查是否有有效账号目录结构
if (this.isAccountDir(entryPath)) {
accounts.push(entry)
}
}
}
} catch { }
return this.dedupeAccountDirs(resolvedRootPath, accounts)
}
/**
* 账号目录去重:仅当存在"前缀-后缀变体对"时(即同时出现 `wxid_X` 与
* `wxid_X_<suffix>`),才二选一保留"微信实际在用"的那个目录。
*
* - 仅有一个候选目录时,原样返回,不做任何处理;
* - 没有匹配到变体对的目录也都保留(互不相关的多账号场景);
* - 真正二选一时由 {@link shouldPreferSuffixedDir} 决定胜负。
*/
private dedupeAccountDirs(rootPath: string, names: string[]): string[] {
if (names.length <= 1) return names.slice()
const lowered = names.map(n => n.toLowerCase())
const toSkip = new Set<string>()
// O(n^2) 双层循环找出所有"前缀-后缀变体对"。账号数极少,性能可忽略。
for (let i = 0; i < names.length; i++) {
for (let j = 0; j < names.length; j++) {
if (i === j) continue
// 判定 names[j] 是 names[i] 的"带后缀变体":以 `<i>_` 开头
if (lowered[j].startsWith(lowered[i] + '_')) {
const baseName = names[i]
const suffixedName = names[j]
if (this.shouldPreferSuffixedDir(rootPath, baseName, suffixedName)) {
toSkip.add(baseName) // 留 suffixedName去掉无后缀的旧目录
} else {
toSkip.add(suffixedName) // 反之亦然
}
}
}
}
return names.filter(n => !toSkip.has(n))
}
/**
* 在"无后缀目录"与"带后缀目录"之间二选一时,判定后者是否应该胜出。
*
* 优先级(从高到低):
* 1) 谁含有 session.db 谁优先 —— 这是"数据真实写入"最强的信号;
* 2) 都含或都不含 session.db 时,比较修改时间,更新的优先;
* 3) 兜底返回 true即默认保留带后缀的目录与微信 4.x 自定义微信号
* 后真实目录命名一致)。
*/
private shouldPreferSuffixedDir(rootPath: string, baseName: string, suffixedName: string): boolean {
const basePath = join(rootPath, baseName)
const suffixedPath = join(rootPath, suffixedName)
const baseHasSession = this.hasSessionDb(basePath)
const suffixedHasSession = this.hasSessionDb(suffixedPath)
if (baseHasSession !== suffixedHasSession) {
return suffixedHasSession
}
const baseTime = this.getAccountModifiedTime(basePath)
const suffixedTime = this.getAccountModifiedTime(suffixedPath)
if (baseTime !== suffixedTime) {
return suffixedTime >= baseTime
}
return true
}
/**
* 浅层检测账号目录下是否存在 session.db"数据是否真实写入"的判据)。
*
* 仅检测两条已知路径,不做深度递归,避免在大目录上拖慢扫描:
* - db_storage/session/session.db (新版本嵌套布局)
* - db_storage/session.db (部分版本扁平布局)
*/
private hasSessionDb(accountDir: string): boolean {
const candidates = [
join(accountDir, 'db_storage', 'session', 'session.db'),
join(accountDir, 'db_storage', 'session.db'),
]
for (const candidate of candidates) {
if (existsSync(candidate)) return true
}
return false
}
private isAccountDir(entryPath: string): boolean {
return (
existsSync(join(entryPath, 'db_storage')) ||
existsSync(join(entryPath, 'FileStorage', 'Image')) ||
existsSync(join(entryPath, 'FileStorage', 'Image2'))
)
}
private isPotentialAccountName(name: string): boolean {
const lower = name.toLowerCase()
if (lower.startsWith('all') || lower.startsWith('applet') || lower.startsWith('backup') || lower.startsWith('wmpf')) {
return false
}
return true
}
private getAccountModifiedTime(entryPath: string): number {
try {
const accountStat = statSync(entryPath)
let latest = accountStat.mtimeMs
const dbPath = join(entryPath, 'db_storage')
if (existsSync(dbPath)) {
const dbStat = statSync(dbPath)
latest = Math.max(latest, dbStat.mtimeMs)
}
const imagePath = join(entryPath, 'FileStorage', 'Image')
if (existsSync(imagePath)) {
const imageStat = statSync(imagePath)
latest = Math.max(latest, imageStat.mtimeMs)
}
const image2Path = join(entryPath, 'FileStorage', 'Image2')
if (existsSync(image2Path)) {
const image2Stat = statSync(image2Path)
latest = Math.max(latest, image2Stat.mtimeMs)
}
return latest
} catch {
return 0
}
}
/**
* 扫描 dbPath 下"目录名包含下划线"的文件夹作为 wxid 候选。
* 与 {@link findAccountDirs} 的区别:本方法不要求目录里真的有 db_storage/
* FileStorage仅按命名特征判断结果会暴露给"手动选择 wxid"的弹窗使用。
*
* ## 修复 #996错误码 -3001未找到数据库目录
*
* 旧实现对 `wxid_` 开头的目录额外要求"段数 ≥ 3"才放行,会误伤未自定义
* 微信号的普通用户(他们的真实目录就叫 `wxid_X`)。现在改为不再依据段数
* 过滤,并在末尾通过 {@link dedupeAccountDirs} 处理 `wxid_X` 与
* `wxid_X_<suffix>` 同时存在的去重场景。
*
* 排除规则保留:
* - 微信本身的非账号目录(如 `all_users`
* - 不含下划线的文件夹(不可能是 wxid
*/
scanWxidCandidates(rootPath: string): WxidInfo[] {
const resolvedRootPath = expandHomePath(rootPath)
const wxids: WxidInfo[] = []
try {
if (existsSync(resolvedRootPath)) {
const entries = readdirSync(resolvedRootPath)
for (const entry of entries) {
const entryPath = join(resolvedRootPath, entry)
let stat: ReturnType<typeof statSync>
try { stat = statSync(entryPath) } catch { continue }
if (!stat.isDirectory()) continue
const lower = entry.toLowerCase()
if (lower === 'all_users') continue
if (!entry.includes('_')) continue
wxids.push({ wxid: entry, modifiedTime: stat.mtimeMs })
}
}
if (wxids.length === 0) {
const rootName = basename(resolvedRootPath)
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
const rootStat = statSync(resolvedRootPath)
wxids.push({ wxid: rootName, modifiedTime: rootStat.mtimeMs })
}
}
} catch { }
// 修复 #996对扫描到的 wxid 候选做去重,避免同时显示 wxid_X 与 wxid_X_<suffix>。
const dedupedNames = new Set(
this.dedupeAccountDirs(resolvedRootPath, wxids.map(w => w.wxid))
)
const deduped = wxids.filter(w => dedupedNames.has(w.wxid))
const sorted = deduped.sort((a, b) => {
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
return a.wxid.localeCompare(b.wxid)
});
const globalInfo = this.parseGlobalConfig(resolvedRootPath);
if (globalInfo) {
for (const w of sorted) {
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
w.nickname = globalInfo.nickname;
w.avatarUrl = globalInfo.avatarUrl;
}
}
}
return sorted;
}
/**
* 扫描 wxid 列表
*/
scanWxids(rootPath: string): WxidInfo[] {
const resolvedRootPath = expandHomePath(rootPath)
const wxids: WxidInfo[] = []
try {
if (this.isAccountDir(resolvedRootPath)) {
const wxid = basename(resolvedRootPath)
const modifiedTime = this.getAccountModifiedTime(resolvedRootPath)
return [{ wxid, modifiedTime }]
}
const accounts = this.findAccountDirs(resolvedRootPath)
for (const account of accounts) {
const fullPath = join(resolvedRootPath, account)
const modifiedTime = this.getAccountModifiedTime(fullPath)
wxids.push({ wxid: account, modifiedTime })
}
} catch { }
const sorted = wxids.sort((a, b) => {
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
return a.wxid.localeCompare(b.wxid)
});
const globalInfo = this.parseGlobalConfig(resolvedRootPath);
if (globalInfo) {
for (const w of sorted) {
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
w.nickname = globalInfo.nickname;
w.avatarUrl = globalInfo.avatarUrl;
}
}
}
return sorted;
}
/**
* 获取默认数据库路径
*/
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')
}
}
export const dbPathService = new DbPathService()

View File

@@ -1,805 +0,0 @@
import { parentPort } from 'worker_threads'
import { wcdbService } from './wcdbService'
import { resolveAccountDir } from './accountDirResolver'
export interface DualReportMessage {
content: string
isSentByMe: boolean
createTime: number
createTimeStr: string
localType?: number
emojiMd5?: string
emojiCdnUrl?: string
}
export interface DualReportFirstChat {
createTime: number
createTimeStr: string
content: string
isSentByMe: boolean
senderUsername?: string
localType?: number
emojiMd5?: string
emojiCdnUrl?: string
}
export interface DualReportStats {
totalMessages: number
totalWords: number
imageCount: number
voiceCount: number
emojiCount: number
myTopEmojiMd5?: string
friendTopEmojiMd5?: string
myTopEmojiUrl?: string
friendTopEmojiUrl?: string
myTopEmojiCount?: number
friendTopEmojiCount?: number
}
export interface DualReportData {
year: number
selfName: string
selfAvatarUrl?: string
friendUsername: string
friendName: string
friendAvatarUrl?: string
firstChat: DualReportFirstChat | null
firstChatMessages?: DualReportMessage[]
yearFirstChat?: {
createTime: number
createTimeStr: string
content: string
isSentByMe: boolean
friendName: string
firstThreeMessages: DualReportMessage[]
localType?: number
emojiMd5?: string
emojiCdnUrl?: string
} | null
stats: DualReportStats
topPhrases: Array<{ phrase: string; count: number }>
myExclusivePhrases: Array<{ phrase: string; count: number }>
friendExclusivePhrases: Array<{ phrase: string; count: number }>
heatmap?: number[][]
initiative?: { initiated: number; received: number }
response?: { avg: number; fastest: number; count: number }
monthly?: Record<string, number>
streak?: { days: number; startDate: string; endDate: string }
}
class DualReportService {
private broadcastProgress(status: string, progress: number) {
if (parentPort) {
parentPort.postMessage({
type: 'dualReport:progress',
data: { status, progress }
})
}
}
private reportProgress(status: string, progress: number, onProgress?: (status: string, progress: number) => void) {
if (onProgress) {
onProgress(status, progress)
return
}
this.broadcastProgress(status, progress)
}
private cleanAccountDirName(dirName: string): string {
const trimmed = dirName.trim()
if (!trimmed) return trimmed
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
if (match) return match[1]
return trimmed
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
return cleaned
}
private async ensureConnectedWithConfig(
dbPath: string,
decryptKey: string,
wxid: string
): Promise<{ success: boolean; cleanedWxid?: string; rawWxid?: string; error?: string }> {
if (!wxid) return { success: false, error: '未配置微信ID' }
if (!dbPath) return { success: false, error: '未配置数据库路径' }
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
const accountDir = resolveAccountDir(dbPath, wxid)
if (!accountDir) return { success: false, error: '无法找到账号目录' }
const ok = await wcdbService.open(accountDir, decryptKey)
if (!ok) return { success: false, error: 'WCDB 打开失败' }
const cleanedWxid = this.cleanAccountDirName(wxid)
return { success: true, cleanedWxid, rawWxid: wxid }
}
private decodeMessageContent(messageContent: any, compressContent: any): string {
let content = this.decodeMaybeCompressed(compressContent)
if (!content || content.length === 0) {
content = this.decodeMaybeCompressed(messageContent)
}
return content
}
private decodeMaybeCompressed(raw: any): string {
if (!raw) return ''
if (typeof raw === 'string') {
if (raw.length === 0) return ''
// 只有当字符串足够长超过16字符且看起来像 hex 时才尝试解码
// 短字符串(如 "123456" 等纯数字)容易被误判为 hex
if (raw.length > 16 && this.looksLikeHex(raw)) {
const bytes = Buffer.from(raw, 'hex')
if (bytes.length > 0) return this.decodeBinaryContent(bytes)
}
// 只有当字符串足够长超过16字符且看起来像 base64 时才尝试解码
// 短字符串(如 "test", "home" 等)容易被误判为 base64
if (raw.length > 16 && this.looksLikeBase64(raw)) {
try {
const bytes = Buffer.from(raw, 'base64')
return this.decodeBinaryContent(bytes)
} catch {
return raw
}
}
return raw
}
return ''
}
private decodeBinaryContent(data: Buffer): string {
if (data.length === 0) return ''
try {
if (data.length >= 4) {
const magic = data.readUInt32LE(0)
if (magic === 0xFD2FB528) {
const fzstd = require('fzstd')
const decompressed = fzstd.decompress(data)
return Buffer.from(decompressed).toString('utf-8')
}
}
const decoded = data.toString('utf-8')
const replacementCount = (decoded.match(/\uFFFD/g) || []).length
if (replacementCount < decoded.length * 0.2) {
return decoded.replace(/\uFFFD/g, '')
}
return data.toString('latin1')
} catch {
return ''
}
}
private looksLikeHex(s: string): boolean {
if (s.length % 2 !== 0) return false
return /^[0-9a-fA-F]+$/.test(s)
}
private looksLikeBase64(s: string): boolean {
if (s.length % 4 !== 0) return false
return /^[A-Za-z0-9+/=]+$/.test(s)
}
private formatDateTime(milliseconds: number): string {
const dt = new Date(milliseconds)
const month = String(dt.getMonth() + 1).padStart(2, '0')
const day = String(dt.getDate()).padStart(2, '0')
const hour = String(dt.getHours()).padStart(2, '0')
const minute = String(dt.getMinutes()).padStart(2, '0')
return `${month}/${day} ${hour}:${minute}`
}
private getRecordField(record: Record<string, any> | undefined | null, keys: string[]): any {
if (!record) return undefined
for (const key of keys) {
if (Object.prototype.hasOwnProperty.call(record, key) && record[key] !== undefined && record[key] !== null) {
return record[key]
}
}
return undefined
}
private coerceNumber(raw: any): number {
if (raw === undefined || raw === null || raw === '') return NaN
if (typeof raw === 'number') return raw
if (typeof raw === 'bigint') return Number(raw)
if (Buffer.isBuffer(raw)) return parseInt(raw.toString('utf-8'), 10)
if (raw instanceof Uint8Array) return parseInt(Buffer.from(raw).toString('utf-8'), 10)
const parsed = parseInt(String(raw), 10)
return Number.isFinite(parsed) ? parsed : NaN
}
private coerceString(raw: any): string {
if (raw === undefined || raw === null) return ''
if (typeof raw === 'string') return raw
if (Buffer.isBuffer(raw)) return this.decodeBinaryContent(raw)
if (raw instanceof Uint8Array) return this.decodeBinaryContent(Buffer.from(raw))
return String(raw)
}
private coerceBoolean(raw: any): boolean | undefined {
if (raw === undefined || raw === null || raw === '') return undefined
if (typeof raw === 'boolean') return raw
if (typeof raw === 'number') return raw !== 0
const normalized = String(raw).trim().toLowerCase()
if (!normalized) return undefined
if (['1', 'true', 'yes', 'me', 'self', 'mine', 'sent', 'out', 'outgoing'].includes(normalized)) return true
if (['0', 'false', 'no', 'friend', 'peer', 'other', 'recv', 'received', 'in', 'incoming'].includes(normalized)) return false
return undefined
}
private normalizeEmojiMd5(raw: string): string | undefined {
if (!raw) return undefined
const trimmed = raw.trim()
if (!trimmed) return undefined
const match = /([a-fA-F0-9]{16,64})/.exec(trimmed)
return match ? match[1].toLowerCase() : undefined
}
private normalizeEmojiUrl(raw: string): string | undefined {
if (!raw) return undefined
let url = raw.trim().replace(/&amp;/g, '&')
if (!url) return undefined
try {
if (url.includes('%')) {
url = decodeURIComponent(url)
}
} catch { }
return url || undefined
}
private extractEmojiUrl(content: string | undefined): string | undefined {
if (!content) return undefined
const direct = this.normalizeEmojiUrl(content)
if (direct && /^https?:\/\//i.test(direct)) return direct
const attrMatch = /(?:cdnurl|thumburl)\s*=\s*['"]([^'"]+)['"]/i.exec(content)
|| /(?:cdnurl|thumburl)\s*=\s*([^'"\s>]+)/i.exec(content)
if (attrMatch) return this.normalizeEmojiUrl(attrMatch[1])
const tagMatch = /<(?:cdnurl|thumburl)>([^<]+)<\/(?:cdnurl|thumburl)>/i.exec(content)
|| /(?:cdnurl|thumburl)[^>]*>([^<]+)/i.exec(content)
return this.normalizeEmojiUrl(tagMatch?.[1] || '')
}
private extractEmojiMd5(content: string | undefined): string | undefined {
if (!content) return undefined
const direct = this.normalizeEmojiMd5(content)
if (direct && direct.length >= 24) return direct
const match = /md5\s*=\s*['"]([a-fA-F0-9]{16,64})['"]/i.exec(content)
|| /md5\s*=\s*([a-fA-F0-9]{16,64})/i.exec(content)
|| /<md5>([a-fA-F0-9]{16,64})<\/md5>/i.exec(content)
return this.normalizeEmojiMd5(match?.[1] || '')
}
private resolveEmojiOwner(item: any, content: string): boolean | undefined {
const sentFlag = this.coerceBoolean(this.getRecordField(item, [
'isMe',
'is_me',
'isSent',
'is_sent',
'isSend',
'is_send',
'fromMe',
'from_me'
]))
if (sentFlag !== undefined) return sentFlag
const sideRaw = this.coerceString(this.getRecordField(item, ['side', 'sender', 'from', 'owner', 'role', 'direction'])).trim().toLowerCase()
if (sideRaw) {
if (['me', 'self', 'mine', 'out', 'outgoing', 'sent'].includes(sideRaw)) return true
if (['friend', 'peer', 'other', 'in', 'incoming', 'received', 'recv'].includes(sideRaw)) return false
}
const prefixMatch = /^\s*([01])\s*:\s*/.exec(content)
if (prefixMatch) return prefixMatch[1] === '1'
return undefined
}
private stripEmojiOwnerPrefix(content: string): string {
if (!content) return ''
return content.replace(/^\s*[01]\s*:\s*/, '')
}
private parseEmojiCandidate(item: any): { isMe?: boolean; md5?: string; url?: string; count: number } {
const rawContent = this.coerceString(this.getRecordField(item, [
'content',
'xml',
'message_content',
'messageContent',
'msg',
'payload',
'raw'
]))
const content = this.stripEmojiOwnerPrefix(rawContent)
const countRaw = this.getRecordField(item, ['count', 'cnt', 'times', 'total', 'num'])
const parsedCount = this.coerceNumber(countRaw)
const count = Number.isFinite(parsedCount) && parsedCount > 0 ? parsedCount : 0
const directMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(item, [
'md5',
'emojiMd5',
'emoji_md5',
'emd5'
])))
const md5 = directMd5 || this.extractEmojiMd5(content)
const directUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(item, [
'cdnUrl',
'cdnurl',
'emojiUrl',
'emoji_url',
'url',
'thumbUrl',
'thumburl'
])))
const url = directUrl || this.extractEmojiUrl(content)
return {
isMe: this.resolveEmojiOwner(item, rawContent),
md5,
url,
count
}
}
private getRowInt(row: Record<string, any>, keys: string[], fallback = 0): number {
const raw = this.getRecordField(row, keys)
const parsed = this.coerceNumber(raw)
return Number.isFinite(parsed) ? parsed : fallback
}
private decodeRowMessageContent(row: Record<string, any>): string {
const messageContent = this.getRecordField(row, [
'message_content',
'messageContent',
'content',
'msg_content',
'msgContent',
'WCDB_CT_message_content',
'WCDB_CT_messageContent'
])
const compressContent = this.getRecordField(row, [
'compress_content',
'compressContent',
'compressed_content',
'WCDB_CT_compress_content',
'WCDB_CT_compressContent'
])
return this.decodeMessageContent(messageContent, compressContent)
}
private async scanEmojiTopFallback(
sessionId: string,
beginTimestamp: number,
endTimestamp: number,
rawWxid: string,
cleanedWxid: string
): Promise<{ my?: { md5: string; url?: string; count: number }; friend?: { md5: string; url?: string; count: number } }> {
const cursorResult = await wcdbService.openMessageCursor(sessionId, 500, true, beginTimestamp, endTimestamp)
if (!cursorResult.success || !cursorResult.cursor) return {}
const tallyMap = new Map<string, { isMe: boolean; md5: string; url?: string; count: number }>()
try {
let hasMore = true
while (hasMore) {
const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
if (!batch.success || !Array.isArray(batch.rows)) break
for (const row of batch.rows) {
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0)
if (localType !== 47) continue
const rawContent = this.decodeRowMessageContent(row)
const content = this.stripEmojiOwnerPrefix(rawContent)
const directMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(row, ['emoji_md5', 'emojiMd5', 'md5'])))
const md5 = directMd5 || this.extractEmojiMd5(content)
if (!md5) continue
const directUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(row, [
'emoji_cdn_url',
'emojiCdnUrl',
'cdnurl',
'cdn_url',
'emoji_url',
'emojiUrl',
'url',
'thumburl',
'thumb_url'
])))
const url = directUrl || this.extractEmojiUrl(content)
const isMe = this.resolveIsSent(row, rawWxid, cleanedWxid)
const mapKey = `${isMe ? '1' : '0'}:${md5}`
const existing = tallyMap.get(mapKey)
if (existing) {
existing.count += 1
if (!existing.url && url) existing.url = url
} else {
tallyMap.set(mapKey, { isMe, md5, url, count: 1 })
}
}
hasMore = batch.hasMore === true
}
} finally {
await wcdbService.closeMessageCursor(cursorResult.cursor)
}
let myTop: { md5: string; url?: string; count: number } | undefined
let friendTop: { md5: string; url?: string; count: number } | undefined
for (const entry of tallyMap.values()) {
if (entry.isMe) {
if (!myTop || entry.count > myTop.count) {
myTop = { md5: entry.md5, url: entry.url, count: entry.count }
}
} else if (!friendTop || entry.count > friendTop.count) {
friendTop = { md5: entry.md5, url: entry.url, count: entry.count }
}
}
return { my: myTop, friend: friendTop }
}
private async getDisplayName(username: string, fallback: string): Promise<string> {
const result = await wcdbService.getDisplayNames([username])
if (result.success && result.map) {
return result.map[username] || fallback
}
return fallback
}
private resolveIsSent(row: any, rawWxid?: string, cleanedWxid?: string): boolean {
const isSendRaw = row.computed_is_send ?? row.is_send
if (isSendRaw !== undefined && isSendRaw !== null) {
return parseInt(isSendRaw, 10) === 1
}
const sender = String(row.sender_username || row.sender || row.talker || '').toLowerCase()
if (!sender) return false
const rawLower = rawWxid ? rawWxid.toLowerCase() : ''
const cleanedLower = cleanedWxid ? cleanedWxid.toLowerCase() : ''
return !!(
sender === rawLower ||
sender === cleanedLower ||
(rawLower && rawLower.startsWith(sender + '_')) ||
(cleanedLower && cleanedLower.startsWith(sender + '_'))
)
}
private async getFirstMessages(
sessionId: string,
limit: number,
beginTimestamp: number,
endTimestamp: number
): Promise<any[]> {
const safeBegin = Math.max(0, beginTimestamp || 0)
const safeEnd = endTimestamp && endTimestamp > 0 ? endTimestamp : Math.floor(Date.now() / 1000)
const cursorResult = await wcdbService.openMessageCursor(sessionId, Math.max(1, limit), true, safeBegin, safeEnd)
if (!cursorResult.success || !cursorResult.cursor) return []
try {
const rows: any[] = []
let hasMore = true
while (hasMore && rows.length < limit) {
const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
if (!batch.success || !batch.rows) break
for (const row of batch.rows) {
rows.push(row)
if (rows.length >= limit) break
}
hasMore = batch.hasMore === true
}
return rows.slice(0, limit)
} finally {
await wcdbService.closeMessageCursor(cursorResult.cursor)
}
}
async generateReportWithConfig(params: {
year: number
friendUsername: string
dbPath: string
decryptKey: string
wxid: string
excludeWords?: string[]
onProgress?: (status: string, progress: number) => void
}): Promise<{ success: boolean; data?: DualReportData; error?: string }> {
try {
const { year, friendUsername, dbPath, decryptKey, wxid, excludeWords, onProgress } = params
this.reportProgress('正在连接数据库...', 5, onProgress)
const conn = await this.ensureConnectedWithConfig(dbPath, decryptKey, wxid)
if (!conn.success || !conn.cleanedWxid || !conn.rawWxid) return { success: false, error: conn.error }
const cleanedWxid = conn.cleanedWxid
const rawWxid = conn.rawWxid
const reportYear = year <= 0 ? 0 : year
const isAllTime = reportYear === 0
const startTime = isAllTime ? 0 : Math.floor(new Date(reportYear, 0, 1).getTime() / 1000)
const endTime = isAllTime ? 0 : Math.floor(new Date(reportYear, 11, 31, 23, 59, 59).getTime() / 1000)
this.reportProgress('加载联系人信息...', 10, onProgress)
const friendName = await this.getDisplayName(friendUsername, friendUsername)
let myName = await this.getDisplayName(rawWxid, rawWxid)
if (myName === rawWxid && cleanedWxid && cleanedWxid !== rawWxid) {
myName = await this.getDisplayName(cleanedWxid, rawWxid)
}
const avatarCandidates = Array.from(new Set([
friendUsername,
rawWxid,
cleanedWxid
].filter(Boolean) as string[]))
let selfAvatarUrl: string | undefined
let friendAvatarUrl: string | undefined
const avatarResult = await wcdbService.getAvatarUrls(avatarCandidates)
if (avatarResult.success && avatarResult.map) {
selfAvatarUrl = avatarResult.map[rawWxid] || avatarResult.map[cleanedWxid]
friendAvatarUrl = avatarResult.map[friendUsername]
}
this.reportProgress('获取首条聊天记录...', 15, onProgress)
const firstRows = await this.getFirstMessages(friendUsername, 10, 0, 0)
let firstChat: DualReportFirstChat | null = null
if (firstRows.length > 0) {
const row = firstRows[0]
const createTime = parseInt(row.create_time || '0', 10) * 1000
const rawContent = this.decodeMessageContent(row.message_content, row.compress_content)
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType'], 0)
let emojiMd5: string | undefined
let emojiCdnUrl: string | undefined
if (localType === 47) {
const stripped = this.stripEmojiOwnerPrefix(rawContent)
emojiMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(row, ['emoji_md5', 'emojiMd5', 'md5']))) || this.extractEmojiMd5(stripped)
emojiCdnUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl']))) || this.extractEmojiUrl(stripped)
}
firstChat = {
createTime,
createTimeStr: this.formatDateTime(createTime),
content: String(rawContent || ''),
isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid),
senderUsername: row.sender_username || row.sender,
localType,
emojiMd5,
emojiCdnUrl
}
}
const firstChatMessages: DualReportMessage[] = firstRows.map((row) => {
const msgTime = parseInt(row.create_time || '0', 10) * 1000
const rawContent = this.decodeMessageContent(row.message_content, row.compress_content)
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType'], 0)
let emojiMd5: string | undefined
let emojiCdnUrl: string | undefined
if (localType === 47) {
const stripped = this.stripEmojiOwnerPrefix(rawContent)
emojiMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(row, ['emoji_md5', 'emojiMd5', 'md5']))) || this.extractEmojiMd5(stripped)
emojiCdnUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl']))) || this.extractEmojiUrl(stripped)
}
return {
content: String(rawContent || ''),
isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid),
createTime: msgTime,
createTimeStr: this.formatDateTime(msgTime),
localType,
emojiMd5,
emojiCdnUrl
}
})
let yearFirstChat: DualReportData['yearFirstChat'] = null
if (!isAllTime) {
this.reportProgress('获取今年首次聊天...', 20, onProgress)
const firstYearRows = await this.getFirstMessages(friendUsername, 10, startTime, endTime)
if (firstYearRows.length > 0) {
const firstRow = firstYearRows[0]
const createTime = parseInt(firstRow.create_time || '0', 10) * 1000
const firstThreeMessages: DualReportMessage[] = firstYearRows.map((row) => {
const msgTime = parseInt(row.create_time || '0', 10) * 1000
const rawContent = this.decodeMessageContent(row.message_content, row.compress_content)
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType'], 0)
let emojiMd5: string | undefined
let emojiCdnUrl: string | undefined
if (localType === 47) {
const stripped = this.stripEmojiOwnerPrefix(rawContent)
emojiMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(row, ['emoji_md5', 'emojiMd5', 'md5']))) || this.extractEmojiMd5(stripped)
emojiCdnUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl']))) || this.extractEmojiUrl(stripped)
}
return {
content: String(rawContent || ''),
isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid),
createTime: msgTime,
createTimeStr: this.formatDateTime(msgTime),
localType,
emojiMd5,
emojiCdnUrl
}
})
const firstRowYear = firstYearRows[0]
const rawContentYear = this.decodeMessageContent(firstRowYear.message_content, firstRowYear.compress_content)
const localTypeYear = this.getRowInt(firstRowYear, ['local_type', 'localType', 'type', 'msg_type', 'msgType'], 0)
let emojiMd5Year: string | undefined
let emojiCdnUrlYear: string | undefined
if (localTypeYear === 47) {
const stripped = this.stripEmojiOwnerPrefix(rawContentYear)
emojiMd5Year = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(firstRowYear, ['emoji_md5', 'emojiMd5', 'md5']))) || this.extractEmojiMd5(stripped)
emojiCdnUrlYear = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(firstRowYear, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl']))) || this.extractEmojiUrl(stripped)
}
yearFirstChat = {
createTime,
createTimeStr: this.formatDateTime(createTime),
content: String(rawContentYear || ''),
isSentByMe: this.resolveIsSent(firstRowYear, rawWxid, cleanedWxid),
friendName,
firstThreeMessages,
localType: localTypeYear,
emojiMd5: emojiMd5Year,
emojiCdnUrl: emojiCdnUrlYear
}
}
}
this.reportProgress('统计聊天数据...', 30, onProgress)
const statsResult = await wcdbService.getDualReportStats(friendUsername, startTime, endTime)
if (!statsResult.success || !statsResult.data) {
return { success: false, error: statsResult.error || '获取双人报告统计失败' }
}
const cppData = statsResult.data
const counts = cppData.counts || {}
const stats: DualReportStats = {
totalMessages: counts.total || 0,
totalWords: counts.words || 0,
imageCount: counts.image || 0,
voiceCount: counts.voice || 0,
emojiCount: counts.emoji || 0
}
// Process Emojis to find top for me and friend
let myTopEmojiMd5: string | undefined
let myTopEmojiUrl: string | undefined
let myTopCount = -1
let friendTopEmojiMd5: string | undefined
let friendTopEmojiUrl: string | undefined
let friendTopCount = -1
if (cppData.emojis && Array.isArray(cppData.emojis)) {
for (const item of cppData.emojis) {
const candidate = this.parseEmojiCandidate(item)
if (!candidate.md5 || candidate.isMe === undefined || candidate.count <= 0) continue
if (candidate.isMe) {
if (candidate.count > myTopCount) {
myTopCount = candidate.count
myTopEmojiMd5 = candidate.md5
myTopEmojiUrl = candidate.url
}
} else if (candidate.count > friendTopCount) {
friendTopCount = candidate.count
friendTopEmojiMd5 = candidate.md5
friendTopEmojiUrl = candidate.url
}
}
}
const needsEmojiFallback = stats.emojiCount > 0 && (!myTopEmojiMd5 || !friendTopEmojiMd5)
if (needsEmojiFallback) {
const fallback = await this.scanEmojiTopFallback(friendUsername, startTime, endTime, rawWxid, cleanedWxid)
if (!myTopEmojiMd5 && fallback.my?.md5) {
myTopEmojiMd5 = fallback.my.md5
myTopEmojiUrl = myTopEmojiUrl || fallback.my.url
myTopCount = fallback.my.count
}
if (!friendTopEmojiMd5 && fallback.friend?.md5) {
friendTopEmojiMd5 = fallback.friend.md5
friendTopEmojiUrl = friendTopEmojiUrl || fallback.friend.url
friendTopCount = fallback.friend.count
}
}
const [myEmojiUrlResult, friendEmojiUrlResult] = await Promise.all([
myTopEmojiMd5 && !myTopEmojiUrl ? wcdbService.getEmoticonCdnUrl(dbPath, myTopEmojiMd5) : Promise.resolve(null),
friendTopEmojiMd5 && !friendTopEmojiUrl ? wcdbService.getEmoticonCdnUrl(dbPath, friendTopEmojiMd5) : Promise.resolve(null)
])
if (myEmojiUrlResult?.success && myEmojiUrlResult.url) myTopEmojiUrl = myEmojiUrlResult.url
if (friendEmojiUrlResult?.success && friendEmojiUrlResult.url) friendTopEmojiUrl = friendEmojiUrlResult.url
stats.myTopEmojiMd5 = myTopEmojiMd5
stats.myTopEmojiUrl = myTopEmojiUrl
stats.friendTopEmojiMd5 = friendTopEmojiMd5
stats.friendTopEmojiUrl = friendTopEmojiUrl
if (myTopCount >= 0) stats.myTopEmojiCount = myTopCount
if (friendTopCount >= 0) stats.friendTopEmojiCount = friendTopCount
if (friendTopCount >= 0) stats.friendTopEmojiCount = friendTopCount
const excludeSet = new Set(excludeWords || [])
const filterPhrases = (list: any[]) => {
return (list || []).filter((p: any) => !excludeSet.has(p.phrase))
}
const cleanPhrases = filterPhrases(cppData.phrases)
const cleanMyPhrases = filterPhrases(cppData.myPhrases)
const cleanFriendPhrases = filterPhrases(cppData.friendPhrases)
const topPhrases = cleanPhrases.map((p: any) => ({
phrase: p.phrase,
count: p.count
}))
// 计算专属词汇:一方频繁使用而另一方很少使用的词
const myPhraseMap = new Map<string, number>()
const friendPhraseMap = new Map<string, number>()
for (const p of cleanMyPhrases) {
myPhraseMap.set(p.phrase, p.count)
}
for (const p of cleanFriendPhrases) {
friendPhraseMap.set(p.phrase, p.count)
}
// 专属词汇:该方使用占比 >= 75% 且至少出现 2 次
const myExclusivePhrases: Array<{ phrase: string; count: number }> = []
const friendExclusivePhrases: Array<{ phrase: string; count: number }> = []
for (const [phrase, myCount] of myPhraseMap) {
const friendCount = friendPhraseMap.get(phrase) || 0
const total = myCount + friendCount
if (myCount >= 2 && total > 0 && myCount / total >= 0.75) {
myExclusivePhrases.push({ phrase, count: myCount })
}
}
for (const [phrase, friendCount] of friendPhraseMap) {
const myCount = myPhraseMap.get(phrase) || 0
const total = myCount + friendCount
if (friendCount >= 2 && total > 0 && friendCount / total >= 0.75) {
friendExclusivePhrases.push({ phrase, count: friendCount })
}
}
// 按频率排序,取前 20
myExclusivePhrases.sort((a, b) => b.count - a.count)
friendExclusivePhrases.sort((a, b) => b.count - a.count)
if (myExclusivePhrases.length > 20) myExclusivePhrases.length = 20
if (friendExclusivePhrases.length > 20) friendExclusivePhrases.length = 20
const reportData: DualReportData = {
year: reportYear,
selfName: myName,
selfAvatarUrl,
friendUsername,
friendName,
friendAvatarUrl,
firstChat,
firstChatMessages,
yearFirstChat,
stats,
topPhrases,
myExclusivePhrases,
friendExclusivePhrases,
heatmap: cppData.heatmap,
initiative: cppData.initiative,
response: cppData.response,
monthly: cppData.monthly,
streak: cppData.streak
} as any
this.reportProgress('双人报告生成完成', 100, onProgress)
return { success: true, data: reportData }
} catch (e) {
return { success: false, error: String(e) }
}
}
}
export const dualReportService = new DualReportService()

View File

@@ -1,354 +0,0 @@
import { mkdir, writeFile } from 'fs/promises'
import { basename, dirname, extname, join } from 'path'
export type ExportCardDiagSource = 'frontend' | 'main' | 'backend' | 'worker'
export type ExportCardDiagLevel = 'debug' | 'info' | 'warn' | 'error'
export type ExportCardDiagStatus = 'running' | 'done' | 'failed' | 'timeout'
export interface ExportCardDiagLogEntry {
id: string
ts: number
source: ExportCardDiagSource
level: ExportCardDiagLevel
message: string
traceId?: string
stepId?: string
stepName?: string
status?: ExportCardDiagStatus
durationMs?: number
data?: Record<string, unknown>
}
interface ActiveStepState {
key: string
traceId: string
stepId: string
stepName: string
source: ExportCardDiagSource
startedAt: number
lastUpdatedAt: number
message?: string
}
interface StepStartInput {
traceId: string
stepId: string
stepName: string
source: ExportCardDiagSource
level?: ExportCardDiagLevel
message?: string
data?: Record<string, unknown>
}
interface StepEndInput {
traceId: string
stepId: string
stepName: string
source: ExportCardDiagSource
status?: Extract<ExportCardDiagStatus, 'done' | 'failed' | 'timeout'>
level?: ExportCardDiagLevel
message?: string
data?: Record<string, unknown>
durationMs?: number
}
interface LogInput {
ts?: number
source: ExportCardDiagSource
level?: ExportCardDiagLevel
message: string
traceId?: string
stepId?: string
stepName?: string
status?: ExportCardDiagStatus
durationMs?: number
data?: Record<string, unknown>
}
export interface ExportCardDiagSnapshot {
logs: ExportCardDiagLogEntry[]
activeSteps: Array<{
traceId: string
stepId: string
stepName: string
source: ExportCardDiagSource
elapsedMs: number
stallMs: number
startedAt: number
lastUpdatedAt: number
message?: string
}>
summary: {
totalLogs: number
activeStepCount: number
errorCount: number
warnCount: number
timeoutCount: number
lastUpdatedAt: number
}
}
export class ExportCardDiagnosticsService {
private readonly maxLogs = 6000
private logs: ExportCardDiagLogEntry[] = []
private activeSteps = new Map<string, ActiveStepState>()
private seq = 0
private nextId(ts: number): string {
this.seq += 1
return `export-card-diag-${ts}-${this.seq}`
}
private trimLogs() {
if (this.logs.length <= this.maxLogs) return
const drop = this.logs.length - this.maxLogs
this.logs.splice(0, drop)
}
log(input: LogInput): ExportCardDiagLogEntry {
const ts = Number.isFinite(input.ts) ? Math.max(0, Math.floor(input.ts as number)) : Date.now()
const entry: ExportCardDiagLogEntry = {
id: this.nextId(ts),
ts,
source: input.source,
level: input.level || 'info',
message: input.message,
traceId: input.traceId,
stepId: input.stepId,
stepName: input.stepName,
status: input.status,
durationMs: Number.isFinite(input.durationMs) ? Math.max(0, Math.floor(input.durationMs as number)) : undefined,
data: input.data
}
this.logs.push(entry)
this.trimLogs()
if (entry.traceId && entry.stepId && entry.stepName) {
const key = `${entry.traceId}::${entry.stepId}`
if (entry.status === 'running') {
const previous = this.activeSteps.get(key)
this.activeSteps.set(key, {
key,
traceId: entry.traceId,
stepId: entry.stepId,
stepName: entry.stepName,
source: entry.source,
startedAt: previous?.startedAt || entry.ts,
lastUpdatedAt: entry.ts,
message: entry.message
})
} else if (entry.status === 'done' || entry.status === 'failed' || entry.status === 'timeout') {
this.activeSteps.delete(key)
}
}
return entry
}
stepStart(input: StepStartInput): ExportCardDiagLogEntry {
return this.log({
source: input.source,
level: input.level || 'info',
message: input.message || `${input.stepName} 开始`,
traceId: input.traceId,
stepId: input.stepId,
stepName: input.stepName,
status: 'running',
data: input.data
})
}
stepEnd(input: StepEndInput): ExportCardDiagLogEntry {
return this.log({
source: input.source,
level: input.level || (input.status === 'done' ? 'info' : 'warn'),
message: input.message || `${input.stepName} ${input.status === 'done' ? '完成' : '结束'}`,
traceId: input.traceId,
stepId: input.stepId,
stepName: input.stepName,
status: input.status || 'done',
durationMs: input.durationMs,
data: input.data
})
}
clear() {
this.logs = []
this.activeSteps.clear()
}
snapshot(limit = 1200): ExportCardDiagSnapshot {
const capped = Number.isFinite(limit) ? Math.max(100, Math.min(5000, Math.floor(limit))) : 1200
const logs = this.logs.slice(-capped)
const now = Date.now()
const activeSteps = Array.from(this.activeSteps.values())
.map(step => ({
traceId: step.traceId,
stepId: step.stepId,
stepName: step.stepName,
source: step.source,
startedAt: step.startedAt,
lastUpdatedAt: step.lastUpdatedAt,
elapsedMs: Math.max(0, now - step.startedAt),
stallMs: Math.max(0, now - step.lastUpdatedAt),
message: step.message
}))
.sort((a, b) => b.lastUpdatedAt - a.lastUpdatedAt)
let errorCount = 0
let warnCount = 0
let timeoutCount = 0
for (const item of logs) {
if (item.level === 'error') errorCount += 1
if (item.level === 'warn') warnCount += 1
if (item.status === 'timeout') timeoutCount += 1
}
return {
logs,
activeSteps,
summary: {
totalLogs: this.logs.length,
activeStepCount: activeSteps.length,
errorCount,
warnCount,
timeoutCount,
lastUpdatedAt: logs.length > 0 ? logs[logs.length - 1].ts : 0
}
}
}
private normalizeExternalLogs(value: unknown[]): ExportCardDiagLogEntry[] {
const result: ExportCardDiagLogEntry[] = []
for (const item of value) {
if (!item || typeof item !== 'object') continue
const row = item as Record<string, unknown>
const tsRaw = row.ts ?? row.timestamp
const tsNum = Number(tsRaw)
const ts = Number.isFinite(tsNum) && tsNum > 0 ? Math.floor(tsNum) : Date.now()
const sourceRaw = String(row.source || 'frontend')
const source: ExportCardDiagSource = sourceRaw === 'main' || sourceRaw === 'backend' || sourceRaw === 'worker'
? sourceRaw
: 'frontend'
const levelRaw = String(row.level || 'info')
const level: ExportCardDiagLevel = levelRaw === 'debug' || levelRaw === 'warn' || levelRaw === 'error'
? levelRaw
: 'info'
const statusRaw = String(row.status || '')
const status: ExportCardDiagStatus | undefined = statusRaw === 'running' || statusRaw === 'done' || statusRaw === 'failed' || statusRaw === 'timeout'
? statusRaw
: undefined
const durationRaw = Number(row.durationMs)
result.push({
id: String(row.id || this.nextId(ts)),
ts,
source,
level,
message: String(row.message || ''),
traceId: typeof row.traceId === 'string' ? row.traceId : undefined,
stepId: typeof row.stepId === 'string' ? row.stepId : undefined,
stepName: typeof row.stepName === 'string' ? row.stepName : undefined,
status,
durationMs: Number.isFinite(durationRaw) ? Math.max(0, Math.floor(durationRaw)) : undefined,
data: row.data && typeof row.data === 'object' ? row.data as Record<string, unknown> : undefined
})
}
return result
}
private serializeLogEntry(log: ExportCardDiagLogEntry): string {
return JSON.stringify(log)
}
private buildSummaryText(logs: ExportCardDiagLogEntry[], activeSteps: ExportCardDiagSnapshot['activeSteps']): string {
const total = logs.length
let errorCount = 0
let warnCount = 0
let timeoutCount = 0
let frontendCount = 0
let backendCount = 0
let mainCount = 0
let workerCount = 0
for (const item of logs) {
if (item.level === 'error') errorCount += 1
if (item.level === 'warn') warnCount += 1
if (item.status === 'timeout') timeoutCount += 1
if (item.source === 'frontend') frontendCount += 1
if (item.source === 'backend') backendCount += 1
if (item.source === 'main') mainCount += 1
if (item.source === 'worker') workerCount += 1
}
const lines: string[] = []
lines.push('WeFlow 导出卡片诊断摘要')
lines.push(`生成时间: ${new Date().toLocaleString('zh-CN')}`)
lines.push(`日志总数: ${total}`)
lines.push(`来源统计: frontend=${frontendCount}, main=${mainCount}, backend=${backendCount}, worker=${workerCount}`)
lines.push(`级别统计: warn=${warnCount}, error=${errorCount}, timeout=${timeoutCount}`)
lines.push(`当前活跃步骤: ${activeSteps.length}`)
if (activeSteps.length > 0) {
lines.push('')
lines.push('活跃步骤:')
for (const step of activeSteps.slice(0, 12)) {
lines.push(`- [${step.source}] ${step.stepName} trace=${step.traceId} elapsed=${step.elapsedMs}ms stall=${step.stallMs}ms`)
}
}
const latestErrors = logs.filter(item => item.level === 'error' || item.status === 'failed' || item.status === 'timeout').slice(-12)
if (latestErrors.length > 0) {
lines.push('')
lines.push('最近异常:')
for (const item of latestErrors) {
lines.push(`- ${new Date(item.ts).toLocaleTimeString('zh-CN')} [${item.source}] ${item.stepName || item.stepId || 'unknown'} ${item.status || item.level} ${item.message}`)
}
}
return lines.join('\n')
}
async exportCombinedLogs(filePath: string, frontendLogs: unknown[] = []): Promise<{
success: boolean
filePath?: string
summaryPath?: string
count?: number
error?: string
}> {
try {
const normalizedFrontend = this.normalizeExternalLogs(Array.isArray(frontendLogs) ? frontendLogs : [])
const merged = [...this.logs, ...normalizedFrontend]
.sort((a, b) => (a.ts - b.ts) || a.id.localeCompare(b.id))
const lines = merged.map(item => this.serializeLogEntry(item)).join('\n')
await mkdir(dirname(filePath), { recursive: true })
await writeFile(filePath, lines ? `${lines}\n` : '', 'utf8')
const ext = extname(filePath)
const baseName = ext ? basename(filePath, ext) : basename(filePath)
const summaryPath = join(dirname(filePath), `${baseName}.txt`)
const snapshot = this.snapshot(1500)
const summaryText = this.buildSummaryText(merged, snapshot.activeSteps)
await writeFile(summaryPath, summaryText, 'utf8')
return {
success: true,
filePath,
summaryPath,
count: merged.length
}
} catch (error) {
return {
success: false,
error: String(error)
}
}
}
}
export const exportCardDiagnosticsService = new ExportCardDiagnosticsService()

View File

@@ -1,229 +0,0 @@
import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { ConfigService } from './config'
const CACHE_VERSION = 1
const MAX_SCOPE_ENTRIES = 12
const MAX_SESSION_ENTRIES_PER_SCOPE = 6000
export interface ExportContentSessionStatsEntry {
updatedAt: number
hasAny: boolean
hasVoice: boolean
hasImage: boolean
hasVideo: boolean
hasEmoji: boolean
mediaReady: boolean
}
export interface ExportContentScopeStatsEntry {
updatedAt: number
sessions: Record<string, ExportContentSessionStatsEntry>
}
interface ExportContentStatsStore {
version: number
scopes: Record<string, ExportContentScopeStatsEntry>
}
function toNonNegativeInt(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
return Math.max(0, Math.floor(value))
}
function toBoolean(value: unknown, fallback = false): boolean {
if (typeof value === 'boolean') return value
return fallback
}
function normalizeSessionStatsEntry(raw: unknown): ExportContentSessionStatsEntry | null {
if (!raw || typeof raw !== 'object') return null
const source = raw as Record<string, unknown>
const updatedAt = toNonNegativeInt(source.updatedAt)
if (updatedAt === undefined) return null
return {
updatedAt,
hasAny: toBoolean(source.hasAny, false),
hasVoice: toBoolean(source.hasVoice, false),
hasImage: toBoolean(source.hasImage, false),
hasVideo: toBoolean(source.hasVideo, false),
hasEmoji: toBoolean(source.hasEmoji, false),
mediaReady: toBoolean(source.mediaReady, false)
}
}
function normalizeScopeStatsEntry(raw: unknown): ExportContentScopeStatsEntry | null {
if (!raw || typeof raw !== 'object') return null
const source = raw as Record<string, unknown>
const updatedAt = toNonNegativeInt(source.updatedAt)
if (updatedAt === undefined) return null
const sessionsRaw = source.sessions
if (!sessionsRaw || typeof sessionsRaw !== 'object') {
return {
updatedAt,
sessions: {}
}
}
const sessions: Record<string, ExportContentSessionStatsEntry> = {}
for (const [sessionId, entryRaw] of Object.entries(sessionsRaw as Record<string, unknown>)) {
const normalized = normalizeSessionStatsEntry(entryRaw)
if (!normalized) continue
sessions[sessionId] = normalized
}
return {
updatedAt,
sessions
}
}
function cloneScope(scope: ExportContentScopeStatsEntry): ExportContentScopeStatsEntry {
return {
updatedAt: scope.updatedAt,
sessions: Object.fromEntries(
Object.entries(scope.sessions).map(([sessionId, entry]) => [sessionId, { ...entry }])
)
}
}
export class ExportContentStatsCacheService {
private readonly cacheFilePath: string
private store: ExportContentStatsStore = {
version: CACHE_VERSION,
scopes: {}
}
constructor(cacheBasePath?: string) {
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
? cacheBasePath
: ConfigService.getInstance().getCacheBasePath()
this.cacheFilePath = join(basePath, 'export-content-stats.json')
this.ensureCacheDir()
this.load()
}
private ensureCacheDir(): void {
const dir = dirname(this.cacheFilePath)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
}
private load(): void {
if (!existsSync(this.cacheFilePath)) return
try {
const raw = readFileSync(this.cacheFilePath, 'utf8')
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object') {
this.store = { version: CACHE_VERSION, scopes: {} }
return
}
const payload = parsed as Record<string, unknown>
const scopesRaw = payload.scopes
if (!scopesRaw || typeof scopesRaw !== 'object') {
this.store = { version: CACHE_VERSION, scopes: {} }
return
}
const scopes: Record<string, ExportContentScopeStatsEntry> = {}
for (const [scopeKey, scopeRaw] of Object.entries(scopesRaw as Record<string, unknown>)) {
const normalizedScope = normalizeScopeStatsEntry(scopeRaw)
if (!normalizedScope) continue
scopes[scopeKey] = normalizedScope
}
this.store = {
version: CACHE_VERSION,
scopes
}
} catch (error) {
console.error('ExportContentStatsCacheService: 载入缓存失败', error)
this.store = { version: CACHE_VERSION, scopes: {} }
}
}
getScope(scopeKey: string): ExportContentScopeStatsEntry | undefined {
if (!scopeKey) return undefined
const rawScope = this.store.scopes[scopeKey]
if (!rawScope) return undefined
const normalizedScope = normalizeScopeStatsEntry(rawScope)
if (!normalizedScope) {
delete this.store.scopes[scopeKey]
this.persist()
return undefined
}
this.store.scopes[scopeKey] = normalizedScope
return cloneScope(normalizedScope)
}
setScope(scopeKey: string, scope: ExportContentScopeStatsEntry): void {
if (!scopeKey) return
const normalized = normalizeScopeStatsEntry(scope)
if (!normalized) return
this.store.scopes[scopeKey] = normalized
this.trimScope(scopeKey)
this.trimScopes()
this.persist()
}
deleteSession(scopeKey: string, sessionId: string): void {
if (!scopeKey || !sessionId) return
const scope = this.store.scopes[scopeKey]
if (!scope) return
if (!(sessionId in scope.sessions)) return
delete scope.sessions[sessionId]
if (Object.keys(scope.sessions).length === 0) {
delete this.store.scopes[scopeKey]
} else {
scope.updatedAt = Date.now()
}
this.persist()
}
clearScope(scopeKey: string): void {
if (!scopeKey) return
if (!this.store.scopes[scopeKey]) return
delete this.store.scopes[scopeKey]
this.persist()
}
clearAll(): void {
this.store = { version: CACHE_VERSION, scopes: {} }
try {
rmSync(this.cacheFilePath, { force: true })
} catch (error) {
console.error('ExportContentStatsCacheService: 清理缓存失败', error)
}
}
private trimScope(scopeKey: string): void {
const scope = this.store.scopes[scopeKey]
if (!scope) return
const entries = Object.entries(scope.sessions)
if (entries.length <= MAX_SESSION_ENTRIES_PER_SCOPE) return
entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
scope.sessions = Object.fromEntries(entries.slice(0, MAX_SESSION_ENTRIES_PER_SCOPE))
}
private trimScopes(): void {
const scopeEntries = Object.entries(this.store.scopes)
if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return
scopeEntries.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
this.store.scopes = Object.fromEntries(scopeEntries.slice(0, MAX_SCOPE_ENTRIES))
}
private persist(): void {
try {
this.ensureCacheDir()
writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8')
} catch (error) {
console.error('ExportContentStatsCacheService: 持久化缓存失败', error)
}
}
}

View File

@@ -1,331 +0,0 @@
:root {
color-scheme: light;
--bg: #f6f7fb;
--card: #ffffff;
--text: #1f2a37;
--muted: #6b7280;
--accent: #4f46e5;
--sent: #dbeafe;
--received: #ffffff;
--border: #e5e7eb;
--shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
--radius: 16px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
}
.page {
max-width: 1080px;
margin: 0 auto;
padding: 8px 20px;
height: 100vh;
display: flex;
flex-direction: column;
}
.header {
background: var(--card);
border-radius: 12px;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
padding: 12px 20px;
flex-shrink: 0;
}
.title {
font-size: 16px;
font-weight: 600;
margin: 0;
display: inline;
}
.meta {
color: var(--muted);
font-size: 13px;
display: inline;
margin-left: 12px;
}
.meta span {
margin-right: 10px;
}
.controls {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
.controls input,
.controls button {
border-radius: 8px;
border: 1px solid var(--border);
padding: 6px 10px;
font-size: 13px;
font-family: inherit;
}
.controls input[type="search"] {
width: 200px;
}
.controls input[type="datetime-local"] {
width: 200px;
}
.controls button {
background: var(--accent);
color: #fff;
border: none;
cursor: pointer;
padding: 6px 14px;
}
.controls button:active {
transform: scale(0.98);
}
.stats {
font-size: 13px;
color: var(--muted);
margin-left: auto;
}
.message-list {
display: flex;
flex-direction: column;
gap: 12px;
padding: 4px 0;
}
.message {
display: flex;
flex-direction: column;
gap: 8px;
}
.message.hidden {
display: none;
}
.message-time {
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
.message-row {
display: flex;
gap: 12px;
align-items: flex-end;
}
.message.sent .message-row {
flex-direction: row-reverse;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 12px;
background: #eef2ff;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
color: #475569;
font-weight: 600;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.bubble {
max-width: min(70%, 720px);
background: var(--received);
border-radius: 18px;
padding: 12px 14px;
border: 1px solid var(--border);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
}
.message.sent .bubble {
background: var(--sent);
border-color: transparent;
}
.sender-name {
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
.message-content {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 14px;
line-height: 1.6;
}
.message-text {
word-break: break-word;
}
.quoted-message {
border-left: 3px solid rgba(79, 70, 229, 0.35);
background: rgba(79, 70, 229, 0.06);
border-radius: 12px;
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 4px;
}
.message.sent .quoted-message {
background: rgba(37, 99, 235, 0.08);
border-left-color: rgba(37, 99, 235, 0.35);
}
.quoted-sender {
font-size: 12px;
color: #374151;
font-weight: 600;
}
.quoted-text {
font-size: 13px;
color: #4b5563;
word-break: break-word;
}
.message-link-card {
color: #2563eb;
text-decoration: underline;
text-underline-offset: 2px;
word-break: break-all;
}
.message-link-card:hover {
color: #1d4ed8;
}
.inline-emoji {
width: 22px;
height: 22px;
vertical-align: text-bottom;
margin: 0 2px;
}
.message-media {
border-radius: 14px;
max-width: 100%;
}
.previewable {
cursor: zoom-in;
}
.message-media.image,
.message-media.emoji {
max-height: 260px;
object-fit: contain;
background: #f1f5f9;
padding: 6px;
}
.message-media.emoji {
max-height: 160px;
width: auto;
}
.message-media.video {
max-height: 360px;
background: #111827;
}
.message-media.audio {
width: 260px;
}
.image-preview {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.7);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
z-index: 999;
}
.image-preview.active {
opacity: 1;
pointer-events: auto;
}
.image-preview img {
max-width: min(90vw, 1200px);
max-height: 90vh;
border-radius: 18px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35);
background: #0f172a;
transition: transform 0.1s ease;
cursor: zoom-out;
}
.highlight {
outline: 2px solid var(--accent);
outline-offset: 4px;
border-radius: 18px;
transition: outline-color 0.3s;
}
.empty {
text-align: center;
color: var(--muted);
padding: 40px;
}
/* Scroll Container */
.scroll-container {
flex: 1;
min-height: 0;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
margin-top: 8px;
margin-bottom: 8px;
padding: 12px;
-webkit-overflow-scrolling: touch;
}
.scroll-container::-webkit-scrollbar {
width: 6px;
}
.scroll-container::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.load-sentinel {
height: 1px;
}

View File

@@ -1,333 +0,0 @@
export const EXPORT_HTML_STYLES = `:root {
color-scheme: light;
--bg: #f6f7fb;
--card: #ffffff;
--text: #1f2a37;
--muted: #6b7280;
--accent: #4f46e5;
--sent: #dbeafe;
--received: #ffffff;
--border: #e5e7eb;
--shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
--radius: 16px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
}
.page {
max-width: 1080px;
margin: 0 auto;
padding: 8px 20px;
height: 100vh;
display: flex;
flex-direction: column;
}
.header {
background: var(--card);
border-radius: 12px;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
padding: 12px 20px;
flex-shrink: 0;
}
.title {
font-size: 16px;
font-weight: 600;
margin: 0;
display: inline;
}
.meta {
color: var(--muted);
font-size: 13px;
display: inline;
margin-left: 12px;
}
.meta span {
margin-right: 10px;
}
.controls {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
.controls input,
.controls button {
border-radius: 8px;
border: 1px solid var(--border);
padding: 6px 10px;
font-size: 13px;
font-family: inherit;
}
.controls input[type="search"] {
width: 200px;
}
.controls input[type="datetime-local"] {
width: 200px;
}
.controls button {
background: var(--accent);
color: #fff;
border: none;
cursor: pointer;
padding: 6px 14px;
}
.controls button:active {
transform: scale(0.98);
}
.stats {
font-size: 13px;
color: var(--muted);
margin-left: auto;
}
.message-list {
display: flex;
flex-direction: column;
gap: 12px;
padding: 4px 0;
}
.message {
display: flex;
flex-direction: column;
gap: 8px;
}
.message.hidden {
display: none;
}
.message-time {
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
.message-row {
display: flex;
gap: 12px;
align-items: flex-end;
}
.message.sent .message-row {
flex-direction: row-reverse;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 12px;
background: #eef2ff;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
color: #475569;
font-weight: 600;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.bubble {
max-width: min(70%, 720px);
background: var(--received);
border-radius: 18px;
padding: 12px 14px;
border: 1px solid var(--border);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
}
.message.sent .bubble {
background: var(--sent);
border-color: transparent;
}
.sender-name {
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
.message-content {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 14px;
line-height: 1.6;
}
.message-text {
word-break: break-word;
}
.quoted-message {
border-left: 3px solid rgba(79, 70, 229, 0.35);
background: rgba(79, 70, 229, 0.06);
border-radius: 12px;
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 4px;
}
.message.sent .quoted-message {
background: rgba(37, 99, 235, 0.08);
border-left-color: rgba(37, 99, 235, 0.35);
}
.quoted-sender {
font-size: 12px;
color: #374151;
font-weight: 600;
}
.quoted-text {
font-size: 13px;
color: #4b5563;
word-break: break-word;
}
.message-link-card {
color: #2563eb;
text-decoration: underline;
text-underline-offset: 2px;
word-break: break-all;
}
.message-link-card:hover {
color: #1d4ed8;
}
.inline-emoji {
width: 22px;
height: 22px;
vertical-align: text-bottom;
margin: 0 2px;
}
.message-media {
border-radius: 14px;
max-width: 100%;
}
.previewable {
cursor: zoom-in;
}
.message-media.image,
.message-media.emoji {
max-height: 260px;
object-fit: contain;
background: #f1f5f9;
padding: 6px;
}
.message-media.emoji {
max-height: 160px;
width: auto;
}
.message-media.video {
max-height: 360px;
background: #111827;
}
.message-media.audio {
width: 260px;
}
.image-preview {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.7);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
z-index: 999;
}
.image-preview.active {
opacity: 1;
pointer-events: auto;
}
.image-preview img {
max-width: min(90vw, 1200px);
max-height: 90vh;
border-radius: 18px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35);
background: #0f172a;
transition: transform 0.1s ease;
cursor: zoom-out;
}
.highlight {
outline: 2px solid var(--accent);
outline-offset: 4px;
border-radius: 18px;
transition: outline-color 0.3s;
}
.empty {
text-align: center;
color: var(--muted);
padding: 40px;
}
/* Scroll Container */
.scroll-container {
flex: 1;
min-height: 0;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
margin-top: 8px;
margin-bottom: 8px;
padding: 12px;
-webkit-overflow-scrolling: touch;
}
.scroll-container::-webkit-scrollbar {
width: 6px;
}
.scroll-container::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.load-sentinel {
height: 1px;
}
`;

View File

@@ -1,96 +0,0 @@
import { app } from 'electron'
import fs from 'fs'
import path from 'path'
export interface ExportRecord {
exportTime: number
format: string
messageCount: number
sourceLatestMessageTimestamp?: number
outputPath?: string
}
type RecordStore = Record<string, ExportRecord[]>
class ExportRecordService {
private filePath: string | null = null
private loaded = false
private store: RecordStore = {}
private resolveFilePath(): string {
if (this.filePath) return this.filePath
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
const userDataPath = workerUserDataPath || app?.getPath?.('userData') || process.cwd()
fs.mkdirSync(userDataPath, { recursive: true })
this.filePath = path.join(userDataPath, 'weflow-export-records.json')
return this.filePath
}
private ensureLoaded(): void {
if (this.loaded) return
this.loaded = true
const filePath = this.resolveFilePath()
try {
if (!fs.existsSync(filePath)) return
const raw = fs.readFileSync(filePath, 'utf-8')
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object') {
this.store = parsed as RecordStore
}
} catch {
this.store = {}
}
}
private persist(): void {
try {
const filePath = this.resolveFilePath()
fs.writeFileSync(filePath, JSON.stringify(this.store), 'utf-8')
} catch {
// ignore persist errors to avoid blocking export flow
}
}
getLatestRecord(sessionId: string, format: string): ExportRecord | null {
this.ensureLoaded()
const records = this.store[sessionId]
if (!records || records.length === 0) return null
for (let i = records.length - 1; i >= 0; i--) {
const record = records[i]
if (record && record.format === format) return record
}
return null
}
saveRecord(
sessionId: string,
format: string,
messageCount: number,
extra?: {
sourceLatestMessageTimestamp?: number
outputPath?: string
}
): void {
this.ensureLoaded()
const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId) return
if (!this.store[normalizedSessionId]) {
this.store[normalizedSessionId] = []
}
const list = this.store[normalizedSessionId]
list.push({
exportTime: Date.now(),
format,
messageCount,
sourceLatestMessageTimestamp: extra?.sourceLatestMessageTimestamp,
outputPath: extra?.outputPath
})
// keep the latest 30 records per session
if (list.length > 30) {
this.store[normalizedSessionId] = list.slice(-30)
}
this.persist()
}
}
export const exportRecordService = new ExportRecordService()

File diff suppressed because it is too large Load Diff

View File

@@ -1,210 +0,0 @@
import * as path from 'path'
import { rm, rmdir } from 'fs/promises'
export type ExportTaskControlState = 'running' | 'pause_requested' | 'cancel_requested'
export interface ExportTaskControlHooks {
shouldPause: () => boolean
shouldStop: () => boolean
recordCreatedFile: (filePath: string) => void
recordCreatedDir: (dirPath: string) => void
}
interface ExportTaskManifest {
outputDir: string
files: Set<string>
dirs: Set<string>
}
interface ExportTaskControlRecord {
state: ExportTaskControlState
manifest: ExportTaskManifest
createdAt: number
updatedAt: number
}
export interface ExportTaskCleanupResult {
success: boolean
filesDeleted: number
dirsDeleted: number
error?: string
}
class ExportTaskControlService {
private tasks = new Map<string, ExportTaskControlRecord>()
createControl(taskId: string, outputDir: string): ExportTaskControlHooks {
this.registerTask(taskId, outputDir)
return {
shouldPause: () => this.getState(taskId) === 'pause_requested',
shouldStop: () => this.getState(taskId) === 'cancel_requested',
recordCreatedFile: (filePath: string) => this.recordCreatedFile(taskId, filePath),
recordCreatedDir: (dirPath: string) => this.recordCreatedDir(taskId, dirPath)
}
}
registerTask(taskId: string, outputDir: string): void {
const normalizedTaskId = this.normalizeTaskId(taskId)
if (!normalizedTaskId) return
const normalizedOutputDir = path.resolve(String(outputDir || '').trim() || '.')
const existing = this.tasks.get(normalizedTaskId)
if (existing) {
existing.state = 'running'
existing.updatedAt = Date.now()
if (!existing.manifest.outputDir) {
existing.manifest.outputDir = normalizedOutputDir
}
return
}
this.tasks.set(normalizedTaskId, {
state: 'running',
manifest: {
outputDir: normalizedOutputDir,
files: new Set<string>(),
dirs: new Set<string>()
},
createdAt: Date.now(),
updatedAt: Date.now()
})
}
pauseTask(taskId: string): boolean {
return this.setState(taskId, 'pause_requested')
}
resumeTask(taskId: string): boolean {
return this.setState(taskId, 'running')
}
cancelTask(taskId: string): boolean {
return this.setState(taskId, 'cancel_requested')
}
getState(taskId: string): ExportTaskControlState | null {
const normalizedTaskId = this.normalizeTaskId(taskId)
if (!normalizedTaskId) return null
return this.tasks.get(normalizedTaskId)?.state || null
}
releaseTask(taskId: string): void {
const normalizedTaskId = this.normalizeTaskId(taskId)
if (!normalizedTaskId) return
this.tasks.delete(normalizedTaskId)
}
recordCreatedFile(taskId: string, filePath: string): void {
const task = this.getTaskForManifestWrite(taskId, filePath)
if (!task) return
task.manifest.files.add(path.resolve(filePath))
task.updatedAt = Date.now()
}
recordCreatedDir(taskId: string, dirPath: string): void {
const task = this.getTaskForManifestWrite(taskId, dirPath)
if (!task) return
task.manifest.dirs.add(path.resolve(dirPath))
task.updatedAt = Date.now()
}
async cleanupTask(taskId: string): Promise<ExportTaskCleanupResult> {
const normalizedTaskId = this.normalizeTaskId(taskId)
const task = normalizedTaskId ? this.tasks.get(normalizedTaskId) : undefined
if (!task) {
return { success: true, filesDeleted: 0, dirsDeleted: 0 }
}
const outputDir = task.manifest.outputDir
let filesDeleted = 0
let dirsDeleted = 0
const errors: string[] = []
const files = Array.from(task.manifest.files)
.filter(filePath => this.isInsideOutputDir(filePath, outputDir))
.sort((a, b) => b.length - a.length)
for (const filePath of files) {
try {
await rm(filePath, { force: true, recursive: false })
filesDeleted++
} catch (error) {
const code = (error as NodeJS.ErrnoException | undefined)?.code
if (code !== 'ENOENT') {
errors.push(`${filePath}: ${error instanceof Error ? error.message : String(error)}`)
}
}
}
const dirs = Array.from(task.manifest.dirs)
.filter(dirPath => this.isInsideOutputDir(dirPath, outputDir) || this.isSamePath(dirPath, outputDir))
.sort((a, b) => b.length - a.length)
for (const dirPath of dirs) {
try {
await rmdir(dirPath)
dirsDeleted++
} catch (error) {
const code = (error as NodeJS.ErrnoException | undefined)?.code
if (code !== 'ENOENT' && code !== 'ENOTEMPTY' && code !== 'EEXIST') {
errors.push(`${dirPath}: ${error instanceof Error ? error.message : String(error)}`)
}
}
}
if (errors.length === 0) {
this.releaseTask(normalizedTaskId)
return { success: true, filesDeleted, dirsDeleted }
}
return {
success: false,
filesDeleted,
dirsDeleted,
error: errors.slice(0, 3).join('; ')
}
}
private setState(taskId: string, state: ExportTaskControlState): boolean {
const normalizedTaskId = this.normalizeTaskId(taskId)
if (!normalizedTaskId) return false
const task = this.tasks.get(normalizedTaskId)
if (!task) return false
task.state = state
task.updatedAt = Date.now()
return true
}
private getTaskForManifestWrite(taskId: string, targetPath: string): ExportTaskControlRecord | null {
const normalizedTaskId = this.normalizeTaskId(taskId)
if (!normalizedTaskId) return null
const task = this.tasks.get(normalizedTaskId)
if (!task) return null
if (!this.isInsideOutputDir(targetPath, task.manifest.outputDir) && !this.isSamePath(targetPath, task.manifest.outputDir)) {
return null
}
return task
}
private isInsideOutputDir(targetPath: string, outputDir: string): boolean {
const resolvedTarget = path.resolve(targetPath)
const resolvedOutputDir = path.resolve(outputDir)
const relativePath = path.relative(resolvedOutputDir, resolvedTarget)
return Boolean(relativePath) && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)
}
private isSamePath(left: string, right: string): boolean {
const resolvedLeft = path.resolve(left)
const resolvedRight = path.resolve(right)
if (process.platform === 'win32') {
return resolvedLeft.toLowerCase() === resolvedRight.toLowerCase()
}
return resolvedLeft === resolvedRight
}
private normalizeTaskId(taskId: string): string {
return String(taskId || '').trim()
}
}
export const exportTaskControlService = new ExportTaskControlService()

File diff suppressed because it is too large Load Diff

View File

@@ -1,204 +0,0 @@
import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
import { ConfigService } from './config'
const CACHE_VERSION = 1
const MAX_GROUP_ENTRIES_PER_SCOPE = 3000
const MAX_SCOPE_ENTRIES = 12
export interface GroupMyMessageCountCacheEntry {
updatedAt: number
messageCount: number
}
interface GroupMyMessageCountScopeMap {
[chatroomId: string]: GroupMyMessageCountCacheEntry
}
interface GroupMyMessageCountCacheStore {
version: number
scopes: Record<string, GroupMyMessageCountScopeMap>
}
function toNonNegativeInt(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
return Math.max(0, Math.floor(value))
}
function normalizeEntry(raw: unknown): GroupMyMessageCountCacheEntry | null {
if (!raw || typeof raw !== 'object') return null
const source = raw as Record<string, unknown>
const updatedAt = toNonNegativeInt(source.updatedAt)
const messageCount = toNonNegativeInt(source.messageCount)
if (updatedAt === undefined || messageCount === undefined) return null
return {
updatedAt,
messageCount
}
}
export class GroupMyMessageCountCacheService {
private readonly cacheFilePath: string
private store: GroupMyMessageCountCacheStore = {
version: CACHE_VERSION,
scopes: {}
}
constructor(cacheBasePath?: string) {
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
? cacheBasePath
: ConfigService.getInstance().getCacheBasePath()
this.cacheFilePath = join(basePath, 'group-my-message-counts.json')
this.ensureCacheDir()
this.load()
}
private ensureCacheDir(): void {
const dir = dirname(this.cacheFilePath)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
}
private load(): void {
if (!existsSync(this.cacheFilePath)) return
try {
const raw = readFileSync(this.cacheFilePath, 'utf8')
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object') {
this.store = { version: CACHE_VERSION, scopes: {} }
return
}
const payload = parsed as Record<string, unknown>
const scopesRaw = payload.scopes
if (!scopesRaw || typeof scopesRaw !== 'object') {
this.store = { version: CACHE_VERSION, scopes: {} }
return
}
const scopes: Record<string, GroupMyMessageCountScopeMap> = {}
for (const [scopeKey, scopeValue] of Object.entries(scopesRaw as Record<string, unknown>)) {
if (!scopeValue || typeof scopeValue !== 'object') continue
const normalizedScope: GroupMyMessageCountScopeMap = {}
for (const [chatroomId, entryRaw] of Object.entries(scopeValue as Record<string, unknown>)) {
const entry = normalizeEntry(entryRaw)
if (!entry) continue
normalizedScope[chatroomId] = entry
}
if (Object.keys(normalizedScope).length > 0) {
scopes[scopeKey] = normalizedScope
}
}
this.store = {
version: CACHE_VERSION,
scopes
}
} catch (error) {
console.error('GroupMyMessageCountCacheService: 载入缓存失败', error)
this.store = { version: CACHE_VERSION, scopes: {} }
}
}
get(scopeKey: string, chatroomId: string): GroupMyMessageCountCacheEntry | undefined {
if (!scopeKey || !chatroomId) return undefined
const scope = this.store.scopes[scopeKey]
if (!scope) return undefined
const entry = normalizeEntry(scope[chatroomId])
if (!entry) {
delete scope[chatroomId]
if (Object.keys(scope).length === 0) {
delete this.store.scopes[scopeKey]
}
this.persist()
return undefined
}
return entry
}
set(scopeKey: string, chatroomId: string, entry: GroupMyMessageCountCacheEntry): void {
if (!scopeKey || !chatroomId) return
const normalized = normalizeEntry(entry)
if (!normalized) return
if (!this.store.scopes[scopeKey]) {
this.store.scopes[scopeKey] = {}
}
const existing = this.store.scopes[scopeKey][chatroomId]
if (existing && existing.updatedAt > normalized.updatedAt) {
return
}
this.store.scopes[scopeKey][chatroomId] = normalized
this.trimScope(scopeKey)
this.trimScopes()
this.persist()
}
delete(scopeKey: string, chatroomId: string): void {
if (!scopeKey || !chatroomId) return
const scope = this.store.scopes[scopeKey]
if (!scope) return
if (!(chatroomId in scope)) return
delete scope[chatroomId]
if (Object.keys(scope).length === 0) {
delete this.store.scopes[scopeKey]
}
this.persist()
}
clearScope(scopeKey: string): void {
if (!scopeKey) return
if (!this.store.scopes[scopeKey]) return
delete this.store.scopes[scopeKey]
this.persist()
}
clearAll(): void {
this.store = { version: CACHE_VERSION, scopes: {} }
try {
rmSync(this.cacheFilePath, { force: true })
} catch (error) {
console.error('GroupMyMessageCountCacheService: 清理缓存失败', error)
}
}
private trimScope(scopeKey: string): void {
const scope = this.store.scopes[scopeKey]
if (!scope) return
const entries = Object.entries(scope)
if (entries.length <= MAX_GROUP_ENTRIES_PER_SCOPE) return
entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
const trimmed: GroupMyMessageCountScopeMap = {}
for (const [chatroomId, entry] of entries.slice(0, MAX_GROUP_ENTRIES_PER_SCOPE)) {
trimmed[chatroomId] = entry
}
this.store.scopes[scopeKey] = trimmed
}
private trimScopes(): void {
const scopeEntries = Object.entries(this.store.scopes)
if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return
scopeEntries.sort((a, b) => {
const aUpdatedAt = Math.max(...Object.values(a[1]).map((entry) => entry.updatedAt), 0)
const bUpdatedAt = Math.max(...Object.values(b[1]).map((entry) => entry.updatedAt), 0)
return bUpdatedAt - aUpdatedAt
})
const trimmedScopes: Record<string, GroupMyMessageCountScopeMap> = {}
for (const [scopeKey, scopeMap] of scopeEntries.slice(0, MAX_SCOPE_ENTRIES)) {
trimmedScopes[scopeKey] = scopeMap
}
this.store.scopes = trimmedScopes
}
private persist(): void {
try {
writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8')
} catch (error) {
console.error('GroupMyMessageCountCacheService: 保存缓存失败', error)
}
}
}

View File

@@ -1,384 +0,0 @@
import { app } from 'electron'
import fs from 'fs'
import path from 'path'
import { createHash, randomUUID } from 'crypto'
import { ConfigService } from './config'
export type GroupSummaryTriggerType = 'auto' | 'manual'
export interface GroupSummaryTopic {
title: string
participants: string[]
keyPoints: string[]
conclusion: string
}
export interface GroupSummaryLog {
endpoint: string
model: string
temperature: number
triggerType: GroupSummaryTriggerType
periodStart: number
periodEnd: number
messageCount: number
readableMessageCount: number
systemPrompt: string
userPrompt: string
rawOutput: string
finalSummary: string
durationMs: number
createdAt: number
responseFormatJson?: boolean
responseFormatFallback?: boolean
responseFormatFallbackReason?: string
parsedTopics?: GroupSummaryTopic[]
}
export interface GroupSummaryRecord {
id: string
accountScope: string
createdAt: number
sessionId: string
displayName: string
avatarUrl?: string
triggerType: GroupSummaryTriggerType
periodStart: number
periodEnd: number
messageCount: number
readableMessageCount: number
topics: GroupSummaryTopic[]
summaryText: string
rawOutput: string
log: GroupSummaryLog
}
export interface GroupSummaryRecordSummary {
id: string
createdAt: number
sessionId: string
displayName: string
avatarUrl?: string
triggerType: GroupSummaryTriggerType
periodStart: number
periodEnd: number
messageCount: number
readableMessageCount: number
topics: GroupSummaryTopic[]
summaryText: string
}
export interface GroupSummaryRecordFilters {
sessionId?: string
startTime?: number
endTime?: number
limit?: number
offset?: number
}
export interface GroupSummaryRecordListResult {
success: boolean
records: GroupSummaryRecordSummary[]
total: number
error?: string
}
interface GroupSummaryIndexRecord extends GroupSummaryRecordSummary {
accountScope: string
logFile?: string
}
interface LegacyGroupSummaryRecord extends GroupSummaryIndexRecord {
rawOutput?: string
log?: GroupSummaryLog
}
class GroupSummaryRecordService {
private readonly maxRecordsPerScope = 2000
private filePath: string | null = null
private logDir: string | null = null
private loaded = false
private records: GroupSummaryIndexRecord[] = []
private resolveUserDataPath(): string {
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
const userDataPath = workerUserDataPath || app?.getPath?.('userData') || process.cwd()
fs.mkdirSync(userDataPath, { recursive: true })
return userDataPath
}
private resolveFilePath(): string {
if (this.filePath) return this.filePath
this.filePath = path.join(this.resolveUserDataPath(), 'weflow-group-summary-records.json')
return this.filePath
}
private resolveLogDir(): string {
if (this.logDir) return this.logDir
this.logDir = path.join(this.resolveUserDataPath(), 'weflow-group-summary-logs')
fs.mkdirSync(this.logDir, { recursive: true })
return this.logDir
}
private normalizeTimestampSeconds(value: unknown): number {
const numeric = Number(value || 0)
if (!Number.isFinite(numeric) || numeric <= 0) return 0
let normalized = Math.floor(numeric)
while (normalized > 10000000000) {
normalized = Math.floor(normalized / 1000)
}
return normalized
}
private safeLogFileName(id: string): string {
const normalized = String(id || '').replace(/[^a-zA-Z0-9_-]/g, '')
return `${normalized || randomUUID()}.json`
}
private writeLogFile(recordId: string, log: GroupSummaryLog, rawOutput: string): string | undefined {
try {
const fileName = this.safeLogFileName(recordId)
const logPath = path.join(this.resolveLogDir(), fileName)
fs.writeFileSync(logPath, JSON.stringify({ version: 1, rawOutput, log }, null, 2), 'utf-8')
return fileName
} catch {
return undefined
}
}
private readLogFile(fileName?: string): { rawOutput: string; log: GroupSummaryLog } | null {
if (!fileName) return null
try {
const logPath = path.join(this.resolveLogDir(), this.safeLogFileName(fileName.replace(/\.json$/i, '')))
if (!fs.existsSync(logPath)) return null
const parsed = JSON.parse(fs.readFileSync(logPath, 'utf-8'))
const log = parsed?.log
if (!log || typeof log !== 'object') return null
return {
rawOutput: typeof parsed?.rawOutput === 'string' ? parsed.rawOutput : String(log.rawOutput || ''),
log: log as GroupSummaryLog
}
} catch {
return null
}
}
private ensureLoaded(): void {
if (this.loaded) return
this.loaded = true
const filePath = this.resolveFilePath()
try {
if (!fs.existsSync(filePath)) return
const raw = fs.readFileSync(filePath, 'utf-8')
const parsed = JSON.parse(raw)
const records = Array.isArray(parsed) ? parsed : parsed?.records
if (!Array.isArray(records)) return
const legacyRecords = records.filter((item) => item && typeof item === 'object') as LegacyGroupSummaryRecord[]
const needsMigration = legacyRecords.some((record) => Boolean(record.log || record.rawOutput))
if (needsMigration) {
this.backupLegacyFile(filePath)
}
this.records = legacyRecords.map((record) => {
const id = String(record.id || randomUUID())
const logFile = record.log
? this.writeLogFile(id, record.log, String(record.rawOutput || record.log.rawOutput || ''))
: record.logFile
return {
id,
accountScope: String(record.accountScope || 'default'),
createdAt: Number(record.createdAt || Date.now()),
sessionId: String(record.sessionId || ''),
displayName: String(record.displayName || record.sessionId || ''),
avatarUrl: record.avatarUrl,
triggerType: record.triggerType === 'auto' ? 'auto' : 'manual',
periodStart: this.normalizeTimestampSeconds(record.periodStart),
periodEnd: this.normalizeTimestampSeconds(record.periodEnd),
messageCount: Math.max(0, Math.floor(Number(record.messageCount || 0))),
readableMessageCount: Math.max(0, Math.floor(Number(record.readableMessageCount || 0))),
topics: Array.isArray(record.topics) ? record.topics : [],
summaryText: String(record.summaryText || ''),
logFile
}
}).filter((record) => record.sessionId && record.periodStart > 0 && record.periodEnd > record.periodStart)
if (needsMigration) {
this.persist()
}
} catch {
this.records = []
}
}
private backupLegacyFile(filePath: string): void {
try {
const backupPath = `${filePath}.legacy-${Date.now()}.bak`
if (!fs.existsSync(backupPath)) {
fs.copyFileSync(filePath, backupPath)
}
} catch {
// Backup failure should not block reading existing records.
}
}
private persist(): void {
try {
const filePath = this.resolveFilePath()
fs.writeFileSync(filePath, JSON.stringify({ version: 2, records: this.records }, null, 2), 'utf-8')
} catch {
// Summary generation should not fail because local record persistence failed.
}
}
private getCurrentAccountScope(): string {
const config = ConfigService.getInstance()
const myWxid = String(config.getMyWxidCleaned() || '').trim()
if (myWxid) return `wxid:${myWxid}`
const dbPath = String(config.get('dbPath') || '').trim()
if (dbPath) {
const hash = createHash('sha1').update(dbPath).digest('hex').slice(0, 16)
return `db:${hash}`
}
return 'default'
}
private toSummary(record: GroupSummaryIndexRecord): GroupSummaryRecordSummary {
return {
id: record.id,
createdAt: record.createdAt,
sessionId: record.sessionId,
displayName: record.displayName,
avatarUrl: record.avatarUrl,
triggerType: record.triggerType,
periodStart: record.periodStart,
periodEnd: record.periodEnd,
messageCount: record.messageCount,
readableMessageCount: record.readableMessageCount,
topics: Array.isArray(record.topics) ? record.topics : [],
summaryText: record.summaryText || ''
}
}
private getScopedRecords(): GroupSummaryIndexRecord[] {
this.ensureLoaded()
const scope = this.getCurrentAccountScope()
return this.records.filter((record) => record.accountScope === scope)
}
addRecord(input: {
sessionId: string
displayName: string
avatarUrl?: string
triggerType: GroupSummaryTriggerType
periodStart: number
periodEnd: number
messageCount: number
readableMessageCount: number
topics: GroupSummaryTopic[]
summaryText: string
rawOutput: string
log: GroupSummaryLog
}): GroupSummaryRecordSummary {
this.ensureLoaded()
const scope = this.getCurrentAccountScope()
const id = randomUUID()
const logFile = this.writeLogFile(id, input.log, input.rawOutput)
const record: GroupSummaryIndexRecord = {
id,
accountScope: scope,
createdAt: Date.now(),
sessionId: input.sessionId,
displayName: input.displayName,
avatarUrl: input.avatarUrl,
triggerType: input.triggerType,
periodStart: input.periodStart,
periodEnd: input.periodEnd,
messageCount: input.messageCount,
readableMessageCount: input.readableMessageCount,
topics: input.topics,
summaryText: input.summaryText,
logFile
}
this.records.push(record)
const scopedRecords = this.records
.filter((item) => item.accountScope === scope)
.sort((a, b) => b.createdAt - a.createdAt)
const keepIds = new Set(scopedRecords.slice(0, this.maxRecordsPerScope).map((item) => item.id))
this.records = this.records.filter((item) => item.accountScope !== scope || keepIds.has(item.id))
this.persist()
return this.toSummary(record)
}
hasAutoRecord(sessionId: string, periodStart: number, periodEnd: number): boolean {
const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId) return false
return this.getScopedRecords().some((record) =>
record.triggerType === 'auto' &&
record.sessionId === normalizedSessionId &&
Number(record.periodStart || 0) === periodStart &&
Number(record.periodEnd || 0) === periodEnd
)
}
listRecords(filters: GroupSummaryRecordFilters = {}): GroupSummaryRecordListResult {
try {
const sessionId = String(filters.sessionId || '').trim()
const startTime = this.normalizeTimestampSeconds(filters.startTime)
const endTime = this.normalizeTimestampSeconds(filters.endTime)
const offset = Math.max(0, Math.floor(Number(filters.offset || 0)))
const limit = Math.min(200, Math.max(1, Math.floor(Number(filters.limit || 100))))
const filtered = this.getScopedRecords()
.filter((record) => {
if (sessionId && record.sessionId !== sessionId) return false
const periodStart = Number(record.periodStart || 0)
const periodEnd = Number(record.periodEnd || 0)
if (startTime > 0 && periodEnd < startTime) return false
if (endTime > 0 && periodStart > endTime) return false
return true
})
.sort((a, b) => Number(b.periodStart || b.createdAt) - Number(a.periodStart || a.createdAt))
return {
success: true,
records: filtered.slice(offset, offset + limit).map((record) => this.toSummary(record)),
total: filtered.length
}
} catch (error) {
return { success: false, records: [], total: 0, error: (error as Error).message || String(error) }
}
}
getRecord(id: string): { success: boolean; record?: GroupSummaryRecord; error?: string } {
this.ensureLoaded()
const normalizedId = String(id || '').trim()
if (!normalizedId) return { success: false, error: '记录 ID 为空' }
const scope = this.getCurrentAccountScope()
const record = this.records.find((item) => item.id === normalizedId && item.accountScope === scope)
if (!record) return { success: false, error: '未找到该群聊总结记录' }
const logData = this.readLogFile(record.logFile)
if (!logData) return { success: false, error: '未找到该群聊总结日志' }
return {
success: true,
record: {
...this.toSummary(record),
accountScope: record.accountScope,
rawOutput: logData.rawOutput,
log: logData.log
}
}
}
clearRuntimeCache(): void {
this.loaded = false
this.records = []
this.filePath = null
this.logDir = null
}
}
export const groupSummaryRecordService = new GroupSummaryRecordService()

View File

@@ -1,801 +0,0 @@
import https from 'https'
import http from 'http'
import { URL } from 'url'
import groupSummaryPrompt from '../../shared/groupSummaryPrompt.json'
import { ConfigService } from './config'
import { chatService, type Message } from './chatService'
import { wcdbService } from './wcdbService'
import {
groupSummaryRecordService,
type GroupSummaryLog,
type GroupSummaryRecord,
type GroupSummaryRecordFilters,
type GroupSummaryRecordListResult,
type GroupSummaryRecordSummary,
type GroupSummaryTopic,
type GroupSummaryTriggerType
} from './groupSummaryRecordService'
const API_TIMEOUT_MS = 90_000
const API_TEMPERATURE = 0.4
const MIN_SUMMARY_MESSAGES = 5
const MAX_MANUAL_RANGE_SECONDS = 48 * 60 * 60
const MAX_MESSAGES_PER_SUMMARY = 3000
const SUMMARY_CURSOR_BATCH_SIZE = 360
const DEFAULT_GROUP_SUMMARY_SYSTEM_PROMPT = String(groupSummaryPrompt.defaultSystemPrompt || '').trim()
const SUMMARY_CONFIG_KEYS = new Set([
'aiGroupSummaryEnabled',
'aiGroupSummaryIntervalHours',
'aiGroupSummarySystemPrompt',
'aiGroupSummaryFilterMode',
'aiGroupSummaryFilterList',
'aiModelApiBaseUrl',
'aiModelApiKey',
'aiModelApiModel',
'aiInsightApiBaseUrl',
'aiInsightApiKey',
'aiInsightApiModel',
'dbPath',
'decryptKey',
'myWxid'
])
interface SharedAiModelConfig {
apiBaseUrl: string
apiKey: string
model: string
}
interface GroupSummaryTriggerResult {
success: boolean
message: string
recordId?: string
record?: GroupSummaryRecordSummary
skipped?: boolean
skippedReason?: string
}
interface GroupSummaryDayTriggerResult {
success: boolean
message: string
generated: number
skipped: number
records: GroupSummaryRecordSummary[]
}
class ApiRequestError extends Error {
statusCode?: number
responseBody?: string
constructor(message: string, statusCode?: number, responseBody?: string) {
super(message)
this.name = 'ApiRequestError'
this.statusCode = statusCode
this.responseBody = responseBody
}
}
function buildApiUrl(baseUrl: string, path: string): string {
const base = baseUrl.replace(/\/+$/, '')
const suffix = path.startsWith('/') ? path : `/${path}`
return `${base}${suffix}`
}
function normalizeSessionIdList(value: unknown): string[] {
if (!Array.isArray(value)) return []
return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean)))
}
function normalizeIntervalHours(value: unknown): number {
const allowed = new Set([1, 2, 4, 8, 12, 24])
const numeric = Math.floor(Number(value) || 4)
return allowed.has(numeric) ? numeric : 4
}
function getStartOfDaySeconds(date: Date = new Date()): number {
const next = new Date(date)
next.setHours(0, 0, 0, 0)
return Math.floor(next.getTime() / 1000)
}
function clampText(value: unknown, maxLength: number): string {
const text = String(value || '').replace(/\s+/g, ' ').trim()
if (text.length <= maxLength) return text
return `${text.slice(0, Math.max(0, maxLength - 1))}`
}
function stripJsonFence(value: string): string {
const text = String(value || '').trim()
const fenced = text.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i)
if (fenced) return fenced[1].trim()
const firstBrace = text.indexOf('{')
const lastBrace = text.lastIndexOf('}')
if (firstBrace >= 0 && lastBrace > firstBrace) {
return text.slice(firstBrace, lastBrace + 1).trim()
}
return text
}
function shouldFallbackJsonMode(error: unknown): boolean {
const statusCode = (error as ApiRequestError)?.statusCode
if (statusCode === 400 || statusCode === 404 || statusCode === 422) return true
const text = `${(error as Error)?.message || ''}\n${(error as ApiRequestError)?.responseBody || ''}`.toLowerCase()
return text.includes('response_format') || text.includes('json_object') || text.includes('json mode')
}
function formatTimestamp(createTime: number): string {
const ms = createTime > 1_000_000_000_000 ? createTime : createTime * 1000
const date = new Date(ms)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
function callChatCompletions(
apiBaseUrl: string,
apiKey: string,
model: string,
messages: Array<{ role: string; content: string }>,
options?: { responseFormatJson?: boolean }
): Promise<string> {
return new Promise((resolve, reject) => {
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
let urlObj: URL
try {
urlObj = new URL(endpoint)
} catch {
reject(new Error(`无效的 API URL: ${endpoint}`))
return
}
const payload: Record<string, unknown> = {
model,
messages,
temperature: API_TEMPERATURE,
stream: false
}
if (options?.responseFormatJson) {
payload.response_format = { type: 'json_object' }
}
const body = JSON.stringify(payload)
const requestOptions = {
hostname: urlObj.hostname,
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
path: urlObj.pathname + urlObj.search,
method: 'POST' as const,
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body).toString(),
Authorization: `Bearer ${apiKey}`
}
}
const requestFn = urlObj.protocol === 'https:' ? https.request : http.request
const req = requestFn(requestOptions, (res) => {
let data = ''
res.on('data', (chunk) => { data += chunk })
res.on('end', () => {
try {
if (res.statusCode && res.statusCode >= 400) {
reject(new ApiRequestError(`API 请求失败 (${res.statusCode}): ${data.slice(0, 200)}`, res.statusCode, data))
return
}
const parsed = JSON.parse(data)
const content = parsed?.choices?.[0]?.message?.content
if (typeof content === 'string' && content.trim()) {
resolve(content.trim())
} else {
reject(new Error(`API 返回格式异常: ${data.slice(0, 200)}`))
}
} catch {
reject(new Error(`JSON 解析失败: ${data.slice(0, 200)}`))
}
})
})
req.setTimeout(API_TIMEOUT_MS, () => {
req.destroy()
reject(new Error('API 请求超时'))
})
req.on('error', reject)
req.write(body)
req.end()
})
}
function parseTopics(rawOutput: string): GroupSummaryTopic[] {
const parsed = JSON.parse(stripJsonFence(rawOutput)) as unknown
if (!parsed || typeof parsed !== 'object') {
throw new Error('模型输出格式异常JSON 根节点不是对象')
}
const source = parsed as Record<string, unknown>
const rawTopics = Array.isArray(source.topics) ? source.topics : []
const topics = rawTopics.map((item, index) => {
const topic = item && typeof item === 'object' ? item as Record<string, unknown> : {}
const participantsRaw = Array.isArray(topic.participants) ? topic.participants : []
const keyPointsRaw = Array.isArray(topic.key_points)
? topic.key_points
: (Array.isArray(topic.keyPoints) ? topic.keyPoints : [])
return {
title: clampText(topic.title || `话题 ${index + 1}`, 48) || `话题 ${index + 1}`,
participants: participantsRaw.map((value) => clampText(value, 24)).filter(Boolean).slice(0, 12),
keyPoints: keyPointsRaw.map((value) => clampText(value, 120)).filter(Boolean).slice(0, 8),
conclusion: clampText(topic.conclusion, 180) || '无明确结论'
}
}).filter((topic) => topic.title || topic.keyPoints.length > 0 || topic.conclusion)
if (topics.length === 0) {
throw new Error('模型输出格式异常topics 为空')
}
return topics
}
function buildSummaryText(topics: GroupSummaryTopic[]): string {
return topics.map((topic) => {
const participants = topic.participants.length > 0 ? topic.participants.join('、') : '未明确'
const keyPoints = topic.keyPoints.length > 0 ? topic.keyPoints.join('') : '无'
return `${topic.title}】参与者:${participants}。关键/矛盾点:${keyPoints}。结论:${topic.conclusion}`
}).join('\n')
}
function fallbackTopicFromRaw(rawOutput: string): GroupSummaryTopic {
return {
title: '未归类总结',
participants: [],
keyPoints: [clampText(rawOutput, 500)],
conclusion: '模型未按固定 JSON 格式返回,请查看完整日志。'
}
}
class GroupSummaryService {
private config: ConfigService
private started = false
private scanTimer: NodeJS.Timeout | null = null
private processing = false
private pendingAutoRun = false
private dbConnected = false
constructor() {
this.config = ConfigService.getInstance()
}
start(): void {
if (this.started) return
this.started = true
void this.refreshConfiguration('startup')
}
stop(): void {
this.started = false
this.clearTimers()
this.processing = false
this.pendingAutoRun = false
this.dbConnected = false
}
async handleConfigChanged(key: string): Promise<void> {
const normalizedKey = String(key || '').trim()
if (!SUMMARY_CONFIG_KEYS.has(normalizedKey)) return
if (normalizedKey === 'aiGroupSummarySystemPrompt') return
if (normalizedKey === 'dbPath' || normalizedKey === 'decryptKey' || normalizedKey === 'myWxid') {
this.dbConnected = false
groupSummaryRecordService.clearRuntimeCache()
}
await this.refreshConfiguration(`config:${normalizedKey}`)
}
handleConfigCleared(): void {
this.clearTimers()
this.processing = false
this.pendingAutoRun = false
this.dbConnected = false
groupSummaryRecordService.clearRuntimeCache()
}
listRecords(filters?: GroupSummaryRecordFilters): GroupSummaryRecordListResult {
return groupSummaryRecordService.listRecords(filters || {})
}
getRecord(id: string): { success: boolean; record?: GroupSummaryRecord; error?: string } {
return groupSummaryRecordService.getRecord(id)
}
async triggerManual(params: {
sessionId: string
displayName?: string
avatarUrl?: string
startTime: number
endTime: number
}): Promise<GroupSummaryTriggerResult> {
if (!this.isEnabled()) {
return { success: false, message: '请先在设置中开启「AI 群聊总结」' }
}
const sessionId = String(params?.sessionId || '').trim()
if (!sessionId.endsWith('@chatroom')) {
return { success: false, message: 'AI 群聊总结仅支持群聊' }
}
const startTime = this.normalizeTimestampSeconds(params?.startTime)
const endTime = this.normalizeTimestampSeconds(params?.endTime)
if (startTime <= 0 || endTime <= startTime) {
return { success: false, message: '请选择有效的总结时段' }
}
if (endTime - startTime > MAX_MANUAL_RANGE_SECONDS) {
return { success: false, message: '手动总结时段不能超过 48 小时' }
}
const displayName = String(params?.displayName || sessionId).trim() || sessionId
const avatarUrl = String(params?.avatarUrl || '').trim() || undefined
return this.generateSummaryForPeriod({
sessionId,
displayName,
avatarUrl,
periodStart: startTime,
periodEnd: endTime,
triggerType: 'manual'
})
}
async triggerDay(params: {
sessionId: string
displayName?: string
avatarUrl?: string
date: string
}): Promise<GroupSummaryDayTriggerResult> {
if (!this.isEnabled()) {
return { success: false, message: '请先在设置中开启「AI 群聊总结」', generated: 0, skipped: 0, records: [] }
}
const sessionId = String(params?.sessionId || '').trim()
if (!sessionId.endsWith('@chatroom')) {
return { success: false, message: 'AI 群聊总结仅支持群聊', generated: 0, skipped: 0, records: [] }
}
const dayRange = this.parseLocalDateDayRange(params?.date)
if (!dayRange) {
return { success: false, message: '请选择有效日期', generated: 0, skipped: 0, records: [] }
}
const todayStart = getStartOfDaySeconds(new Date())
if (dayRange.start > todayStart) {
return { success: false, message: '不能总结未来日期', generated: 0, skipped: 0, records: [] }
}
const now = Math.floor(Date.now() / 1000)
const effectiveEnd = dayRange.start === todayStart ? Math.min(dayRange.end, now) : dayRange.end
const periods = this.getIntervalPeriods(dayRange.start, effectiveEnd, false)
if (periods.length === 0) {
return { success: true, message: '当前日期暂无已完成的总结时段', generated: 0, skipped: 0, records: [] }
}
const displayName = String(params?.displayName || sessionId).trim() || sessionId
const avatarUrl = String(params?.avatarUrl || '').trim() || undefined
return this.generateSummariesForPeriods({
sessionId,
displayName,
avatarUrl,
periods,
triggerType: 'manual'
})
}
private async refreshConfiguration(_reason: string): Promise<void> {
if (!this.started) return
this.clearTimers()
if (!this.isEnabled()) return
await this.queueDueAutoSummaries()
this.scheduleNextAutoRun()
}
private isEnabled(): boolean {
return this.config.get('aiGroupSummaryEnabled') === true
}
private clearTimers(): void {
if (this.scanTimer !== null) {
clearTimeout(this.scanTimer)
this.scanTimer = null
}
}
private scheduleNextAutoRun(): void {
if (!this.started || !this.isEnabled()) return
const intervalHours = normalizeIntervalHours(this.config.get('aiGroupSummaryIntervalHours'))
const now = Math.floor(Date.now() / 1000)
const dayStart = getStartOfDaySeconds(new Date())
const intervalSeconds = intervalHours * 60 * 60
const elapsed = Math.max(0, now - dayStart)
const nextBoundary = dayStart + (Math.floor(elapsed / intervalSeconds) + 1) * intervalSeconds
const delayMs = Math.max(1_000, (nextBoundary - now) * 1000 + 1_000)
this.scanTimer = setTimeout(async () => {
this.scanTimer = null
await this.queueDueAutoSummaries()
this.scheduleNextAutoRun()
}, delayMs)
}
private async ensureConnected(): Promise<boolean> {
if (this.dbConnected) return true
const result = await chatService.connect()
this.dbConnected = result.success === true
return this.dbConnected
}
private getSharedAiModelConfig(): SharedAiModelConfig {
const apiBaseUrl = String(
this.config.get('aiModelApiBaseUrl')
|| this.config.get('aiInsightApiBaseUrl')
|| ''
).trim()
const apiKey = String(
this.config.get('aiModelApiKey')
|| this.config.get('aiInsightApiKey')
|| ''
).trim()
const model = String(
this.config.get('aiModelApiModel')
|| this.config.get('aiInsightApiModel')
|| 'gpt-4o-mini'
).trim() || 'gpt-4o-mini'
return { apiBaseUrl, apiKey, model }
}
private getAutoScopeSessionIds(): string[] {
return normalizeSessionIdList(this.config.get('aiGroupSummaryFilterList'))
.filter((sessionId) => sessionId.endsWith('@chatroom'))
}
private normalizeTimestampSeconds(value: unknown): number {
const numeric = Number(value || 0)
if (!Number.isFinite(numeric) || numeric <= 0) return 0
let normalized = Math.floor(numeric)
while (normalized > 10000000000) {
normalized = Math.floor(normalized / 1000)
}
return normalized
}
private parseLocalDateDayRange(value: unknown): { start: number; end: number } | null {
const text = String(value || '').trim()
const match = text.match(/^(\d{4})-(\d{2})-(\d{2})$/)
if (!match) return null
const year = Number(match[1])
const month = Number(match[2])
const day = Number(match[3])
const start = new Date(year, month - 1, day, 0, 0, 0, 0)
if (
!Number.isFinite(start.getTime()) ||
start.getFullYear() !== year ||
start.getMonth() !== month - 1 ||
start.getDate() !== day
) {
return null
}
const end = new Date(start)
end.setDate(end.getDate() + 1)
return {
start: Math.floor(start.getTime() / 1000),
end: Math.floor(end.getTime() / 1000)
}
}
private getIntervalPeriods(startTime: number, endTime: number, includePartial: boolean): Array<{ start: number; end: number }> {
const intervalHours = normalizeIntervalHours(this.config.get('aiGroupSummaryIntervalHours'))
const intervalSeconds = intervalHours * 60 * 60
const periods: Array<{ start: number; end: number }> = []
for (let start = startTime; start < endTime; start += intervalSeconds) {
const end = Math.min(start + intervalSeconds, endTime)
if (!includePartial && end - start < intervalSeconds) continue
if (end > start) periods.push({ start, end })
}
return periods
}
private getCompletedPeriodsToday(): Array<{ start: number; end: number }> {
const dayStart = getStartOfDaySeconds(new Date())
const now = Math.floor(Date.now() / 1000)
return this.getIntervalPeriods(dayStart, now, false)
}
private async queueDueAutoSummaries(): Promise<void> {
if (!this.started || !this.isEnabled()) return
if (this.processing) {
this.pendingAutoRun = true
return
}
this.processing = true
try {
do {
this.pendingAutoRun = false
await this.runDueAutoSummariesOnce()
} while (this.pendingAutoRun && this.started && this.isEnabled())
} finally {
this.processing = false
}
}
private async runDueAutoSummariesOnce(): Promise<void> {
if (!this.started || !this.isEnabled()) return
try {
const { apiBaseUrl, apiKey } = this.getSharedAiModelConfig()
if (!apiBaseUrl || !apiKey) return
const scopeSessionIds = this.getAutoScopeSessionIds()
if (scopeSessionIds.length === 0) return
if (!await this.ensureConnected()) return
const contacts = (await chatService.enrichSessionsContactInfo(scopeSessionIds).catch(() => null))?.contacts || {}
const periods = this.getCompletedPeriodsToday()
for (const period of periods) {
for (const sessionId of scopeSessionIds) {
if (!this.started || !this.isEnabled()) return
if (!sessionId) continue
if (groupSummaryRecordService.hasAutoRecord(sessionId, period.start, period.end)) continue
await this.generateSummaryForPeriod({
sessionId,
displayName: contacts[sessionId]?.displayName || sessionId,
avatarUrl: contacts[sessionId]?.avatarUrl,
periodStart: period.start,
periodEnd: period.end,
triggerType: 'auto'
})
}
}
} catch (error) {
console.warn('[GroupSummaryService] 自动总结失败:', error)
}
}
private async readMessagesInPeriod(sessionId: string, startTime: number, endTime: number): Promise<Message[]> {
if (!await this.ensureConnected()) {
throw new Error('数据库连接失败,请先在“数据库连接”页完成配置')
}
const cursorResult = await wcdbService.openMessageCursorLite(
sessionId,
SUMMARY_CURSOR_BATCH_SIZE,
true,
startTime,
endTime
)
if (!cursorResult.success || !cursorResult.cursor) {
throw new Error(cursorResult.error || '打开消息游标失败')
}
const cursor = cursorResult.cursor
const messages: Message[] = []
try {
let hasMore = true
while (hasMore && messages.length < MAX_MESSAGES_PER_SUMMARY) {
const batch = await wcdbService.fetchMessageBatch(cursor)
if (!batch.success) {
throw new Error(batch.error || '读取消息失败')
}
hasMore = batch.hasMore === true
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
if (rows.length === 0) {
if (!hasMore) break
continue
}
const mapped = chatService.mapRowsToMessagesForApi(rows, sessionId)
for (const message of mapped) {
const createTime = Number(message.createTime || 0)
if (createTime < startTime || createTime > endTime) continue
messages.push(message)
if (messages.length >= MAX_MESSAGES_PER_SUMMARY) break
}
}
} finally {
await wcdbService.closeMessageCursor(cursor).catch(() => {})
}
return messages.sort((a, b) => {
if (a.createTime !== b.createTime) return a.createTime - b.createTime
if (a.sortSeq !== b.sortSeq) return a.sortSeq - b.sortSeq
return a.localId - b.localId
})
}
private normalizeMessageText(message: Message): string {
const parsedContent = String(message.parsedContent || '').replace(/\s+/g, ' ').trim()
const quotedContent = String(message.quotedContent || '').replace(/\s+/g, ' ').trim()
const quotedSender = String(message.quotedSender || '').replace(/\s+/g, ' ').trim()
let text = parsedContent
if (quotedContent) {
const quote = quotedSender ? `${quotedSender}${quotedContent}` : quotedContent
text = text && text !== '[引用消息]' ? `${text} [引用 ${quote}]` : `[引用 ${quote}]`
}
if (!text) {
text = String(message.linkTitle || message.fileName || message.appMsgDesc || '').replace(/\s+/g, ' ').trim()
}
if (!text) return ''
if (/^<\?xml|^<msg\b|^<appmsg\b|^<img\b|^<emoji\b/i.test(text)) return ''
return text
}
private async buildTranscript(sessionId: string, messages: Message[]): Promise<{ transcript: string; readableMessages: Message[] }> {
const readableMessages = messages.filter((message) => this.normalizeMessageText(message))
const senderIds = Array.from(new Set(
readableMessages
.map((message) => String(message.senderUsername || '').trim())
.filter(Boolean)
))
const contacts = senderIds.length > 0
? (await chatService.enrichSessionsContactInfo(senderIds).catch(() => null))?.contacts || {}
: {}
const myWxid = String(this.config.getMyWxidCleaned() || '').trim()
const lines = readableMessages.map((message) => {
const senderUsername = String(message.senderUsername || '').trim()
const senderName = message.isSend === 1 || (senderUsername && myWxid && senderUsername === myWxid)
? '我'
: (contacts[senderUsername]?.displayName || senderUsername || '未知成员')
return `${formatTimestamp(message.createTime)} ${senderName}${this.normalizeMessageText(message)}`
})
return {
transcript: lines.join('\n'),
readableMessages
}
}
private async generateSummaryForPeriod(params: {
sessionId: string
displayName: string
avatarUrl?: string
periodStart: number
periodEnd: number
triggerType: GroupSummaryTriggerType
}): Promise<GroupSummaryTriggerResult> {
const { apiBaseUrl, apiKey, model } = this.getSharedAiModelConfig()
if (!apiBaseUrl || !apiKey) {
return { success: false, message: '请先填写通用 AI 模型配置API 地址和 Key' }
}
try {
const messages = await this.readMessagesInPeriod(params.sessionId, params.periodStart, params.periodEnd)
const { transcript, readableMessages } = await this.buildTranscript(params.sessionId, messages)
if (readableMessages.length < MIN_SUMMARY_MESSAGES) {
return {
success: true,
skipped: true,
skippedReason: 'message_count_too_low',
message: `该时段可总结消息少于 ${MIN_SUMMARY_MESSAGES} 条,已跳过`
}
}
const customPrompt = String(this.config.get('aiGroupSummarySystemPrompt') || '').trim()
const systemPrompt = customPrompt || DEFAULT_GROUP_SUMMARY_SYSTEM_PROMPT
const userPrompt = `群聊:${params.displayName}
总结时段:${formatTimestamp(params.periodStart)}${formatTimestamp(params.periodEnd)}
消息数量:${readableMessages.length}
群聊记录:
${transcript}
请只输出指定 JSON。`
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
const requestMessages = [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
]
let rawOutput = ''
let responseFormatJson = true
let responseFormatFallback = false
let responseFormatFallbackReason = ''
const startedAt = Date.now()
try {
rawOutput = await callChatCompletions(apiBaseUrl, apiKey, model, requestMessages, { responseFormatJson: true })
} catch (error) {
if (!shouldFallbackJsonMode(error)) throw error
responseFormatJson = false
responseFormatFallback = true
responseFormatFallbackReason = (error as Error).message || 'response_format 不受支持'
rawOutput = await callChatCompletions(apiBaseUrl, apiKey, model, requestMessages)
}
let topics: GroupSummaryTopic[]
let finalSummary: string
try {
topics = parseTopics(rawOutput)
finalSummary = buildSummaryText(topics)
} catch {
topics = [fallbackTopicFromRaw(rawOutput)]
finalSummary = buildSummaryText(topics)
}
const log: GroupSummaryLog = {
endpoint,
model,
temperature: API_TEMPERATURE,
triggerType: params.triggerType,
periodStart: params.periodStart,
periodEnd: params.periodEnd,
messageCount: messages.length,
readableMessageCount: readableMessages.length,
systemPrompt,
userPrompt,
rawOutput,
finalSummary,
durationMs: Date.now() - startedAt,
createdAt: Date.now(),
responseFormatJson,
responseFormatFallback,
responseFormatFallbackReason,
parsedTopics: topics
}
const record = groupSummaryRecordService.addRecord({
sessionId: params.sessionId,
displayName: params.displayName,
avatarUrl: params.avatarUrl,
triggerType: params.triggerType,
periodStart: params.periodStart,
periodEnd: params.periodEnd,
messageCount: messages.length,
readableMessageCount: readableMessages.length,
topics,
summaryText: finalSummary,
rawOutput,
log
})
return { success: true, message: '群聊总结已生成', recordId: record.id, record }
} catch (error) {
return { success: false, message: `生成失败:${(error as Error).message || String(error)}` }
}
}
private async generateSummariesForPeriods(params: {
sessionId: string
displayName: string
avatarUrl?: string
periods: Array<{ start: number; end: number }>
triggerType: GroupSummaryTriggerType
}): Promise<GroupSummaryDayTriggerResult> {
const records: GroupSummaryRecordSummary[] = []
let skipped = 0
let failed = 0
let firstError = ''
for (const period of params.periods) {
const result = await this.generateSummaryForPeriod({
sessionId: params.sessionId,
displayName: params.displayName,
avatarUrl: params.avatarUrl,
periodStart: period.start,
periodEnd: period.end,
triggerType: params.triggerType
})
if (result.success && result.record) {
records.push(result.record)
continue
}
if (result.success && result.skipped) {
skipped += 1
continue
}
failed += 1
if (!firstError) firstError = result.message
}
const generated = records.length
const parts = [`生成 ${generated}`, `跳过 ${skipped}`]
if (failed > 0) parts.push(`失败 ${failed}`)
const message = failed > 0 && generated === 0 && skipped === 0
? (firstError || '群聊总结生成失败')
: `群聊总结完成:${parts.join('')}`
return {
success: generated > 0 || skipped > 0 || failed === 0,
message,
generated,
skipped,
records
}
}
}
export const groupSummaryService = new GroupSummaryService()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,203 +0,0 @@
import { app } from 'electron'
import { join } from 'path'
import { existsSync } from 'fs'
import { execFile } from 'child_process'
import { promisify } from 'util'
// import { ConfigService } from './config'
const execFileAsync = promisify(execFile)
export class ImageDownloadService {
private static instance: ImageDownloadService
private koffi: any = null
private lib: any = null
private initialized = false
private initImgHelper: any = null
private uninstallImgHelper: any = null
private getImgHelperError: any = null
private currentPid: number | null = null
private pollTimer: NodeJS.Timeout | null = null
private isHooked = false
private lastWhitelist: string[] = []
static getInstance(): ImageDownloadService {
if (!ImageDownloadService.instance) {
ImageDownloadService.instance = new ImageDownloadService()
}
return ImageDownloadService.instance
}
private constructor() {
}
private async ensureInitialized(): Promise<boolean> {
if (this.initialized) return true
if (process.platform !== 'win32' || process.arch !== 'x64') return false
try {
this.koffi = require('koffi')
const dllPath = this.getDllPath()
if (!existsSync(dllPath)) return false
this.lib = this.koffi.load(dllPath)
this.initImgHelper = this.lib.func('bool InitImgHelper(uint32, const char*)')
this.uninstallImgHelper = this.lib.func('void UninstallImgHelper()')
this.getImgHelperError = this.lib.func('const char* GetImgHelperError()')
this.initialized = true
return true
} catch (error) {
console.error('[ImageDownloadService] failed to initialize:', error)
return false
}
}
private getDllPath(): string {
const isPackaged = app.isPackaged
const candidates: string[] = []
if (isPackaged) {
candidates.push(join(process.resourcesPath, 'resources', 'image', 'win32', 'x64', 'img_helper.dll'))
} else {
candidates.push(join(process.cwd(), 'resources', 'image', 'win32', 'x64', 'img_helper.dll'))
}
for (const path of candidates) {
if (existsSync(path)) return path
}
return candidates[0]
}
private async findMainWeChatPid(): Promise<number | null> {
try {
const script = `
Get-CimInstance Win32_Process -Filter "Name = 'Weixin.exe'" |
Select-Object ProcessId, CommandLine |
ConvertTo-Json -Compress
`;
const { stdout } = await execFileAsync('powershell', ['-NoProfile', '-Command', script])
if (!stdout || !stdout.trim()) return null
let processes = JSON.parse(stdout.trim())
if (!Array.isArray(processes)) processes = [processes]
const target = processes
.filter((p: any) => p.CommandLine && p.CommandLine.toLowerCase().includes('weixin.exe'))
.sort((a: any, b: any) => a.CommandLine.length - b.CommandLine.length)[0]
return target ? target.ProcessId : null;
} catch (e) {
return null
}
}
async startAutoDownload(whitelist: string[] | string = []): Promise<{ success: boolean; error?: string }> {
if (!await this.ensureInitialized()) {
return { success: false, error: '核心组件初始化失败' }
}
if (this.isHooked) {
await this.unhook()
}
this.lastWhitelist = whitelist
if (!this.pollTimer) {
this.pollTimer = setInterval(() => this.checkAndHook(this.lastWhitelist, false), 30000)
}
return await this.checkAndHook(whitelist, true)
}
async stopAutoDownload() {
if (this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = null
}
await this.unhook()
}
private async checkAndHook(whitelist: string[] | string = [], isManualStart = false): Promise<{ success: boolean; error?: string }> {
const pid = await this.findMainWeChatPid()
if (!pid) {
if (this.isHooked) {
console.log('[ImageDownloadService] WeChat exited, unhooking')
await this.unhook()
}
return { success: true, error: '等待微信启动' }
}
if (this.isHooked && this.currentPid === pid) {
return { success: true }
}
if (this.isHooked && this.currentPid !== pid) {
console.log('[ImageDownloadService] WeChat PID changed, re-hooking')
await this.unhook()
}
console.log(`[ImageDownloadService] attempting to hook PID: ${pid}`)
try {
let whitelistBuffer: Buffer | null = null;
if (typeof whitelist === 'string') {
if (whitelist.length > 0) {
whitelistBuffer = Buffer.from(whitelist, 'utf8');
}
} else if (Array.isArray(whitelist) && whitelist.length > 0) {
whitelistBuffer = Buffer.from(whitelist.join('\0') + '\0\0', 'utf8');
}
const success = this.initImgHelper(pid, whitelistBuffer)
if (success) {
this.isHooked = true
this.currentPid = pid
console.log('[ImageDownloadService] hook successful')
return { success: true }
} else {
const err = this.getImgHelperError()
console.error(`[ImageDownloadService] hook failed: ${err}`)
if (isManualStart && this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = null
}
return { success: false, error: err || 'Hook 失败' }
}
} catch (e: any) {
console.error('[ImageDownloadService] InitImgHelper call crashed:', e)
if (isManualStart && this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = null
}
return { success: false, error: `调用异常: ${e.message || String(e)}` }
}
}
private async unhook() {
if (this.isHooked && this.uninstallImgHelper) {
try {
this.uninstallImgHelper()
} catch (e) {
console.error('[ImageDownloadService] uninstall failed:', e)
}
}
this.isHooked = false
this.currentPid = null
}
async getStatus() {
return {
isHooked: this.isHooked,
pid: this.currentPid,
supported: process.platform === 'win32' && process.arch === 'x64'
}
}
}
export const imageDownloadService = ImageDownloadService.getInstance()

View File

@@ -1,103 +0,0 @@
import { imageDecryptService } from './imageDecryptService'
type PreloadImagePayload = {
sessionId?: string
imageMd5?: string
imageDatName?: string
createTime?: number
}
type PreloadOptions = {
allowDecrypt?: boolean
allowCacheIndex?: boolean
}
type PreloadTask = PreloadImagePayload & {
key: string
allowDecrypt: boolean
allowCacheIndex: boolean
}
export class ImagePreloadService {
private queue: PreloadTask[] = []
private pending = new Set<string>()
private activeCache = 0
private activeDecrypt = 0
private readonly maxCacheConcurrent = 8
private readonly maxDecryptConcurrent = 2
private readonly maxQueueSize = 320
enqueue(payloads: PreloadImagePayload[], options?: PreloadOptions): void {
if (!Array.isArray(payloads) || payloads.length === 0) return
const allowDecrypt = options?.allowDecrypt !== false
const allowCacheIndex = options?.allowCacheIndex !== false
for (const payload of payloads) {
if (!allowDecrypt && this.queue.length >= this.maxQueueSize) break
const cacheKey = payload.imageMd5 || payload.imageDatName
if (!cacheKey) continue
const key = `${payload.sessionId || 'unknown'}|${cacheKey}`
if (this.pending.has(key)) continue
this.pending.add(key)
this.queue.push({ ...payload, key, allowDecrypt, allowCacheIndex })
}
this.processQueue()
}
private processQueue(): void {
while (this.queue.length > 0) {
const taskIndex = this.queue.findIndex((task) => (
task.allowDecrypt
? this.activeDecrypt < this.maxDecryptConcurrent
: this.activeCache < this.maxCacheConcurrent
))
if (taskIndex < 0) return
const task = this.queue.splice(taskIndex, 1)[0]
if (!task) return
if (task.allowDecrypt) this.activeDecrypt += 1
else this.activeCache += 1
void this.handleTask(task).finally(() => {
if (task.allowDecrypt) this.activeDecrypt = Math.max(0, this.activeDecrypt - 1)
else this.activeCache = Math.max(0, this.activeCache - 1)
this.pending.delete(task.key)
this.processQueue()
})
}
}
private async handleTask(task: PreloadTask): Promise<void> {
const cacheKey = task.imageMd5 || task.imageDatName
if (!cacheKey) return
try {
const cached = await imageDecryptService.resolveCachedImage({
sessionId: task.sessionId,
imageMd5: task.imageMd5,
imageDatName: task.imageDatName,
createTime: task.createTime,
preferFilePath: true,
hardlinkOnly: true,
disableUpdateCheck: !task.allowDecrypt,
allowCacheIndex: task.allowCacheIndex,
suppressEvents: true
})
if (cached.success) return
if (!task.allowDecrypt) return
await imageDecryptService.decryptImage({
sessionId: task.sessionId,
imageMd5: task.imageMd5,
imageDatName: task.imageDatName,
createTime: task.createTime,
preferFilePath: true,
hardlinkOnly: true,
disableUpdateCheck: true,
suppressEvents: true
})
} catch {
// ignore preload failures
}
}
}
export const imagePreloadService = new ImagePreloadService()

File diff suppressed because it is too large Load Diff

View File

@@ -1,380 +0,0 @@
import { app } from 'electron'
import fs from 'fs'
import path from 'path'
import { createHash, randomUUID } from 'crypto'
import { ConfigService } from './config'
export type InsightRecordTriggerReason = 'activity' | 'silence' | 'test' | 'manual' | 'message_analysis'
export type InsightRecordSourceType = 'insight' | 'message_analysis'
export interface MessageInsightAnalysis {
explicitText: string
emotion: string
intent: string
topic: string
}
export interface MessageInsightTarget {
targetLocalId: number
targetCreateTime: number
targetMessageKey: string
targetSenderName: string
targetTextPreview: string
analysis: MessageInsightAnalysis
}
export interface InsightRecordLog {
endpoint: string
model: string
maxTokens: number
temperature: number
triggerReason: InsightRecordTriggerReason
allowContext: boolean
contextCount: number
systemPrompt: string
userPrompt: string
rawOutput: string
finalInsight: string
durationMs: number
createdAt: number
responseFormatJson?: boolean
responseFormatFallback?: boolean
responseFormatFallbackReason?: string
targetMessage?: {
localId: number
createTime: number
messageKey: string
senderName: string
textPreview: string
}
contextStats?: {
requested: number
beforeTarget: number
afterTarget: number
readError?: string
}
parsedAnalysis?: MessageInsightAnalysis
}
export interface InsightRecord {
id: string
accountScope: string
sourceType: InsightRecordSourceType
createdAt: number
sessionId: string
displayName: string
avatarUrl?: string
triggerReason: InsightRecordTriggerReason
insight: string
read: boolean
messageInsight?: MessageInsightTarget
log: InsightRecordLog
}
export interface InsightRecordSummary {
id: string
sourceType: InsightRecordSourceType
createdAt: number
sessionId: string
displayName: string
avatarUrl?: string
triggerReason: InsightRecordTriggerReason
insight: string
read: boolean
messageInsight?: MessageInsightTarget
}
export interface InsightRecordContactFacet {
sessionId: string
displayName: string
avatarUrl?: string
count: number
}
export interface InsightRecordFilters {
keyword?: string
sessionId?: string
startTime?: number
endTime?: number
sourceType?: InsightRecordSourceType | 'all'
limit?: number
offset?: number
}
export interface InsightRecordListResult {
success: boolean
records: InsightRecordSummary[]
total: number
todayCount: number
unreadCount: number
contacts: InsightRecordContactFacet[]
error?: string
}
class InsightRecordService {
private readonly maxRecordsPerScope = 1000
private filePath: string | null = null
private loaded = false
private records: InsightRecord[] = []
private resolveFilePath(): string {
if (this.filePath) return this.filePath
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
const userDataPath = workerUserDataPath || app?.getPath?.('userData') || process.cwd()
fs.mkdirSync(userDataPath, { recursive: true })
this.filePath = path.join(userDataPath, 'weflow-insight-records.json')
return this.filePath
}
private ensureLoaded(): void {
if (this.loaded) return
this.loaded = true
const filePath = this.resolveFilePath()
try {
if (!fs.existsSync(filePath)) return
const raw = fs.readFileSync(filePath, 'utf-8')
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) {
this.records = parsed.filter((item) => item && typeof item === 'object') as InsightRecord[]
} else if (Array.isArray(parsed?.records)) {
this.records = parsed.records.filter((item: unknown) => item && typeof item === 'object') as InsightRecord[]
}
} catch {
this.records = []
}
}
private persist(): void {
try {
const filePath = this.resolveFilePath()
fs.writeFileSync(filePath, JSON.stringify({ version: 1, records: this.records }, null, 2), 'utf-8')
} catch {
// Keep insight generation non-blocking even if local persistence fails.
}
}
private getCurrentAccountScope(): string {
const config = ConfigService.getInstance()
const myWxid = String(config.getMyWxidCleaned() || '').trim()
if (myWxid) return `wxid:${myWxid}`
const dbPath = String(config.get('dbPath') || '').trim()
if (dbPath) {
const hash = createHash('sha1').update(dbPath).digest('hex').slice(0, 16)
return `db:${hash}`
}
return 'default'
}
private getStartOfToday(): number {
const date = new Date()
date.setHours(0, 0, 0, 0)
return date.getTime()
}
private toSummary(record: InsightRecord): InsightRecordSummary {
return {
id: record.id,
sourceType: record.sourceType || 'insight',
createdAt: record.createdAt,
sessionId: record.sessionId,
displayName: record.displayName,
avatarUrl: record.avatarUrl,
triggerReason: record.triggerReason,
insight: record.insight,
read: record.read,
messageInsight: record.messageInsight
}
}
private getScopedRecords(): InsightRecord[] {
this.ensureLoaded()
const scope = this.getCurrentAccountScope()
return this.records.filter((record) => record.accountScope === scope)
}
addRecord(input: {
sessionId: string
displayName: string
avatarUrl?: string
sourceType?: InsightRecordSourceType
triggerReason: InsightRecordTriggerReason
insight: string
messageInsight?: MessageInsightTarget
log: InsightRecordLog
}): InsightRecord {
this.ensureLoaded()
const scope = this.getCurrentAccountScope()
const now = Date.now()
const record: InsightRecord = {
id: randomUUID(),
accountScope: scope,
sourceType: input.sourceType || 'insight',
createdAt: now,
sessionId: input.sessionId,
displayName: input.displayName,
avatarUrl: input.avatarUrl,
triggerReason: input.triggerReason,
insight: input.insight,
read: false,
messageInsight: input.messageInsight,
log: input.log
}
this.records.push(record)
const scopedRecords = this.records
.filter((item) => item.accountScope === scope)
.sort((a, b) => b.createdAt - a.createdAt)
const keepIds = new Set(scopedRecords.slice(0, this.maxRecordsPerScope).map((item) => item.id))
this.records = this.records.filter((item) => item.accountScope !== scope || keepIds.has(item.id))
this.persist()
return record
}
listRecords(filters: InsightRecordFilters = {}): InsightRecordListResult {
try {
const allScoped = this.getScopedRecords()
const todayStart = this.getStartOfToday()
const contactsMap = new Map<string, InsightRecordContactFacet>()
for (const record of allScoped) {
const existing = contactsMap.get(record.sessionId)
if (existing) {
existing.count += 1
} else {
contactsMap.set(record.sessionId, {
sessionId: record.sessionId,
displayName: record.displayName,
avatarUrl: record.avatarUrl,
count: 1
})
}
}
const keyword = String(filters.keyword || '').trim().toLowerCase()
const sessionId = String(filters.sessionId || '').trim()
const sourceType = String(filters.sourceType || 'all').trim()
const startTime = Number(filters.startTime || 0)
const endTime = Number(filters.endTime || 0)
const offset = Math.max(0, Math.floor(Number(filters.offset || 0)))
const limit = Math.min(200, Math.max(1, Math.floor(Number(filters.limit || 100))))
const filtered = allScoped
.filter((record) => {
if (sessionId && record.sessionId !== sessionId) return false
const recordSourceType = record.sourceType || 'insight'
if (sourceType !== 'all' && sourceType && recordSourceType !== sourceType) return false
if (startTime > 0 && record.createdAt < startTime) return false
if (endTime > 0 && record.createdAt > endTime) return false
if (keyword) {
const haystack = [
record.displayName,
record.sessionId,
record.insight,
record.messageInsight?.targetSenderName,
record.messageInsight?.targetTextPreview,
record.messageInsight?.analysis?.explicitText,
record.messageInsight?.analysis?.emotion,
record.messageInsight?.analysis?.intent,
record.messageInsight?.analysis?.topic
].join('\n').toLowerCase()
if (!haystack.includes(keyword)) return false
}
return true
})
.sort((a, b) => b.createdAt - a.createdAt)
return {
success: true,
records: filtered.slice(offset, offset + limit).map((record) => this.toSummary(record)),
total: filtered.length,
todayCount: allScoped.filter((record) => record.createdAt >= todayStart).length,
unreadCount: allScoped.filter((record) => !record.read).length,
contacts: Array.from(contactsMap.values()).sort((a, b) => b.count - a.count)
}
} catch (error) {
return {
success: false,
records: [],
total: 0,
todayCount: 0,
unreadCount: 0,
contacts: [],
error: (error as Error).message
}
}
}
getRecord(id: string): { success: boolean; record?: InsightRecord; error?: string } {
this.ensureLoaded()
const normalizedId = String(id || '').trim()
if (!normalizedId) return { success: false, error: '记录 ID 为空' }
const scope = this.getCurrentAccountScope()
const record = this.records.find((item) => item.id === normalizedId && item.accountScope === scope)
if (!record) return { success: false, error: '未找到该见解记录' }
return { success: true, record }
}
findLatestMessageAnalysis(input: {
sessionId: string
targetLocalId?: number
targetCreateTime?: number
targetMessageKey?: string
}): InsightRecord | null {
this.ensureLoaded()
const scope = this.getCurrentAccountScope()
const sessionId = String(input.sessionId || '').trim()
if (!sessionId) return null
const targetLocalId = Math.floor(Number(input.targetLocalId || 0))
const targetCreateTime = Math.floor(Number(input.targetCreateTime || 0))
const targetMessageKey = String(input.targetMessageKey || '').trim()
const matches = this.records
.filter((record) => {
if (record.accountScope !== scope) return false
if ((record.sourceType || 'insight') !== 'message_analysis') return false
if (record.sessionId !== sessionId) return false
const target = record.messageInsight
if (!target) return false
if (targetLocalId > 0 && Number(target.targetLocalId || 0) === targetLocalId) {
if (targetCreateTime <= 0 || Number(target.targetCreateTime || 0) === targetCreateTime) return true
}
if (targetMessageKey && target.targetMessageKey === targetMessageKey) return true
return false
})
.sort((a, b) => b.createdAt - a.createdAt)
return matches[0] || null
}
markRecordRead(id: string): { success: boolean; error?: string } {
this.ensureLoaded()
const normalizedId = String(id || '').trim()
const scope = this.getCurrentAccountScope()
const record = this.records.find((item) => item.id === normalizedId && item.accountScope === scope)
if (!record) return { success: false, error: '未找到该见解记录' }
if (!record.read) {
record.read = true
this.persist()
}
return { success: true }
}
clearRecords(filters: InsightRecordFilters = {}): { success: boolean; removed: number; error?: string } {
this.ensureLoaded()
const scope = this.getCurrentAccountScope()
const sessionId = String(filters.sessionId || '').trim()
const startTime = Number(filters.startTime || 0)
const endTime = Number(filters.endTime || 0)
let removed = 0
this.records = this.records.filter((record) => {
if (record.accountScope !== scope) return true
if (sessionId && record.sessionId !== sessionId) return true
if (startTime > 0 && record.createdAt < startTime) return true
if (endTime > 0 && record.createdAt > endTime) return true
removed += 1
return false
})
this.persist()
return { success: true, removed }
}
}
export const insightRecordService = new InsightRecordService()

File diff suppressed because it is too large Load Diff

View File

@@ -1,127 +0,0 @@
/**
* ISAAC-64: A fast cryptographic PRNG
* Re-implemented in TypeScript using BigInt for 64-bit support.
* Used for WeChat Channels/SNS video decryption.
*/
export class Isaac64 {
private mm = new BigUint64Array(256);
private aa = 0n;
private bb = 0n;
private cc = 0n;
private randrsl = new BigUint64Array(256);
private randcnt = 0;
private static readonly MASK = 0xFFFFFFFFFFFFFFFFn;
constructor(seed: number | string | bigint) {
const seedBig = BigInt(seed);
// 通常单密钥初始化是将密钥放在第一个槽位,其余清零(或者按某种规律填充)
// 这里我们尝试仅设置第一个槽位,这在很多 WASM 移植版本中更为常见
this.randrsl.fill(0n);
this.randrsl[0] = seedBig;
this.init(true);
}
private init(flag: boolean) {
let a: bigint, b: bigint, c: bigint, d: bigint, e: bigint, f: bigint, g: bigint, h: bigint;
a = b = c = d = e = f = g = h = 0x9e3779b97f4a7c15n;
const mix = () => {
a = (a - e) & Isaac64.MASK; f ^= (h >> 9n); h = (h + a) & Isaac64.MASK;
b = (b - f) & Isaac64.MASK; g ^= (a << 9n) & Isaac64.MASK; a = (a + b) & Isaac64.MASK;
c = (c - g) & Isaac64.MASK; h ^= (b >> 23n); b = (b + c) & Isaac64.MASK;
d = (d - h) & Isaac64.MASK; a ^= (c << 15n) & Isaac64.MASK; c = (c + d) & Isaac64.MASK;
e = (e - a) & Isaac64.MASK; b ^= (d >> 14n); d = (d + e) & Isaac64.MASK;
f = (f - b) & Isaac64.MASK; c ^= (e << 20n) & Isaac64.MASK; e = (e + f) & Isaac64.MASK;
g = (g - c) & Isaac64.MASK; d ^= (f >> 17n); f = (f + g) & Isaac64.MASK;
h = (h - d) & Isaac64.MASK; e ^= (g << 14n) & Isaac64.MASK; g = (g + h) & Isaac64.MASK;
};
for (let i = 0; i < 4; i++) mix();
for (let i = 0; i < 256; i += 8) {
if (flag) {
a = (a + this.randrsl[i]) & Isaac64.MASK;
b = (b + this.randrsl[i + 1]) & Isaac64.MASK;
c = (c + this.randrsl[i + 2]) & Isaac64.MASK;
d = (d + this.randrsl[i + 3]) & Isaac64.MASK;
e = (e + this.randrsl[i + 4]) & Isaac64.MASK;
f = (f + this.randrsl[i + 5]) & Isaac64.MASK;
g = (g + this.randrsl[i + 6]) & Isaac64.MASK;
h = (h + this.randrsl[i + 7]) & Isaac64.MASK;
}
mix();
this.mm[i] = a; this.mm[i + 1] = b; this.mm[i + 2] = c; this.mm[i + 3] = d;
this.mm[i + 4] = e; this.mm[i + 5] = f; this.mm[i + 6] = g; this.mm[i + 7] = h;
}
if (flag) {
for (let i = 0; i < 256; i += 8) {
a = (a + this.mm[i]) & Isaac64.MASK;
b = (b + this.mm[i + 1]) & Isaac64.MASK;
c = (c + this.mm[i + 2]) & Isaac64.MASK;
d = (d + this.mm[i + 3]) & Isaac64.MASK;
e = (e + this.mm[i + 4]) & Isaac64.MASK;
f = (f + this.mm[i + 5]) & Isaac64.MASK;
g = (g + this.mm[i + 6]) & Isaac64.MASK;
h = (h + this.mm[i + 7]) & Isaac64.MASK;
mix();
this.mm[i] = a; this.mm[i + 1] = b; this.mm[i + 2] = c; this.mm[i + 3] = d;
this.mm[i + 4] = e; this.mm[i + 5] = f; this.mm[i + 6] = g; this.mm[i + 7] = h;
}
}
this.isaac64();
this.randcnt = 256;
}
private isaac64() {
this.cc = (this.cc + 1n) & Isaac64.MASK;
this.bb = (this.bb + this.cc) & Isaac64.MASK;
for (let i = 0; i < 256; i++) {
let x = this.mm[i];
switch (i & 3) {
case 0: this.aa = (this.aa ^ (((this.aa << 21n) & Isaac64.MASK) ^ Isaac64.MASK)) & Isaac64.MASK; break;
case 1: this.aa = (this.aa ^ (this.aa >> 5n)) & Isaac64.MASK; break;
case 2: this.aa = (this.aa ^ ((this.aa << 12n) & Isaac64.MASK)) & Isaac64.MASK; break;
case 3: this.aa = (this.aa ^ (this.aa >> 33n)) & Isaac64.MASK; break;
}
this.aa = (this.mm[(i + 128) & 255] + this.aa) & Isaac64.MASK;
const y = (this.mm[Number(x >> 3n) & 255] + this.aa + this.bb) & Isaac64.MASK;
this.mm[i] = y;
this.bb = (this.mm[Number(y >> 11n) & 255] + x) & Isaac64.MASK;
this.randrsl[i] = this.bb;
}
}
public getNext(): bigint {
if (this.randcnt === 0) {
this.isaac64();
this.randcnt = 256;
}
return this.randrsl[--this.randcnt];
}
/**
* Generates a keystream where each 64-bit block is Big-Endian.
* This matches WeChat's behavior (Reverse index order + byte reversal).
*/
public generateKeystreamBE(size: number): Buffer {
const buffer = Buffer.allocUnsafe(size);
const fullBlocks = Math.floor(size / 8);
for (let i = 0; i < fullBlocks; i++) {
buffer.writeBigUInt64BE(this.getNext(), i * 8);
}
const remaining = size % 8;
if (remaining > 0) {
const lastK = this.getNext();
const temp = Buffer.allocUnsafe(8);
temp.writeBigUInt64BE(lastK, 0);
temp.copy(buffer, fullBlocks * 8, 0, remaining);
}
return buffer;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,450 +0,0 @@
import { app } from 'electron'
import { join } from 'path'
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
import { execFile, exec, spawn } from 'child_process'
import { promisify } from 'util'
import crypto from 'crypto'
import { createRequire } from 'module';
const require = createRequire(__filename);
const execFileAsync = promisify(execFile)
const execAsync = promisify(exec)
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string }
export class KeyServiceLinux {
private sudo: any
constructor() {
try {
this.sudo = require('@vscode/sudo-prompt');
} catch (e) {
console.error('Failed to load @vscode/sudo-prompt', e);
}
}
private getHelperPath(): string {
const isPackaged = app.isPackaged
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
const candidates: string[] = []
if (process.env.WX_KEY_HELPER_PATH) candidates.push(process.env.WX_KEY_HELPER_PATH)
if (isPackaged) {
candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', archDir, 'xkey_helper_linux'))
candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux'))
candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', 'xkey_helper_linux'))
candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper_linux'))
candidates.push(join(process.resourcesPath, 'xkey_helper_linux'))
} else {
candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', archDir, 'xkey_helper_linux'))
candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux'))
candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', 'xkey_helper_linux'))
candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper_linux'))
candidates.push(join(process.cwd(), 'resources', 'key', 'linux', archDir, 'xkey_helper_linux'))
candidates.push(join(process.cwd(), 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux'))
candidates.push(join(process.cwd(), 'resources', 'key', 'linux', 'xkey_helper_linux'))
candidates.push(join(app.getAppPath(), '..', 'Xkey', 'build', 'xkey_helper_linux'))
}
for (const p of candidates) {
if (existsSync(p)) return p
}
throw new Error('找不到 xkey_helper_linux请检查路径')
}
public async autoGetDbKey(
timeoutMs = 60_000,
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)
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 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',
'/usr/local/bin/wechat',
'/usr/bin/wechat',
'/opt/apps/com.tencent.wechat/files/wechat',
'/usr/bin/wechat-bin',
'/usr/local/bin/wechat-bin',
'com.tencent.wechat'
]
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))
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 = '未能自动启动微信或获取PID失败请查看控制台日志或手动启动微信看到登录窗口后点击确认。'
onStatus?.(err, 2)
return { success: false, error: err }
}
onStatus?.(`捕获到微信 PID: ${pid},准备获取密钥...`, 0)
await new Promise(r => setTimeout(r, 2000))
return await this.getDbKey(pid, onStatus, timeoutMs)
} catch (err: any) {
console.error('[Debug] 自动获取流程彻底崩溃:', err);
const errMsg = '自动获取微信 PID 失败: ' + err.message
onStatus?.(errMsg, 2)
return { success: false, error: errMsg }
}
}
public async getDbKey(pid: number, onStatus?: (message: string, level: number) => void, timeoutMs = 180_000): Promise<DbKeyResult> {
try {
const helperPath = this.getHelperPath()
onStatus?.('正在扫描数据库基址...', 0)
const { stdout: scanOut } = await execFileAsync(helperPath, ['db_scan', pid.toString()])
const scanRes = JSON.parse(scanOut.trim())
if (!scanRes.success) {
const err = scanRes.result || '扫描失败,请确保微信已完全登录'
onStatus?.(err, 2)
return { success: false, error: err }
}
const targetAddr = scanRes.target_addr
onStatus?.('基址扫描成功,正在请求管理员权限进行内存 Hook...', 0)
if (!this.sudo || typeof this.sudo.exec !== 'function') {
const err = 'Linux 授权组件 @vscode/sudo-prompt 未加载,请确认依赖已安装并重新启动 WeFlow'
onStatus?.(err, 2)
return { success: false, error: err }
}
return await new Promise((resolve) => {
const options = {
name: 'WeFlow',
env: {
PATH: `${process.env.PATH || ''}:/bin:/usr/bin:/sbin:/usr/sbin:/usr/local/bin`
}
}
const timeoutSec = Math.ceil((timeoutMs + 15_000) / 1000)
const command = `timeout -k 5s ${timeoutSec}s "${helperPath}" db_hook ${pid} ${targetAddr} ${timeoutMs}`
let settled = false
const finish = (result: DbKeyResult) => {
if (settled) return
settled = true
clearTimeout(watchdog)
resolve(result)
}
const watchdog = setTimeout(() => {
execAsync(`kill -CONT ${pid}`).catch(() => {})
const err = `Hook 等待超时(${Math.round(timeoutMs / 1000)} 秒)。请确认微信登录确认已完成,或重启微信后重试。`
onStatus?.(err, 2)
finish({ success: false, error: err })
}, timeoutMs + 30_000)
onStatus?.('授权通过后请在手机上确认登录微信,正在等待密钥回调...', 0)
this.sudo.exec(command, options, (error, stdout, stderr) => {
execAsync(`kill -CONT ${pid}`).catch(() => {})
if (error) {
const detail = String(stderr || '').trim()
const message = detail ? `${error.message}: ${detail}` : error.message
onStatus?.('授权失败或 Hook 执行失败', 2)
finish({ success: false, error: `授权失败或 Hook 执行失败: ${message}` })
return
}
try {
const output = String(stdout || '').trim()
if (!output) {
const detail = String(stderr || '').trim()
throw new Error(detail ? `Hook 无输出: ${detail}` : 'Hook 无输出')
}
const hookRes = JSON.parse(output)
if (hookRes.success) {
onStatus?.('密钥获取成功', 1)
finish({ success: true, key: hookRes.key })
} else {
onStatus?.(hookRes.result, 2)
finish({ success: false, error: hookRes.result })
}
} catch (e: any) {
onStatus?.('解析 Hook 结果失败', 2)
finish({ success: false, error: e?.message || '解析 Hook 结果失败' })
}
})
})
} catch (err: any) {
onStatus?.(err.message, 2)
return { success: false, error: err.message }
}
}
public async autoGetImageKey(
accountPath?: string,
onProgress?: (msg: string) => void,
wxid?: string
): Promise<ImageKeyResult> {
try {
onProgress?.('正在初始化缓存扫描...');
const helperPath = this.getHelperPath()
const { stdout } = await execFileAsync(helperPath, ['image_local'])
const res = JSON.parse(stdout.trim())
if (!res.success) return { success: false, error: res.result }
const accounts = res.data.accounts || []
let account = accounts.find((a: any) => a.wxid === wxid)
if (!account && accounts.length > 0) account = accounts[0]
if (account && account.keys && account.keys.length > 0) {
onProgress?.(`已找到匹配的图片密钥 (wxid: ${account.wxid})`);
const keyObj = account.keys[0]
const aesKey = String(keyObj.aesKey || '')
const verified = await this.verifyImageKeyByTemplate(accountPath, aesKey)
if (verified === true) {
onProgress?.('缓存密钥校验成功,已确认可用')
} else if (verified === false) {
onProgress?.('已从缓存计算密钥,但未通过本地模板校验')
}
return { success: true, xorKey: keyObj.xorKey, aesKey, verified: verified === true }
}
return { success: false, error: '未在缓存中找到匹配的图片密钥' }
} catch (err: any) {
return { success: false, error: err.message }
}
}
private async verifyImageKeyByTemplate(accountPath: string | undefined, aesKey: string): Promise<boolean | null> {
const normalizedPath = String(accountPath || '').trim()
if (!normalizedPath || !aesKey || aesKey.length < 16 || !existsSync(normalizedPath)) return null
try {
const template = await this._findTemplateData(normalizedPath, 32)
if (!template.ciphertext) return null
return this.verifyDerivedAesKey(aesKey, template.ciphertext)
} catch {
return null
}
}
private verifyDerivedAesKey(aesKey: string, ciphertext: Buffer): boolean {
try {
if (!aesKey || aesKey.length < 16 || ciphertext.length !== 16) return false
const decipher = crypto.createDecipheriv('aes-128-ecb', Buffer.from(aesKey, 'ascii').subarray(0, 16), null)
decipher.setAutoPadding(false)
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true
if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true
if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
return false
} catch {
return false
}
}
public async autoGetImageKeyByMemoryScan(
accountPath: string,
onProgress?: (msg: string) => void
): Promise<ImageKeyResult> {
try {
onProgress?.('正在查找模板文件...')
let result = await this._findTemplateData(accountPath, 32)
let { ciphertext, xorKey } = result
if (ciphertext && xorKey === null) {
onProgress?.('未找到有效密钥,尝试扫描更多文件...')
result = await this._findTemplateData(accountPath, 100)
xorKey = result.xorKey
}
if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' }
if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥' }
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
// 2. 找微信 PID
const { stdout } = await execAsync('pidof wechat wechat-bin xwechat').catch(() => ({ stdout: '' }))
const pids = stdout.trim().split(/\s+/).filter(p => p)
if (pids.length === 0) return { success: false, error: '微信未运行,无法扫描内存' }
const pid = parseInt(pids[0], 10)
onProgress?.(`已找到微信进程 PID=${pid},正在提权扫描进程内存...`);
// 3. 将 Buffer 转换为 hex 传递给 helper
const ciphertextHex = ciphertext.toString('hex')
const helperPath = this.getHelperPath()
try {
console.log(`[Debug] 准备执行 Helper: ${helperPath} image_mem ${pid} ${ciphertextHex}`);
const { stdout: memOut, stderr } = await execFileAsync(helperPath, ['image_mem', pid.toString(), ciphertextHex])
console.log(`[Debug] Helper stdout: ${memOut}`);
if (stderr) {
console.warn(`[Debug] Helper stderr: ${stderr}`);
}
if (!memOut || memOut.trim() === '') {
return { success: false, error: 'Helper 返回为空,请检查是否有足够的权限(如需sudo)读取进程内存。' }
}
const res = JSON.parse(memOut.trim())
if (res.success) {
onProgress?.('内存扫描成功');
return { success: true, xorKey, aesKey: res.key }
}
return { success: false, error: res.result || '未知错误' }
} catch (err: any) {
console.error('[Debug] 执行或解析 Helper 时发生崩溃:', err);
return {
success: false,
error: `内存扫描失败: ${err.message}\nstdout: ${err.stdout || '无'}\nstderr: ${err.stderr || '无'}`
}
}
} catch (err: any) {
return { success: false, error: `内存扫描失败: ${err.message}` }
}
}
private async _findTemplateData(userDir: string, limit: number = 32): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> {
const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
// 递归收集 *_t.dat 文件
const collect = (dir: string, results: string[], maxFiles: number) => {
if (results.length >= maxFiles) return
try {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (results.length >= maxFiles) break
const full = join(dir, entry.name)
if (entry.isDirectory()) collect(full, results, maxFiles)
else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full)
}
} catch { /* 忽略无权限目录 */ }
}
const files: string[] = []
collect(userDir, files, limit)
// 按修改时间降序
files.sort((a, b) => {
try { return statSync(b).mtimeMs - statSync(a).mtimeMs } catch { return 0 }
})
let ciphertext: Buffer | null = null
const tailCounts: Record<string, number> = {}
for (const f of files.slice(0, 32)) {
try {
const data = readFileSync(f)
if (data.length < 8) continue
// 统计末尾两字节用于 XOR 密钥
if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) {
const key = `${data[data.length - 2]}_${data[data.length - 1]}`
tailCounts[key] = (tailCounts[key] ?? 0) + 1
}
// 提取密文(取第一个有效的)
if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1F) {
ciphertext = data.subarray(0xF, 0x1F)
}
} catch { /* 忽略 */ }
}
// 计算 XOR 密钥
let xorKey: number | null = null
let maxCount = 0
for (const [key, count] of Object.entries(tailCounts)) {
if (count > maxCount) {
maxCount = count
const [x, y] = key.split('_').map(Number)
const k = x ^ 0xFF
if (k === (y ^ 0xD9)) xorKey = k
}
}
return { ciphertext, xorKey }
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,186 +0,0 @@
import { Notification } from "electron";
import { avatarFileCache, AvatarFileCacheService } from "./avatarFileCacheService";
export interface LinuxNotificationData {
sessionId?: string;
title: string;
content: string;
avatarUrl?: string;
channel?: string;
insightRecordId?: string;
targetRoute?: string;
expireTimeout?: number;
}
type NotificationCallback = (payload: unknown) => void;
let notificationCallbacks: NotificationCallback[] = [];
let notificationCounter = 1;
const activeNotifications: Map<number, Notification> = new Map();
const closeTimers: Map<number, NodeJS.Timeout> = new Map();
function nextNotificationId(): number {
const id = notificationCounter;
notificationCounter += 1;
return id;
}
function clearNotificationState(notificationId: number): void {
activeNotifications.delete(notificationId);
const timer = closeTimers.get(notificationId);
if (timer) {
clearTimeout(timer);
closeTimers.delete(notificationId);
}
}
function triggerNotificationCallback(payload: unknown): void {
for (const callback of notificationCallbacks) {
try {
callback(payload);
} catch (error) {
console.error("[LinuxNotification] Callback error:", error);
}
}
}
export async function showLinuxNotification(
data: LinuxNotificationData,
): Promise<number | null> {
if (process.platform !== "linux") {
return null;
}
if (!Notification.isSupported()) {
console.warn("[LinuxNotification] Notification API is not supported");
return null;
}
try {
let iconPath: string | undefined;
if (data.avatarUrl) {
iconPath = (await avatarFileCache.getAvatarPath(data.avatarUrl)) || undefined;
}
const notification = new Notification({
title: data.title,
body: data.content,
icon: iconPath,
});
const notificationId = nextNotificationId();
activeNotifications.set(notificationId, notification);
notification.on("click", () => {
if (data.channel === "ai-insight" && data.insightRecordId) {
triggerNotificationCallback({
sessionId: data.sessionId,
channel: data.channel,
insightRecordId: data.insightRecordId,
targetRoute: data.targetRoute,
});
return;
}
if (data.sessionId) {
triggerNotificationCallback(data.sessionId);
}
});
notification.on("close", () => {
clearNotificationState(notificationId);
});
notification.on("failed", (_, error) => {
console.error("[LinuxNotification] Notification failed:", error);
clearNotificationState(notificationId);
});
const expireTimeout = data.expireTimeout ?? 5000;
if (expireTimeout > 0) {
const timer = setTimeout(() => {
const currentNotification = activeNotifications.get(notificationId);
if (currentNotification) {
currentNotification.close();
}
}, expireTimeout);
closeTimers.set(notificationId, timer);
}
notification.show();
console.log(
`[LinuxNotification] Shown notification ${notificationId}: ${data.title}`,
);
return notificationId;
} catch (error) {
console.error("[LinuxNotification] Failed to show notification:", error);
return null;
}
}
export async function closeLinuxNotification(
notificationId: number,
): Promise<void> {
const notification = activeNotifications.get(notificationId);
if (!notification) return;
notification.close();
clearNotificationState(notificationId);
}
export async function getCapabilities(): Promise<string[]> {
if (process.platform !== "linux") {
return [];
}
if (!Notification.isSupported()) {
return [];
}
return ["native-notification", "click"];
}
export function onNotificationAction(callback: NotificationCallback): void {
notificationCallbacks.push(callback);
}
export function removeNotificationCallback(
callback: NotificationCallback,
): void {
const index = notificationCallbacks.indexOf(callback);
if (index > -1) {
notificationCallbacks.splice(index, 1);
}
}
export async function initLinuxNotificationService(): Promise<void> {
if (process.platform !== "linux") {
console.log("[LinuxNotification] Not on Linux, skipping init");
return;
}
if (!Notification.isSupported()) {
console.warn("[LinuxNotification] Notification API is not supported");
return;
}
const caps = await getCapabilities();
console.log("[LinuxNotification] Service initialized with native API:", caps);
}
export async function shutdownLinuxNotificationService(): Promise<void> {
// 清理所有活动的通知
for (const [id, notification] of activeNotifications) {
try {
notification.close();
} catch {}
clearNotificationState(id);
}
// 清理头像文件缓存
try {
await avatarFileCache.clearCache();
} catch {}
console.log("[LinuxNotification] Service shutdown complete");
}

View File

@@ -1,94 +0,0 @@
import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
import { app } from 'electron'
import { ConfigService } from './config'
export interface SessionMessageCacheEntry {
updatedAt: number
messages: any[]
}
export class MessageCacheService {
private readonly cacheFilePath: string
private cache: Record<string, SessionMessageCacheEntry> = {}
private readonly sessionLimit = 150
private readonly maxSessionEntries = 48
constructor(cacheBasePath?: string) {
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
? cacheBasePath
: ConfigService.getInstance().getCacheBasePath()
this.cacheFilePath = join(basePath, 'session-messages.json')
this.ensureCacheDir()
this.loadCache()
}
private ensureCacheDir() {
const dir = dirname(this.cacheFilePath)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
}
private loadCache() {
if (!existsSync(this.cacheFilePath)) return
try {
const raw = readFileSync(this.cacheFilePath, 'utf8')
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object') {
this.cache = parsed
this.pruneSessionEntries()
}
} catch (error) {
console.error('MessageCacheService: 载入缓存失败', error)
this.cache = {}
}
}
private pruneSessionEntries(): void {
const entries = Object.entries(this.cache || {})
if (entries.length <= this.maxSessionEntries) return
entries.sort((left, right) => {
const leftAt = Number(left[1]?.updatedAt || 0)
const rightAt = Number(right[1]?.updatedAt || 0)
return rightAt - leftAt
})
this.cache = Object.fromEntries(entries.slice(0, this.maxSessionEntries))
}
get(sessionId: string): SessionMessageCacheEntry | undefined {
return this.cache[sessionId]
}
set(sessionId: string, messages: any[]): void {
if (!sessionId) return
const trimmed = messages.length > this.sessionLimit
? messages.slice(-this.sessionLimit)
: messages.slice()
this.cache[sessionId] = {
updatedAt: Date.now(),
messages: trimmed
}
this.pruneSessionEntries()
this.persist()
}
private persist() {
try {
writeFileSync(this.cacheFilePath, JSON.stringify(this.cache), 'utf8')
} catch (error) {
console.error('MessageCacheService: 保存缓存失败', error)
}
}
clear(): void {
this.cache = {}
try {
rmSync(this.cacheFilePath, { force: true })
} catch (error) {
console.error('MessageCacheService: 清理缓存失败', error)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,154 +0,0 @@
import { existsSync } from 'fs'
import { join } from 'path'
type NativeDecryptResult = {
data: Buffer
ext: string
isWxgf?: boolean
is_wxgf?: boolean
version?: number
aesSize?: number
aes_size?: number
xorSize?: number
xor_size?: number
rawSize?: number
raw_size?: number
flag?: number
}
export type NativeDatMeta = {
version?: number
aesSize?: number
aes_size?: number
xorSize?: number
xor_size?: number
rawSize?: number
raw_size?: number
flag?: number
}
type NativeAddon = {
decryptDatNative: (inputPath: string, xorKey: number, aesKey?: string) => NativeDecryptResult
encryptDatNative?: (inputPath: string, xorKey: number, aesKey?: string, meta?: NativeDatMeta) => Buffer
}
let cachedAddon: NativeAddon | null | undefined
function shouldEnableNative(): boolean {
return process.env.WEFLOW_IMAGE_NATIVE !== '0'
}
function expandAsarCandidates(filePath: string): string[] {
if (!filePath.includes('app.asar') || filePath.includes('app.asar.unpacked')) {
return [filePath]
}
return [filePath.replace('app.asar', 'app.asar.unpacked'), filePath]
}
function getPlatformDir(): string {
if (process.platform === 'win32') return 'win32'
if (process.platform === 'darwin') return 'macos'
if (process.platform === 'linux') return 'linux'
return process.platform
}
function getArchDir(): string {
if (process.arch === 'x64') return 'x64'
if (process.arch === 'arm64') return 'arm64'
return process.arch
}
function getAddonCandidates(): string[] {
const platformDir = getPlatformDir()
const archDir = getArchDir()
const cwd = process.cwd()
const fileNames = [
`weflow-image-native-${platformDir}-${archDir}.node`
]
const roots = [
join(cwd, 'resources', 'wedecrypt', platformDir, archDir),
...(process.resourcesPath
? [
join(process.resourcesPath, 'resources', 'wedecrypt', platformDir, archDir),
join(process.resourcesPath, 'wedecrypt', platformDir, archDir)
]
: [])
]
const candidates = roots.flatMap((root) => fileNames.map((name) => join(root, name)))
return Array.from(new Set(candidates.flatMap(expandAsarCandidates)))
}
function loadAddon(): NativeAddon | null {
if (!shouldEnableNative()) return null
if (cachedAddon !== undefined) return cachedAddon
for (const candidate of getAddonCandidates()) {
if (!existsSync(candidate)) continue
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const addon = require(candidate) as NativeAddon
if (addon && typeof addon.decryptDatNative === 'function') {
cachedAddon = addon
return addon
}
} catch {
// try next candidate
}
}
cachedAddon = null
return null
}
export function nativeAddonLocation(): string | null {
for (const candidate of getAddonCandidates()) {
if (existsSync(candidate)) return candidate
}
return null
}
export function decryptDatViaNative(
inputPath: string,
xorKey: number,
aesKey?: string
): { data: Buffer; ext: string; isWxgf: boolean; meta: NativeDatMeta } | null {
const addon = loadAddon()
if (!addon) return null
try {
const result = addon.decryptDatNative(inputPath, xorKey, aesKey)
const isWxgf = Boolean(result?.isWxgf ?? result?.is_wxgf)
if (!result || !Buffer.isBuffer(result.data)) return null
const rawExt = typeof result.ext === 'string' && result.ext.trim()
? result.ext.trim().toLowerCase()
: ''
const ext = rawExt ? (rawExt.startsWith('.') ? rawExt : `.${rawExt}`) : ''
const meta: NativeDatMeta = {
version: result.version,
aes_size: result.aes_size ?? result.aesSize,
xor_size: result.xor_size ?? result.xorSize,
raw_size: result.raw_size ?? result.rawSize,
flag: result.flag
}
return { data: result.data, ext, isWxgf, meta }
} catch {
return null
}
}
export function encryptDatViaNative(
inputPath: string,
xorKey: number,
aesKey?: string,
meta?: NativeDatMeta
): Buffer | null {
const addon = loadAddon()
if (!addon || typeof addon.encryptDatNative !== 'function') return null
try {
const result = addon.encryptDatNative(inputPath, xorKey, aesKey, meta)
return Buffer.isBuffer(result) ? result : null
} catch {
return null
}
}

View File

@@ -1,293 +0,0 @@
import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
import { ConfigService } from './config'
const CACHE_VERSION = 2
const MAX_SESSION_ENTRIES_PER_SCOPE = 2000
const MAX_SCOPE_ENTRIES = 12
export interface SessionStatsCacheStats {
totalMessages: number
voiceMessages: number
imageMessages: number
videoMessages: number
emojiMessages: number
transferMessages: number
redPacketMessages: number
callMessages: number
firstTimestamp?: number
lastTimestamp?: number
privateMutualGroups?: number
groupMemberCount?: number
groupMyMessages?: number
groupActiveSpeakers?: number
groupMutualFriends?: number
}
export interface SessionStatsCacheEntry {
updatedAt: number
includeRelations: boolean
stats: SessionStatsCacheStats
}
interface SessionStatsScopeMap {
[sessionId: string]: SessionStatsCacheEntry
}
interface SessionStatsCacheStore {
version: number
scopes: Record<string, SessionStatsScopeMap>
}
function toNonNegativeInt(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
return Math.max(0, Math.floor(value))
}
function normalizeStats(raw: unknown): SessionStatsCacheStats | null {
if (!raw || typeof raw !== 'object') return null
const source = raw as Record<string, unknown>
const totalMessages = toNonNegativeInt(source.totalMessages)
const voiceMessages = toNonNegativeInt(source.voiceMessages)
const imageMessages = toNonNegativeInt(source.imageMessages)
const videoMessages = toNonNegativeInt(source.videoMessages)
const emojiMessages = toNonNegativeInt(source.emojiMessages)
const transferMessages = toNonNegativeInt(source.transferMessages)
const redPacketMessages = toNonNegativeInt(source.redPacketMessages)
const callMessages = toNonNegativeInt(source.callMessages)
if (
totalMessages === undefined ||
voiceMessages === undefined ||
imageMessages === undefined ||
videoMessages === undefined ||
emojiMessages === undefined ||
transferMessages === undefined ||
redPacketMessages === undefined ||
callMessages === undefined
) {
return null
}
const normalized: SessionStatsCacheStats = {
totalMessages,
voiceMessages,
imageMessages,
videoMessages,
emojiMessages,
transferMessages,
redPacketMessages,
callMessages
}
const firstTimestamp = toNonNegativeInt(source.firstTimestamp)
if (firstTimestamp !== undefined) normalized.firstTimestamp = firstTimestamp
const lastTimestamp = toNonNegativeInt(source.lastTimestamp)
if (lastTimestamp !== undefined) normalized.lastTimestamp = lastTimestamp
const privateMutualGroups = toNonNegativeInt(source.privateMutualGroups)
if (privateMutualGroups !== undefined) normalized.privateMutualGroups = privateMutualGroups
const groupMemberCount = toNonNegativeInt(source.groupMemberCount)
if (groupMemberCount !== undefined) normalized.groupMemberCount = groupMemberCount
const groupMyMessages = toNonNegativeInt(source.groupMyMessages)
if (groupMyMessages !== undefined) normalized.groupMyMessages = groupMyMessages
const groupActiveSpeakers = toNonNegativeInt(source.groupActiveSpeakers)
if (groupActiveSpeakers !== undefined) normalized.groupActiveSpeakers = groupActiveSpeakers
const groupMutualFriends = toNonNegativeInt(source.groupMutualFriends)
if (groupMutualFriends !== undefined) normalized.groupMutualFriends = groupMutualFriends
return normalized
}
function normalizeEntry(raw: unknown): SessionStatsCacheEntry | null {
if (!raw || typeof raw !== 'object') return null
const source = raw as Record<string, unknown>
const updatedAt = toNonNegativeInt(source.updatedAt)
const includeRelations = typeof source.includeRelations === 'boolean' ? source.includeRelations : false
const stats = normalizeStats(source.stats)
if (updatedAt === undefined || !stats) {
return null
}
return {
updatedAt,
includeRelations,
stats
}
}
export class SessionStatsCacheService {
private readonly cacheFilePath: string
private store: SessionStatsCacheStore = {
version: CACHE_VERSION,
scopes: {}
}
constructor(cacheBasePath?: string) {
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
? cacheBasePath
: ConfigService.getInstance().getCacheBasePath()
this.cacheFilePath = join(basePath, 'session-stats.json')
this.ensureCacheDir()
this.load()
}
private ensureCacheDir(): void {
const dir = dirname(this.cacheFilePath)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
}
private load(): void {
if (!existsSync(this.cacheFilePath)) return
try {
const raw = readFileSync(this.cacheFilePath, 'utf8')
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object') {
this.store = { version: CACHE_VERSION, scopes: {} }
return
}
const payload = parsed as Record<string, unknown>
const version = Number(payload.version)
if (!Number.isFinite(version) || version !== CACHE_VERSION) {
this.store = { version: CACHE_VERSION, scopes: {} }
return
}
const scopesRaw = payload.scopes
if (!scopesRaw || typeof scopesRaw !== 'object') {
this.store = { version: CACHE_VERSION, scopes: {} }
return
}
const scopes: Record<string, SessionStatsScopeMap> = {}
for (const [scopeKey, scopeValue] of Object.entries(scopesRaw as Record<string, unknown>)) {
if (!scopeValue || typeof scopeValue !== 'object') continue
const normalizedScope: SessionStatsScopeMap = {}
for (const [sessionId, entryRaw] of Object.entries(scopeValue as Record<string, unknown>)) {
const entry = normalizeEntry(entryRaw)
if (!entry) continue
normalizedScope[sessionId] = entry
}
if (Object.keys(normalizedScope).length > 0) {
scopes[scopeKey] = normalizedScope
}
}
this.store = {
version: CACHE_VERSION,
scopes
}
} catch (error) {
console.error('SessionStatsCacheService: 载入缓存失败', error)
this.store = { version: CACHE_VERSION, scopes: {} }
}
}
get(scopeKey: string, sessionId: string): SessionStatsCacheEntry | undefined {
if (!scopeKey || !sessionId) return undefined
const scope = this.store.scopes[scopeKey]
if (!scope) return undefined
const entry = normalizeEntry(scope[sessionId])
if (!entry) {
delete scope[sessionId]
if (Object.keys(scope).length === 0) {
delete this.store.scopes[scopeKey]
}
this.persist()
return undefined
}
return entry
}
set(scopeKey: string, sessionId: string, entry: SessionStatsCacheEntry): void {
if (!scopeKey || !sessionId) return
const normalized = normalizeEntry(entry)
if (!normalized) return
if (!this.store.scopes[scopeKey]) {
this.store.scopes[scopeKey] = {}
}
this.store.scopes[scopeKey][sessionId] = normalized
this.trimScope(scopeKey)
this.trimScopes()
this.persist()
}
delete(scopeKey: string, sessionId: string): void {
if (!scopeKey || !sessionId) return
const scope = this.store.scopes[scopeKey]
if (!scope) return
if (!(sessionId in scope)) return
delete scope[sessionId]
if (Object.keys(scope).length === 0) {
delete this.store.scopes[scopeKey]
}
this.persist()
}
clearScope(scopeKey: string): void {
if (!scopeKey) return
if (!this.store.scopes[scopeKey]) return
delete this.store.scopes[scopeKey]
this.persist()
}
clearAll(): void {
this.store = { version: CACHE_VERSION, scopes: {} }
try {
rmSync(this.cacheFilePath, { force: true })
} catch (error) {
console.error('SessionStatsCacheService: 清理缓存失败', error)
}
}
private trimScope(scopeKey: string): void {
const scope = this.store.scopes[scopeKey]
if (!scope) return
const entries = Object.entries(scope)
if (entries.length <= MAX_SESSION_ENTRIES_PER_SCOPE) return
entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
const trimmed: SessionStatsScopeMap = {}
for (const [sessionId, entry] of entries.slice(0, MAX_SESSION_ENTRIES_PER_SCOPE)) {
trimmed[sessionId] = entry
}
this.store.scopes[scopeKey] = trimmed
}
private trimScopes(): void {
const scopeEntries = Object.entries(this.store.scopes)
if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return
scopeEntries.sort((a, b) => {
const aUpdatedAt = Math.max(...Object.values(a[1]).map((entry) => entry.updatedAt), 0)
const bUpdatedAt = Math.max(...Object.values(b[1]).map((entry) => entry.updatedAt), 0)
return bUpdatedAt - aUpdatedAt
})
const trimmedScopes: Record<string, SessionStatsScopeMap> = {}
for (const [scopeKey, scopeMap] of scopeEntries.slice(0, MAX_SCOPE_ENTRIES)) {
trimmedScopes[scopeKey] = scopeMap
}
this.store.scopes = trimmedScopes
}
private persist(): void {
try {
writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8')
} catch (error) {
console.error('SessionStatsCacheService: 保存缓存失败', error)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,367 +0,0 @@
import https from 'https'
import { createHash } from 'crypto'
import { URL } from 'url'
const WEIBO_TIMEOUT_MS = 10_000
const WEIBO_MAX_POSTS = 5
const WEIBO_CACHE_TTL_MS = 30 * 60 * 1000
const WEIBO_USER_AGENT =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36'
const WEIBO_MOBILE_USER_AGENT =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'
interface BrowserCookieEntry {
domain?: string
name?: string
value?: string
}
interface WeiboUserInfo {
id?: number | string
screen_name?: string
}
interface WeiboWaterFallItem {
id?: number | string
idstr?: string
mblogid?: string
created_at?: string
text_raw?: string
isLongText?: boolean
user?: WeiboUserInfo
retweeted_status?: WeiboWaterFallItem
}
interface WeiboWaterFallResponse {
ok?: number
data?: {
list?: WeiboWaterFallItem[]
next_cursor?: string
}
}
interface WeiboStatusShowResponse {
id?: number | string
idstr?: string
mblogid?: string
created_at?: string
text_raw?: string
user?: WeiboUserInfo
retweeted_status?: WeiboWaterFallItem
}
interface MWeiboCard {
mblog?: WeiboWaterFallItem
card_group?: MWeiboCard[]
}
interface MWeiboContainerResponse {
ok?: number
data?: {
cards?: MWeiboCard[]
}
}
export interface WeiboRecentPost {
id: string
createdAt: string
url: string
text: string
screenName?: string
}
interface CachedRecentPosts {
expiresAt: number
posts: WeiboRecentPost[]
}
function requestJson<T>(url: string, options: { cookie?: string; referer?: string; userAgent?: string }): Promise<T> {
return new Promise((resolve, reject) => {
let urlObj: URL
try {
urlObj = new URL(url)
} catch {
reject(new Error(`无效的微博请求地址:${url}`))
return
}
const headers: Record<string, string> = {
Accept: 'application/json, text/plain, */*',
Referer: options.referer || 'https://weibo.com',
'User-Agent': options.userAgent || WEIBO_USER_AGENT,
'X-Requested-With': 'XMLHttpRequest'
}
if (options.cookie) {
headers.Cookie = options.cookie
}
const req = https.request(
{
hostname: urlObj.hostname,
port: urlObj.port || 443,
path: urlObj.pathname + urlObj.search,
method: 'GET',
headers
},
(res) => {
let raw = ''
res.setEncoding('utf8')
res.on('data', (chunk) => {
raw += chunk
})
res.on('end', () => {
const statusCode = res.statusCode || 0
if (statusCode < 200 || statusCode >= 300) {
reject(new Error(`微博接口返回异常状态码 ${statusCode}`))
return
}
try {
resolve(JSON.parse(raw) as T)
} catch {
reject(new Error('微博接口返回了非 JSON 响应'))
}
})
}
)
req.setTimeout(WEIBO_TIMEOUT_MS, () => {
req.destroy()
reject(new Error('微博请求超时'))
})
req.on('error', reject)
req.end()
})
}
function normalizeCookieArray(entries: BrowserCookieEntry[]): string {
const picked = new Map<string, string>()
for (const entry of entries) {
const name = String(entry?.name || '').trim()
const value = String(entry?.value || '').trim()
const domain = String(entry?.domain || '').trim().toLowerCase()
if (!name || !value) continue
if (domain && !domain.includes('weibo.com') && !domain.includes('weibo.cn')) continue
picked.set(name, value)
}
return Array.from(picked.entries())
.map(([name, value]) => `${name}=${value}`)
.join('; ')
}
export function normalizeWeiboCookieInput(rawInput: string): string {
const trimmed = String(rawInput || '').trim()
if (!trimmed) return ''
try {
const parsed = JSON.parse(trimmed) as unknown
if (Array.isArray(parsed)) {
const normalized = normalizeCookieArray(parsed as BrowserCookieEntry[])
if (normalized) return normalized
throw new Error('Cookie JSON 中未找到可用的微博 Cookie 项')
}
} catch (error) {
if (!(error instanceof SyntaxError)) {
throw error
}
}
return trimmed.replace(/^Cookie:\s*/i, '').trim()
}
function normalizeWeiboUid(input: string): string {
const trimmed = String(input || '').trim()
const directMatch = trimmed.match(/^\d{5,}$/)
if (directMatch) return directMatch[0]
const linkMatch = trimmed.match(/(?:weibo\.com|m\.weibo\.cn)\/u\/(\d{5,})/i)
if (linkMatch) return linkMatch[1]
throw new Error('请输入有效的微博 UID纯数字')
}
function sanitizeWeiboText(text: string): string {
return String(text || '')
.replace(/\u200b|\u200c|\u200d|\ufeff/g, '')
.replace(/https?:\/\/t\.cn\/[A-Za-z0-9]+/g, ' ')
.replace(/ +/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
function mergeRetweetText(item: Pick<WeiboWaterFallItem, 'text_raw' | 'retweeted_status'>): string {
const baseText = sanitizeWeiboText(item.text_raw || '')
const retweetText = sanitizeWeiboText(item.retweeted_status?.text_raw || '')
if (!retweetText) return baseText
if (!baseText || baseText === '转发微博') return `转发:${retweetText}`
return `${baseText}\n\n转发内容${retweetText}`
}
function buildCacheKey(uid: string, count: number, cookie: string): string {
const cookieHash = createHash('sha1').update(cookie).digest('hex')
return `${uid}:${count}:${cookieHash}`
}
class WeiboService {
private recentPostsCache = new Map<string, CachedRecentPosts>()
clearCache(): void {
this.recentPostsCache.clear()
}
async validateUid(
uidInput: string,
cookieInput: string
): Promise<{ success: boolean; uid?: string; screenName?: string; error?: string }> {
try {
const uid = normalizeWeiboUid(uidInput)
const cookie = normalizeWeiboCookieInput(cookieInput)
if (!cookie) {
return { success: true, uid }
}
const timeline = await this.fetchTimeline(uid, cookie)
const firstItem = timeline.data?.list?.[0]
if (!firstItem) {
return { success: false, error: '该微博账号暂无可读取的近期公开内容,或当前 Cookie 已失效' }
}
return {
success: true,
uid,
screenName: firstItem.user?.screen_name
}
} catch (error) {
return {
success: false,
error: (error as Error).message || '微博 UID 校验失败'
}
}
}
async fetchRecentPosts(
uidInput: string,
cookieInput: string,
requestedCount: number
): Promise<WeiboRecentPost[]> {
const uid = normalizeWeiboUid(uidInput)
const cookie = normalizeWeiboCookieInput(cookieInput)
const hasCookie = Boolean(cookie)
const count = Math.max(1, Math.min(WEIBO_MAX_POSTS, Math.floor(Number(requestedCount) || 0)))
const cacheKey = buildCacheKey(uid, count, hasCookie ? cookie : '__no_cookie_mobile__')
const cached = this.recentPostsCache.get(cacheKey)
const now = Date.now()
if (cached && cached.expiresAt > now) {
return cached.posts
}
const rawItems = hasCookie
? (await this.fetchTimeline(uid, cookie)).data?.list || []
: await this.fetchMobileTimeline(uid)
const posts: WeiboRecentPost[] = []
for (const item of rawItems) {
if (posts.length >= count) break
const id = String(item.idstr || item.id || '').trim()
if (!id) continue
let text = mergeRetweetText(item)
if (item.isLongText && hasCookie) {
try {
const detail = await this.fetchDetail(id, cookie)
text = mergeRetweetText(detail)
} catch {
// 长文补抓失败时回退到列表摘要
}
}
text = sanitizeWeiboText(text)
if (!text) continue
posts.push({
id,
createdAt: String(item.created_at || ''),
url: `https://m.weibo.cn/detail/${id}`,
text,
screenName: item.user?.screen_name
})
}
this.recentPostsCache.set(cacheKey, {
expiresAt: now + WEIBO_CACHE_TTL_MS,
posts
})
return posts
}
private fetchTimeline(uid: string, cookie: string): Promise<WeiboWaterFallResponse> {
return requestJson<WeiboWaterFallResponse>(
`https://weibo.com/ajax/profile/getWaterFallContent?uid=${encodeURIComponent(uid)}`,
{
cookie,
referer: `https://weibo.com/u/${encodeURIComponent(uid)}`
}
).then((response) => {
if (response.ok !== 1 || !Array.isArray(response.data?.list)) {
throw new Error('微博时间线获取失败,请检查 Cookie 是否仍然有效')
}
return response
})
}
private fetchMobileTimeline(uid: string): Promise<WeiboWaterFallItem[]> {
const containerid = `107603${uid}`
return requestJson<MWeiboContainerResponse>(
`https://m.weibo.cn/api/container/getIndex?type=uid&value=${encodeURIComponent(uid)}&containerid=${encodeURIComponent(containerid)}`,
{
referer: `https://m.weibo.cn/u/${encodeURIComponent(uid)}`,
userAgent: WEIBO_MOBILE_USER_AGENT
}
).then((response) => {
if (response.ok !== 1 || !Array.isArray(response.data?.cards)) {
throw new Error('微博时间线获取失败,请稍后重试')
}
const rows: WeiboWaterFallItem[] = []
for (const card of response.data.cards) {
if (card?.mblog) rows.push(card.mblog)
if (Array.isArray(card?.card_group)) {
for (const subCard of card.card_group) {
if (subCard?.mblog) rows.push(subCard.mblog)
}
}
}
if (rows.length === 0) {
throw new Error('该微博账号暂无可读取的近期公开内容')
}
return rows
})
}
private fetchDetail(id: string, cookie: string): Promise<WeiboStatusShowResponse> {
return requestJson<WeiboStatusShowResponse>(
`https://weibo.com/ajax/statuses/show?id=${encodeURIComponent(id)}&isGetLongText=true`,
{
cookie,
referer: `https://weibo.com/detail/${encodeURIComponent(id)}`
}
).then((response) => {
if (!response || (!response.id && !response.idstr)) {
throw new Error('微博详情获取失败')
}
return response
})
}
}
export const weiboService = new WeiboService()

View File

@@ -1,607 +0,0 @@
import { join } from 'path'
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs'
import { pathToFileURL } from 'url'
import { app } from 'electron'
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
export interface VideoInfo {
videoUrl?: string // 视频文件路径(用于 readFile
coverUrl?: string // 封面 data URL
thumbUrl?: string // 缩略图 data URL
exists: boolean
}
interface TimedCacheEntry<T> {
value: T
expiresAt: number
}
interface VideoIndexEntry {
videoPath?: string
coverPath?: string
thumbPath?: string
}
type PosterFormat = 'dataUrl' | 'fileUrl'
class VideoService {
private configService: ConfigService
private hardlinkResolveCache = new Map<string, TimedCacheEntry<string | null>>()
private videoInfoCache = new Map<string, TimedCacheEntry<VideoInfo>>()
private videoDirIndexCache = new Map<string, TimedCacheEntry<Map<string, VideoIndexEntry>>>()
private pendingVideoInfo = new Map<string, Promise<VideoInfo>>()
private readonly hardlinkCacheTtlMs = 10 * 60 * 1000
private readonly videoInfoCacheTtlMs = 2 * 60 * 1000
private readonly videoIndexCacheTtlMs = 90 * 1000
private readonly maxCacheEntries = 2000
private readonly maxIndexEntries = 6
constructor() {
this.configService = new ConfigService()
}
private log(message: string, meta?: Record<string, unknown>): void {
try {
const timestamp = new Date().toISOString()
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
const logDir = join(app.getPath('userData'), 'logs')
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true })
appendFileSync(join(logDir, 'wcdb.log'), `[${timestamp}] [VideoService] ${message}${metaStr}\n`, 'utf8')
} catch { }
}
private readTimedCache<T>(cache: Map<string, TimedCacheEntry<T>>, key: string): T | undefined {
const hit = cache.get(key)
if (!hit) return undefined
if (hit.expiresAt <= Date.now()) {
cache.delete(key)
return undefined
}
return hit.value
}
private writeTimedCache<T>(
cache: Map<string, TimedCacheEntry<T>>,
key: string,
value: T,
ttlMs: number,
maxEntries: number
): void {
cache.set(key, { value, expiresAt: Date.now() + ttlMs })
if (cache.size <= maxEntries) return
const now = Date.now()
for (const [cacheKey, entry] of cache) {
if (entry.expiresAt <= now) {
cache.delete(cacheKey)
}
}
while (cache.size > maxEntries) {
const oldestKey = cache.keys().next().value as string | undefined
if (!oldestKey) break
cache.delete(oldestKey)
}
}
/**
* 获取数据库根目录
*/
private getDbPath(): string {
return this.configService.get('dbPath') || ''
}
/**
* 获取当前用户的wxid
*/
private getMyWxid(): string {
return this.configService.getMyWxidCleaned() || ''
}
/**
* 清理 wxid 目录名(去掉后缀)
*/
private cleanWxid(wxid: string): string {
const trimmed = wxid.trim()
if (!trimmed) return trimmed
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
if (match) return match[1]
return trimmed
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
if (suffixMatch) return suffixMatch[1]
return trimmed
}
private getScopeKey(dbPath: string, wxid: string): string {
return `${dbPath}::${this.cleanWxid(wxid)}`.toLowerCase()
}
private resolveVideoBaseDir(dbPath: string, wxid: string): string {
const cleanedWxid = this.cleanWxid(wxid)
const dbPathLower = dbPath.toLowerCase()
const wxidLower = wxid.toLowerCase()
const cleanedWxidLower = cleanedWxid.toLowerCase()
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
if (dbPathContainsWxid) {
return join(dbPath, 'msg', 'video')
}
// 使用 ConfigService 的统一账号目录解析
const accountDir = this.configService.getAccountDir(dbPath, wxid)
if (accountDir) {
return join(accountDir, 'msg', 'video')
}
// 回退到原始逻辑
return join(dbPath, wxid, 'msg', 'video')
}
private getHardlinkDbPaths(dbPath: string, wxid: string, cleanedWxid: string): string[] {
const dbPathLower = dbPath.toLowerCase()
const wxidLower = wxid.toLowerCase()
const cleanedWxidLower = cleanedWxid.toLowerCase()
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
if (dbPathContainsWxid) {
return [join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')]
}
// 使用 ConfigService 的统一账号目录解析
const accountDir = this.configService.getAccountDir(dbPath, wxid)
if (accountDir) {
return [join(accountDir, 'db_storage', 'hardlink', 'hardlink.db')]
}
// 回退到原始逻辑
return [
join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'),
join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')
]
}
/**
* 从 video_hardlink_info_v4 表查询视频文件名
* 使用 wcdb 专属接口查询加密的 hardlink.db
*/
private async resolveVideoHardlinks(
md5List: string[],
dbPath: string,
wxid: string,
cleanedWxid: string
): Promise<Map<string, string>> {
const scopeKey = this.getScopeKey(dbPath, wxid)
const normalizedList = Array.from(
new Set((md5List || []).map((item) => String(item || '').trim().toLowerCase()).filter(Boolean))
)
const resolvedMap = new Map<string, string>()
const unresolvedSet = new Set(normalizedList)
for (const md5 of normalizedList) {
const cacheKey = `${scopeKey}|${md5}`
const cached = this.readTimedCache(this.hardlinkResolveCache, cacheKey)
if (cached === undefined) continue
if (cached) resolvedMap.set(md5, cached)
unresolvedSet.delete(md5)
}
if (unresolvedSet.size === 0) return resolvedMap
const encryptedDbPaths = this.getHardlinkDbPaths(dbPath, wxid, cleanedWxid)
for (const p of encryptedDbPaths) {
if (!existsSync(p) || unresolvedSet.size === 0) continue
const unresolved = Array.from(unresolvedSet)
const requests = unresolved.map((md5) => ({ md5, dbPath: p }))
try {
const batchResult = await wcdbService.resolveVideoHardlinkMd5Batch(requests)
if (batchResult.success && Array.isArray(batchResult.rows)) {
for (const row of batchResult.rows) {
const index = Number.isFinite(Number(row?.index)) ? Math.floor(Number(row?.index)) : -1
const inputMd5 = index >= 0 && index < requests.length
? requests[index].md5
: String(row?.md5 || '').trim().toLowerCase()
if (!inputMd5) continue
const resolvedMd5 = row?.success && row?.data?.resolved_md5
? String(row.data.resolved_md5).trim().toLowerCase()
: ''
if (!resolvedMd5) continue
const cacheKey = `${scopeKey}|${inputMd5}`
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries)
resolvedMap.set(inputMd5, resolvedMd5)
unresolvedSet.delete(inputMd5)
}
} else {
// 兼容不支持批量接口的版本,回退单条请求。
for (const req of requests) {
try {
const single = await wcdbService.resolveVideoHardlinkMd5(req.md5, req.dbPath)
const resolvedMd5 = single.success && single.data?.resolved_md5
? String(single.data.resolved_md5).trim().toLowerCase()
: ''
if (!resolvedMd5) continue
const cacheKey = `${scopeKey}|${req.md5}`
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries)
resolvedMap.set(req.md5, resolvedMd5)
unresolvedSet.delete(req.md5)
} catch { }
}
}
} catch (e) {
this.log('resolveVideoHardlinks 批量查询失败', { path: p, error: String(e) })
}
}
for (const md5 of unresolvedSet) {
const cacheKey = `${scopeKey}|${md5}`
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, null, this.hardlinkCacheTtlMs, this.maxCacheEntries)
}
return resolvedMap
}
private async queryVideoFileName(md5: string): Promise<string | undefined> {
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
const cleanedWxid = this.cleanWxid(wxid)
this.log('queryVideoFileName 开始', { md5: normalizedMd5, wxid, cleanedWxid, dbPath })
if (!normalizedMd5 || !wxid || !dbPath) {
this.log('queryVideoFileName: 参数缺失', { hasMd5: !!normalizedMd5, hasWxid: !!wxid, hasDbPath: !!dbPath })
return undefined
}
const resolvedMap = await this.resolveVideoHardlinks([normalizedMd5], dbPath, wxid, cleanedWxid)
const resolved = resolvedMap.get(normalizedMd5)
if (resolved) {
this.log('queryVideoFileName 命中', { input: normalizedMd5, resolved })
return resolved
}
return undefined
}
async preloadVideoHardlinkMd5s(md5List: string[]): Promise<void> {
// 视频链路已改为直接使用 packed_info_data 提取出的文件名索引本地目录。
// 该预热接口保留仅为兼容旧调用方,不再查询 hardlink.db。
void md5List
}
private fileToPosterUrl(filePath: string | undefined, mimeType: string, posterFormat: PosterFormat): string | undefined {
try {
if (!filePath || !existsSync(filePath)) return undefined
if (posterFormat === 'fileUrl') return pathToFileURL(filePath).toString()
const buffer = readFileSync(filePath)
return `data:${mimeType};base64,${buffer.toString('base64')}`
} catch {
return undefined
}
}
private getOrBuildVideoIndex(videoBaseDir: string): Map<string, VideoIndexEntry> {
const cached = this.readTimedCache(this.videoDirIndexCache, videoBaseDir)
if (cached) return cached
const index = new Map<string, VideoIndexEntry>()
const ensureEntry = (key: string): VideoIndexEntry => {
let entry = index.get(key)
if (!entry) {
entry = {}
index.set(key, entry)
}
return entry
}
try {
const yearMonthDirs = readdirSync(videoBaseDir)
.filter((dir) => {
const dirPath = join(videoBaseDir, dir)
try {
return statSync(dirPath).isDirectory()
} catch {
return false
}
})
.sort((a, b) => b.localeCompare(a))
for (const yearMonth of yearMonthDirs) {
const dirPath = join(videoBaseDir, yearMonth)
let files: string[] = []
try {
files = readdirSync(dirPath)
} catch {
continue
}
for (const file of files) {
const lower = file.toLowerCase()
const fullPath = join(dirPath, file)
if (lower.endsWith('.mp4')) {
const md5 = lower.slice(0, -4)
const entry = ensureEntry(md5)
if (!entry.videoPath) entry.videoPath = fullPath
if (md5.endsWith('_raw')) {
const baseMd5 = md5.replace(/_raw$/, '')
const baseEntry = ensureEntry(baseMd5)
if (!baseEntry.videoPath) baseEntry.videoPath = fullPath
}
continue
}
if (!lower.endsWith('.jpg')) continue
const jpgBase = lower.slice(0, -4)
if (jpgBase.endsWith('_thumb')) {
const baseMd5 = jpgBase.slice(0, -6)
const entry = ensureEntry(baseMd5)
if (!entry.thumbPath) entry.thumbPath = fullPath
} else {
const entry = ensureEntry(jpgBase)
if (!entry.coverPath) entry.coverPath = fullPath
}
}
}
for (const [key, entry] of index) {
if (!key.endsWith('_raw')) continue
const baseKey = key.replace(/_raw$/, '')
const baseEntry = index.get(baseKey)
if (!baseEntry) continue
if (!entry.coverPath) entry.coverPath = baseEntry.coverPath
if (!entry.thumbPath) entry.thumbPath = baseEntry.thumbPath
}
} catch (e) {
this.log('构建视频索引失败', { videoBaseDir, error: String(e) })
}
this.writeTimedCache(
this.videoDirIndexCache,
videoBaseDir,
index,
this.videoIndexCacheTtlMs,
this.maxIndexEntries
)
return index
}
private getVideoInfoFromIndex(
index: Map<string, VideoIndexEntry>,
md5: string,
includePoster = true,
posterFormat: PosterFormat = 'dataUrl'
): VideoInfo | null {
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
if (!normalizedMd5) return null
const candidates = [normalizedMd5]
const baseMd5 = normalizedMd5.replace(/_raw$/, '')
if (baseMd5 !== normalizedMd5) {
candidates.push(baseMd5)
} else {
candidates.push(`${normalizedMd5}_raw`)
}
for (const key of candidates) {
const entry = index.get(key)
if (!entry?.videoPath) continue
if (!existsSync(entry.videoPath)) continue
if (!includePoster) {
return {
videoUrl: entry.videoPath,
exists: true
}
}
return {
videoUrl: entry.videoPath,
coverUrl: this.fileToPosterUrl(entry.coverPath, 'image/jpeg', posterFormat),
thumbUrl: this.fileToPosterUrl(entry.thumbPath, 'image/jpeg', posterFormat),
exists: true
}
}
return null
}
private normalizeVideoLookupKey(value: string): string {
let text = String(value || '').trim().toLowerCase()
if (!text) return ''
text = text.replace(/^.*[\\/]/, '')
text = text.replace(/\.(?:mp4|mov|m4v|avi|mkv|flv|jpg|jpeg|png|gif|dat)$/i, '')
text = text.replace(/_thumb$/, '')
const direct = /^([a-f0-9]{16,64})(?:_raw)?$/i.exec(text)
if (direct) {
const suffix = /_raw$/i.test(text) ? '_raw' : ''
return `${direct[1].toLowerCase()}${suffix}`
}
const preferred32 = /([a-f0-9]{32})(?![a-f0-9])/i.exec(text)
if (preferred32?.[1]) return preferred32[1].toLowerCase()
const fallback = /([a-f0-9]{16,64})(?![a-f0-9])/i.exec(text)
return String(fallback?.[1] || '').toLowerCase()
}
private fallbackScanVideo(
videoBaseDir: string,
realVideoMd5: string,
includePoster = true,
posterFormat: PosterFormat = 'dataUrl'
): VideoInfo | null {
try {
const yearMonthDirs = readdirSync(videoBaseDir)
.filter((dir) => {
const dirPath = join(videoBaseDir, dir)
try {
return statSync(dirPath).isDirectory()
} catch {
return false
}
})
.sort((a, b) => b.localeCompare(a))
for (const yearMonth of yearMonthDirs) {
const dirPath = join(videoBaseDir, yearMonth)
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
if (!existsSync(videoPath)) continue
if (!includePoster) {
return {
videoUrl: videoPath,
exists: true
}
}
const baseMd5 = realVideoMd5.replace(/_raw$/, '')
const coverPath = join(dirPath, `${baseMd5}.jpg`)
const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`)
return {
videoUrl: videoPath,
coverUrl: this.fileToPosterUrl(coverPath, 'image/jpeg', posterFormat),
thumbUrl: this.fileToPosterUrl(thumbPath, 'image/jpeg', posterFormat),
exists: true
}
}
} catch (e) {
this.log('fallback 扫描视频目录失败', { error: String(e) })
}
return null
}
private async ensurePoster(info: VideoInfo, includePoster: boolean, posterFormat: PosterFormat): Promise<VideoInfo> {
void posterFormat
if (!includePoster) return info
return info
}
/**
* 根据视频MD5获取视频文件信息
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
*/
async getVideoInfo(videoMd5: string, options?: { includePoster?: boolean; posterFormat?: PosterFormat }): Promise<VideoInfo> {
const normalizedMd5 = String(videoMd5 || '').trim().toLowerCase()
const includePoster = options?.includePoster !== false
const posterFormat: PosterFormat = options?.posterFormat === 'fileUrl' ? 'fileUrl' : 'dataUrl'
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
this.log('getVideoInfo 开始', { videoMd5: normalizedMd5, dbPath, wxid })
if (!dbPath || !wxid || !normalizedMd5) {
this.log('getVideoInfo: 参数缺失', { hasDbPath: !!dbPath, hasWxid: !!wxid, hasVideoMd5: !!normalizedMd5 })
return { exists: false }
}
const scopeKey = this.getScopeKey(dbPath, wxid)
const cacheKey = `${scopeKey}|${normalizedMd5}|poster=${includePoster ? 1 : 0}|format=${posterFormat}`
const cachedInfo = this.readTimedCache(this.videoInfoCache, cacheKey)
if (cachedInfo) return cachedInfo
const pending = this.pendingVideoInfo.get(cacheKey)
if (pending) return pending
const task = (async (): Promise<VideoInfo> => {
const realVideoMd5 = this.normalizeVideoLookupKey(normalizedMd5) || normalizedMd5
const videoBaseDir = this.resolveVideoBaseDir(dbPath, wxid)
if (!existsSync(videoBaseDir)) {
const miss = { exists: false }
this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries)
return miss
}
const index = this.getOrBuildVideoIndex(videoBaseDir)
const indexed = this.getVideoInfoFromIndex(index, realVideoMd5, includePoster, posterFormat)
if (indexed) {
const withPoster = await this.ensurePoster(indexed, includePoster, posterFormat)
this.writeTimedCache(this.videoInfoCache, cacheKey, withPoster, this.videoInfoCacheTtlMs, this.maxCacheEntries)
return withPoster
}
const fallback = this.fallbackScanVideo(videoBaseDir, realVideoMd5, includePoster, posterFormat)
if (fallback) {
const withPoster = await this.ensurePoster(fallback, includePoster, posterFormat)
this.writeTimedCache(this.videoInfoCache, cacheKey, withPoster, this.videoInfoCacheTtlMs, this.maxCacheEntries)
return withPoster
}
const miss = { exists: false }
this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries)
this.log('getVideoInfo: 未找到视频', { lookupKey: normalizedMd5, normalizedKey: realVideoMd5 })
return miss
})()
this.pendingVideoInfo.set(cacheKey, task)
try {
return await task
} finally {
this.pendingVideoInfo.delete(cacheKey)
}
}
/**
* 根据消息内容解析视频MD5
*/
parseVideoMd5(content: string): string | undefined {
if (!content) return undefined
// 打印原始 XML 前 800 字符,帮助排查自己发的视频结构
this.log('parseVideoMd5 原始内容', { preview: content.slice(0, 800) })
try {
// 收集所有 md5 相关属性,方便对比
const allMd5Attrs: string[] = []
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]*)['"]/gi
let match
while ((match = md5Regex.exec(content)) !== null) {
allMd5Attrs.push(match[0])
}
this.log('parseVideoMd5 所有 md5 属性', { attrs: allMd5Attrs })
// 方法1从 <videomsg md5="..."> 提取(收到的视频)
const videoMsgMd5Match = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (videoMsgMd5Match) {
this.log('parseVideoMd5 命中 videomsg md5 属性', { md5: videoMsgMd5Match[1] })
return videoMsgMd5Match[1].toLowerCase()
}
// 方法2从 <videomsg rawmd5="..."> 提取(自己发的视频,没有 md5 只有 rawmd5
const rawMd5Match = /<videomsg[^>]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (rawMd5Match) {
this.log('parseVideoMd5 命中 videomsg rawmd5 属性(自发视频)', { rawmd5: rawMd5Match[1] })
return rawMd5Match[1].toLowerCase()
}
// 方法3任意属性 md5="..."(非 rawmd5/cdnthumbaeskey 等)
const attrMatch = /(?<![a-z])md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (attrMatch) {
this.log('parseVideoMd5 命中通用 md5 属性', { md5: attrMatch[1] })
return attrMatch[1].toLowerCase()
}
// 方法4<md5>...</md5> 标签
const md5TagMatch = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
if (md5TagMatch) {
this.log('parseVideoMd5 命中 md5 标签', { md5: md5TagMatch[1] })
return md5TagMatch[1].toLowerCase()
}
// 方法5兜底取 rawmd5 属性(任意位置)
const rawMd5Fallback = /\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (rawMd5Fallback) {
this.log('parseVideoMd5 兜底命中 rawmd5', { rawmd5: rawMd5Fallback[1] })
return rawMd5Fallback[1].toLowerCase()
}
this.log('parseVideoMd5 未提取到任何 md5', { contentLength: content.length })
} catch (e) {
this.log('parseVideoMd5 异常', { error: String(e) })
}
return undefined
}
}
export const videoService = new VideoService()

View File

@@ -1,543 +0,0 @@
import { app } from 'electron'
import { existsSync, mkdirSync, statSync, unlinkSync, createWriteStream, openSync, writeSync, closeSync } from 'fs'
import { join } from 'path'
import * as https from 'https'
import * as http from 'http'
import { ConfigService } from './config'
// Sherpa-onnx 类型定义
type OfflineRecognizer = any
type OfflineStream = any
type ModelInfo = {
name: string
files: {
model: string
tokens: string
}
sizeBytes: number
sizeLabel: string
}
type DownloadProgress = {
modelName: string
downloadedBytes: number
totalBytes?: number
percent?: number
speed?: number
}
const SENSEVOICE_MODEL: ModelInfo = {
name: 'SenseVoiceSmall',
files: {
model: 'model.int8.onnx',
tokens: 'tokens.txt'
},
sizeBytes: 245_000_000,
sizeLabel: '245 MB'
}
const MODEL_DOWNLOAD_URLS = {
model: 'https://modelscope.cn/models/pengzhendong/sherpa-onnx-sense-voice-zh-en-ja-ko-yue/resolve/master/model.int8.onnx',
tokens: 'https://modelscope.cn/models/pengzhendong/sherpa-onnx-sense-voice-zh-en-ja-ko-yue/resolve/master/tokens.txt'
}
export class VoiceTranscribeService {
private configService = new ConfigService()
private downloadTasks = new Map<string, Promise<{ success: boolean; path?: string; error?: string }>>()
private recognizer: OfflineRecognizer | null = null
private isInitializing = false
private buildTranscribeWorkerEnv(): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...process.env }
const platform = process.platform === 'win32' ? 'win' : process.platform
const platformPkg = `sherpa-onnx-${platform}-${process.arch}`
const candidates = [
join(__dirname, '..', 'node_modules', platformPkg),
join(__dirname, 'node_modules', platformPkg),
join(process.cwd(), 'node_modules', platformPkg),
process.resourcesPath ? join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', platformPkg) : ''
].filter((item): item is string => Boolean(item) && existsSync(item))
if (process.platform === 'darwin') {
const key = 'DYLD_LIBRARY_PATH'
const existing = env[key] || ''
const merged = [...candidates, ...existing.split(':').filter(Boolean)]
env[key] = Array.from(new Set(merged)).join(':')
if (candidates.length === 0) {
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
}
} else if (process.platform === 'linux') {
const key = 'LD_LIBRARY_PATH'
const existing = env[key] || ''
const merged = [...candidates, ...existing.split(':').filter(Boolean)]
env[key] = Array.from(new Set(merged)).join(':')
if (candidates.length === 0) {
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
}
} else if (process.platform === 'win32') {
// Windows: 把 sherpa-onnx 所在目录加到 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
}
private resolveModelDir(): string {
const configured = this.configService.get('whisperModelDir') as string | undefined
if (configured) return configured
return join(app.getPath('documents'), 'WeFlow', 'models', 'sensevoice')
}
private resolveModelPath(fileName: string): string {
return join(this.resolveModelDir(), fileName)
}
/**
* 检查模型状态
*/
async getModelStatus(): Promise<{
success: boolean
exists?: boolean
modelPath?: string
tokensPath?: string
sizeBytes?: number
error?: string
}> {
try {
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens)
const modelExists = existsSync(modelPath)
const tokensExists = existsSync(tokensPath)
const exists = modelExists && tokensExists
if (!exists) {
return { success: true, exists: false, modelPath, tokensPath }
}
const modelSize = statSync(modelPath).size
const tokensSize = statSync(tokensPath).size
const totalSize = modelSize + tokensSize
return {
success: true,
exists: true,
modelPath,
tokensPath,
sizeBytes: totalSize
}
} catch (error) {
return { success: false, error: String(error) }
}
}
/**
* 下载模型文件
*/
async downloadModel(
onProgress?: (progress: DownloadProgress) => void
): Promise<{ success: boolean; modelPath?: string; tokensPath?: string; error?: string }> {
const cacheKey = 'sensevoice'
const pending = this.downloadTasks.get(cacheKey)
if (pending) return pending
const task = (async () => {
try {
const modelDir = this.resolveModelDir()
if (!existsSync(modelDir)) {
mkdirSync(modelDir, { recursive: true })
}
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens)
// 初始进度
onProgress?.({
modelName: SENSEVOICE_MODEL.name,
downloadedBytes: 0,
totalBytes: SENSEVOICE_MODEL.sizeBytes,
percent: 0
})
// 下载模型文件 (80% 权重)
console.info('[VoiceTranscribe] 开始下载模型文件...')
await this.downloadToFile(
MODEL_DOWNLOAD_URLS.model,
modelPath,
'model',
(downloaded, total, speed) => {
const percent = total ? (downloaded / total) * 80 : 0
onProgress?.({
modelName: SENSEVOICE_MODEL.name,
downloadedBytes: downloaded,
totalBytes: SENSEVOICE_MODEL.sizeBytes,
percent,
speed
})
}
)
// 下载 tokens 文件 (20% 权重)
console.info('[VoiceTranscribe] 开始下载 tokens 文件...')
await this.downloadToFile(
MODEL_DOWNLOAD_URLS.tokens,
tokensPath,
'tokens',
(downloaded, total, speed) => {
const modelSize = existsSync(modelPath) ? statSync(modelPath).size : 0
const percent = total ? 80 + (downloaded / total) * 20 : 80
onProgress?.({
modelName: SENSEVOICE_MODEL.name,
downloadedBytes: modelSize + downloaded,
totalBytes: SENSEVOICE_MODEL.sizeBytes,
percent,
speed
})
}
)
console.info('[VoiceTranscribe] 模型下载完成')
return { success: true, modelPath, tokensPath }
} catch (error) {
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens)
try {
if (existsSync(modelPath)) unlinkSync(modelPath)
if (existsSync(tokensPath)) unlinkSync(tokensPath)
} catch { }
return { success: false, error: String(error) }
} finally {
this.downloadTasks.delete(cacheKey)
}
})()
this.downloadTasks.set(cacheKey, task)
return task
}
/**
* 转写 WAV 音频数据
*/
async transcribeWavBuffer(
wavData: Buffer,
onPartial?: (text: string) => void,
languages?: string[]
): Promise<{ success: boolean; transcript?: string; error?: string }> {
return new Promise((resolve) => {
try {
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens)
if (!existsSync(modelPath) || !existsSync(tokensPath)) {
resolve({ success: false, error: '模型文件不存在,请先下载模型' })
return
}
let supportedLanguages = languages
if (!supportedLanguages || supportedLanguages.length === 0) {
supportedLanguages = this.configService.get('transcribeLanguages')
if (!supportedLanguages || supportedLanguages.length === 0) {
supportedLanguages = ['zh', 'yue']
}
}
const { fork } = require('child_process')
const workerPath = join(__dirname, 'transcribeWorker.js')
const worker = fork(workerPath, [], {
env: this.buildTranscribeWorkerEnv(),
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
serialization: 'advanced'
})
worker.send({
modelPath,
tokensPath,
wavData,
sampleRate: 16000,
languages: supportedLanguages
})
let finalTranscript = ''
worker.on('message', (msg: any) => {
if (msg.type === 'partial') {
onPartial?.(msg.text)
} else if (msg.type === 'final') {
finalTranscript = msg.text
resolve({ success: true, transcript: finalTranscript })
worker.disconnect()
worker.kill()
} else if (msg.type === 'error') {
console.error('[VoiceTranscribe] Worker 错误:', msg.error)
resolve({ success: false, error: msg.error })
worker.disconnect()
worker.kill()
}
})
worker.on('error', (err: Error) => resolve({ success: false, error: String(err) }))
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) {
resolve({ success: false, error: String(error) })
}
})
}
/**
* 下载文件 (支持多线程)
*/
private async downloadToFile(
url: string,
targetPath: string,
fileName: string,
onProgress?: (downloaded: number, total?: number, speed?: number) => void
): Promise<void> {
if (existsSync(targetPath)) {
unlinkSync(targetPath)
}
console.info(`[VoiceTranscribe] 准备下载 ${fileName}: ${url}`)
// 1. 探测支持情况
let probeResult
try {
probeResult = await this.probeUrl(url)
} catch (err) {
console.warn(`[VoiceTranscribe] ${fileName} 探测失败,使用单线程`, err)
return this.downloadSingleThread(url, targetPath, fileName, onProgress)
}
const { totalSize, acceptRanges, finalUrl } = probeResult
// 如果文件太小 (< 2MB) 或者不支持 Range使用单线程
if (totalSize < 2 * 1024 * 1024 || !acceptRanges) {
return this.downloadSingleThread(finalUrl, targetPath, fileName, onProgress)
}
console.info(`[VoiceTranscribe] ${fileName} 开始多线程下载 (4 线程), 大小: ${(totalSize / 1024 / 1024).toFixed(2)} MB`)
const threadCount = 4
const chunkSize = Math.ceil(totalSize / threadCount)
const fd = openSync(targetPath, 'w')
let downloadedTotal = 0
let lastDownloaded = 0
let lastTime = Date.now()
let speed = 0
const speedInterval = setInterval(() => {
const now = Date.now()
const duration = (now - lastTime) / 1000
if (duration > 0) {
speed = (downloadedTotal - lastDownloaded) / duration
lastDownloaded = downloadedTotal
lastTime = now
onProgress?.(downloadedTotal, totalSize, speed)
}
}, 1000)
try {
const promises = []
for (let i = 0; i < threadCount; i++) {
const start = i * chunkSize
const end = i === threadCount - 1 ? totalSize - 1 : (i + 1) * chunkSize - 1
promises.push(this.downloadChunk(finalUrl, fd, start, end, (bytes) => {
downloadedTotal += bytes
}))
}
await Promise.all(promises)
// Final progress update
onProgress?.(totalSize, totalSize, 0)
console.info(`[VoiceTranscribe] ${fileName} 多线程下载完成`)
} catch (err) {
console.error(`[VoiceTranscribe] ${fileName} 多线程下载失败:`, err)
throw err
} finally {
clearInterval(speedInterval)
closeSync(fd)
}
}
private async probeUrl(url: string, remainingRedirects = 5): Promise<{ totalSize: number, acceptRanges: boolean, finalUrl: string }> {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http
const options = {
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'https://modelscope.cn/',
'Range': 'bytes=0-0'
}
}
const req = protocol.get(url, options, (res) => {
if ([301, 302, 303, 307, 308].includes(res.statusCode || 0)) {
const location = res.headers.location
if (location && remainingRedirects > 0) {
const nextUrl = new URL(location, url).href
this.probeUrl(nextUrl, remainingRedirects - 1).then(resolve).catch(reject)
return
}
}
if (res.statusCode !== 206 && res.statusCode !== 200) {
reject(new Error(`Probe failed: HTTP ${res.statusCode}`))
return
}
const contentRange = res.headers['content-range']
let totalSize = 0
if (contentRange) {
const parts = contentRange.split('/')
totalSize = parseInt(parts[parts.length - 1], 10)
} else {
totalSize = parseInt(res.headers['content-length'] || '0', 10)
}
const acceptRanges = res.headers['accept-ranges'] === 'bytes' || !!contentRange
resolve({ totalSize, acceptRanges, finalUrl: url })
res.destroy()
})
req.on('error', reject)
})
}
private async downloadChunk(url: string, fd: number, start: number, end: number, onData: (bytes: number) => void): Promise<void> {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http
const options = {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'https://modelscope.cn/',
'Range': `bytes=${start}-${end}`
}
}
const req = protocol.get(url, options, (res) => {
if (res.statusCode !== 206) {
reject(new Error(`Chunk download failed: HTTP ${res.statusCode}`))
return
}
let currentOffset = start
res.on('data', (chunk: Buffer) => {
try {
writeSync(fd, chunk, 0, chunk.length, currentOffset)
currentOffset += chunk.length
onData(chunk.length)
} catch (err) {
reject(err)
res.destroy()
}
})
res.on('end', () => resolve())
res.on('error', reject)
})
req.on('error', reject)
})
}
private async downloadSingleThread(url: string, targetPath: string, fileName: string, onProgress?: (downloaded: number, total?: number, speed?: number) => void, remainingRedirects = 5): Promise<void> {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http
const options = {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'https://modelscope.cn/'
}
}
const request = protocol.get(url, options, (response) => {
if ([301, 302, 303, 307, 308].includes(response.statusCode || 0)) {
const location = response.headers.location
if (location && remainingRedirects > 0) {
const nextUrl = new URL(location, url).href
this.downloadSingleThread(nextUrl, targetPath, fileName, onProgress, remainingRedirects - 1).then(resolve).catch(reject)
return
}
}
if (response.statusCode !== 200) {
reject(new Error(`Fallback download failed: HTTP ${response.statusCode}`))
return
}
const totalBytes = Number(response.headers['content-length'] || 0) || undefined
let downloadedBytes = 0
let lastDownloaded = 0
let lastTime = Date.now()
let speed = 0
const speedInterval = setInterval(() => {
const now = Date.now()
const duration = (now - lastTime) / 1000
if (duration > 0) {
speed = (downloadedBytes - lastDownloaded) / duration
lastDownloaded = downloadedBytes
lastTime = now
onProgress?.(downloadedBytes, totalBytes, speed)
}
}, 1000)
const writer = createWriteStream(targetPath)
response.on('data', (chunk) => {
downloadedBytes += chunk.length
})
writer.on('finish', () => {
clearInterval(speedInterval)
writer.close()
resolve()
})
writer.on('error', (err) => {
clearInterval(speedInterval)
// 确保在错误情况下也关闭文件句柄
writer.destroy()
reject(err)
})
response.on('error', (err) => {
clearInterval(speedInterval)
// 确保在响应错误时也关闭文件句柄
writer.destroy()
reject(err)
})
response.pipe(writer)
})
request.on('error', reject)
})
}
dispose() {
if (this.recognizer) {
this.recognizer = null
}
}
}
export const voiceTranscribeService = new VoiceTranscribeService()

View File

@@ -1,180 +0,0 @@
import path from 'path';
import fs from 'fs';
import vm from 'vm';
let app: any;
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
app = require('electron').app;
} catch (e) {
app = { isPackaged: false };
}
// This service handles the loading and execution of the WeChat WASM module
// to generate the correct Isaac64 keystream for video decryption.
export class WasmService {
private static instance: WasmService;
private module: any = null;
private wasmLoaded = false;
private initPromise: Promise<void> | null = null;
private capturedKeystream: Uint8Array | null = null;
private constructor() { }
public static getInstance(): WasmService {
if (!WasmService.instance) {
WasmService.instance = new WasmService();
}
return WasmService.instance;
}
private async init(): Promise<void> {
if (this.wasmLoaded) return;
if (this.initPromise) return this.initPromise;
this.initPromise = new Promise((resolve, reject) => {
try {
// For dev, files are in electron/assets/wasm
// __dirname in dev (from dist-electron) is .../dist-electron
// So we need to go up one level and then into electron/assets/wasm
const isDev = !app.isPackaged;
const basePath = isDev
? path.join(__dirname, '../electron/assets/wasm')
: path.join(process.resourcesPath, 'assets/wasm'); // Adjust as needed for production build
const wasmPath = path.join(basePath, 'wasm_video_decode.wasm');
const jsPath = path.join(basePath, 'wasm_video_decode.js');
if (!fs.existsSync(wasmPath) || !fs.existsSync(jsPath)) {
throw new Error(`WASM files not found at ${basePath}`);
}
const wasmBinary = fs.readFileSync(wasmPath);
// Emulate Emscripten environment
// We must use 'any' for global mocking
const mockGlobal: any = {
console: console,
Buffer: Buffer,
Uint8Array: Uint8Array,
Int8Array: Int8Array,
Uint16Array: Uint16Array,
Int16Array: Int16Array,
Uint32Array: Uint32Array,
Int32Array: Int32Array,
Float32Array: Float32Array,
Float64Array: Float64Array,
BigInt64Array: BigInt64Array,
BigUint64Array: BigUint64Array,
Array: Array,
Object: Object,
Function: Function,
String: String,
Number: Number,
Boolean: Boolean,
Error: Error,
Promise: Promise,
require: require,
process: process,
setTimeout: setTimeout,
clearTimeout: clearTimeout,
setInterval: setInterval,
clearInterval: clearInterval,
};
// Define Module
mockGlobal.Module = {
onRuntimeInitialized: () => {
this.wasmLoaded = true;
resolve();
},
wasmBinary: wasmBinary,
print: (text: string) => console.log('[WASM stdout]', text),
printErr: (text: string) => console.error('[WASM stderr]', text)
};
// Define necessary globals for Emscripten loader
mockGlobal.self = mockGlobal;
mockGlobal.self.location = { href: jsPath };
mockGlobal.WorkerGlobalScope = function () { };
mockGlobal.VTS_WASM_URL = `file://${wasmPath}`; // Needs a URL, file protocol works in Node context for our mock?
// Define the callback function that WASM calls to return data
// The WASM module calls `wasm_isaac_generate(ptr, size)`
mockGlobal.wasm_isaac_generate = (ptr: number, size: number) => {
// console.log(`[WasmService] wasm_isaac_generate called: ptr=${ptr}, size=${size}`);
const buffer = new Uint8Array(mockGlobal.Module.HEAPU8.buffer, ptr, size);
// Copy the data because WASM memory might change or be invalidated
this.capturedKeystream = new Uint8Array(buffer);
};
// Execute the loader script in the context
const jsContent = fs.readFileSync(jsPath, 'utf8');
const script = new vm.Script(jsContent, { filename: jsPath });
// create context
const context = vm.createContext(mockGlobal);
script.runInContext(context);
// Store reference to module
this.module = mockGlobal.Module;
} catch (error) {
console.error('[WasmService] Failed to initialize WASM:', error);
reject(error);
}
});
return this.initPromise;
}
public async getKeystream(key: string, size: number = 131072): Promise<Buffer> {
// ISAAC-64 uses 8-byte blocks. If size is not a multiple of 8,
// the global reverse() will cause a shift in alignment.
const alignSize = Math.ceil(size / 8) * 8;
const buffer = await this.getRawKeystream(key, alignSize);
// Reverse the entire aligned buffer
const reversed = new Uint8Array(buffer);
reversed.reverse();
// Return exactly the requested size from the beginning of the reversed stream.
// Since we reversed the 'aligned' buffer, index 0 is the last byte of the last block.
return Buffer.from(reversed).subarray(0, size);
}
public async getRawKeystream(key: string, size: number = 131072): Promise<Buffer> {
await this.init();
if (!this.module || !this.module.WxIsaac64) {
if (this.module.asm && this.module.asm.WxIsaac64) {
this.module.WxIsaac64 = this.module.asm.WxIsaac64;
}
}
if (!this.module.WxIsaac64) {
throw new Error('[WasmService] WxIsaac64 not found in WASM module');
}
try {
this.capturedKeystream = null;
const isaac = new this.module.WxIsaac64(key);
isaac.generate(size);
if (isaac.delete) {
isaac.delete();
}
if (this.capturedKeystream) {
return Buffer.from(this.capturedKeystream);
} else {
throw new Error('[WasmService] Failed to capture keystream (callback not called)');
}
} catch (error) {
console.error('[WasmService] Error generating raw keystream:', error);
throw error;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,741 +0,0 @@
import { Worker } from 'worker_threads'
import { join } from 'path'
import { existsSync } from 'fs'
/**
* Worker 消息接口
*/
interface WorkerMessage {
id: number
result?: any
error?: string
}
/**
* WCDB 服务 (客户端代理)
* 负责与后台 Worker 线程通信,执行数据库操作
* 避免主进程阻塞
*/
export class WcdbService {
private worker: Worker | null = null
private messageId = 0
private pending = new Map<number, { resolve: (val: any) => void; reject: (err: any) => void }>()
private resourcesPath: string | null = null
private userDataPath: string | null = null
private logEnabled = false
private monitorListener: ((type: string, json: string) => void) | null = null
constructor() {}
/**
* 初始化 Worker 线程
*/
private initWorker() {
if (this.worker) return
const isDev = process.env.NODE_ENV === 'development'
const workerPath = isDev
? join(__dirname, '../dist-electron/wcdbWorker.js')
: join(__dirname, 'wcdbWorker.js')
let finalPath = workerPath
if (isDev && !existsSync(finalPath)) {
finalPath = join(__dirname, 'wcdbWorker.js')
}
try {
this.worker = new Worker(finalPath)
this.worker.on('message', (msg: any) => {
const { id, result, error, type, payload } = msg
if (type === 'monitor') {
if (this.monitorListener) {
this.monitorListener(payload.type, payload.json)
}
return
}
const p = this.pending.get(id)
if (p) {
this.pending.delete(id)
if (error) p.reject(new Error(error))
else p.resolve(result)
}
})
this.worker.on('error', (err) => {
// Worker 发生错误,需要 reject 所有 pending promises
console.error('WCDB Worker 错误:', err)
const errorMsg = err instanceof Error ? err.message : String(err)
for (const [id, p] of this.pending) {
p.reject(new Error(`Worker 错误: ${errorMsg}`))
}
this.pending.clear()
})
this.worker.on('exit', (code) => {
// Worker 退出,需要 reject 所有 pending promises
if (code !== 0) {
console.error('WCDB Worker 异常退出,退出码:', code)
const errorMsg = `Worker 异常退出 (退出码: ${code})。可能是数据服务加载失败,请检查是否安装了 Visual C++ Redistributable。`
for (const [id, p] of this.pending) {
p.reject(new Error(errorMsg))
}
this.pending.clear()
}
this.worker = null
})
// 如果已有路径配置,重新发送给新的 worker
if (this.resourcesPath && this.userDataPath) {
this.setPaths(this.resourcesPath, this.userDataPath)
}
this.setLogEnabled(this.logEnabled)
if (this.monitorListener) {
this.callWorker<{ success?: boolean }>('setMonitor').catch(() => { })
}
} catch (e) {
// Failed to create worker
}
}
/**
* 发送消息到 Worker 并等待响应
*/
private callWorker<T>(type: string, payload: any = {}): Promise<T> {
if (!this.worker) this.initWorker()
if (!this.worker) return Promise.reject(new Error('WCDB Worker 不可用'))
return new Promise((resolve, reject) => {
const id = ++this.messageId
this.pending.set(id, { resolve, reject })
this.worker!.postMessage({ id, type, payload })
})
}
/**
* 设置资源路径
*/
setPaths(resourcesPath: string, userDataPath: string): void {
this.resourcesPath = resourcesPath
this.userDataPath = userDataPath
this.callWorker('setPaths', { resourcesPath, userDataPath }).catch(() => { })
}
/**
* 启用/禁用日志
*/
setLogEnabled(enabled: boolean): void {
this.logEnabled = enabled
this.callWorker('setLogEnabled', { enabled }).catch(() => { })
}
/**
* 设置数据库监控回调
*/
setMonitor(callback: (type: string, json: string) => void): void {
this.monitorListener = callback;
this.callWorker<{ success?: boolean }>('setMonitor').catch(() => { });
}
/**
* 检查服务是否就绪
*/
isReady(): boolean {
return !!this.worker
}
// ==========================================
// 代理方法 (Proxy Methods)
// ==========================================
/**
* 测试数据库连接
*/
async testConnection(accountDir: string, hexKey: string): Promise<{ success: boolean; error?: string; sessionCount?: number }> {
return this.callWorker('testConnection', { accountDir, hexKey })
}
/**
* 打开数据库
* @param accountDir 账号目录的完整路径
* @param hexKey 解密密钥
*/
async open(accountDir: string, hexKey: string): Promise<boolean> {
return this.callWorker('open', { accountDir, hexKey })
}
async getLastInitError(): Promise<string | null> {
return this.callWorker('getLastInitError')
}
/**
* 关闭数据库连接
*/
async close(): Promise<void> {
return this.callWorker('close')
}
/**
* 关闭服务
*/
async shutdown(): Promise<void> {
try { await this.close() } catch {}
if (this.worker) {
try { await this.worker.terminate() } catch {}
this.worker = null
}
}
/**
* 获取数据库连接状态
* 注意:此方法现在是异步的
*/
async isConnected(): Promise<boolean> {
return this.callWorker('isConnected')
}
/**
* 获取会话列表
*/
async getSessions(): Promise<{ success: boolean; sessions?: any[]; error?: string }> {
return this.callWorker('getSessions')
}
async markAllSessionsRead(): Promise<{ success: boolean; error?: string }> {
return this.callWorker('markAllSessionsRead')
}
/**
* 获取消息列表
*/
async getMessages(sessionId: string, limit: number, offset: number): Promise<{ success: boolean; messages?: any[]; error?: string }> {
return this.callWorker('getMessages', { sessionId, limit, offset })
}
/**
* 获取新消息(增量刷新)
*/
async getNewMessages(sessionId: string, minTime: number, limit: number = 1000): Promise<{ success: boolean; messages?: any[]; error?: string }> {
return this.callWorker('getNewMessages', { sessionId, minTime, limit })
}
/**
* 获取消息总数
*/
async getMessageCount(sessionId: string): Promise<{ success: boolean; count?: number; error?: string }> {
return this.callWorker('getMessageCount', { sessionId })
}
/**
* 根据 server_id 查询单条消息
*/
async getMessageByServerId(sessionId: string, svrid: string): Promise<{ success: boolean; row?: any; error?: string }> {
return this.callWorker('getMessageByServerId', { sessionId, svrid })
}
async getMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
return this.callWorker('getMessageCounts', { sessionIds })
}
async getSessionMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
return this.callWorker('getSessionMessageCounts', { sessionIds })
}
async getSessionMessageTypeStats(
sessionId: string,
beginTimestamp: number = 0,
endTimestamp: number = 0
): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('getSessionMessageTypeStats', { sessionId, beginTimestamp, endTimestamp })
}
async getSessionMessageTypeStatsBatch(
sessionIds: string[],
options?: {
beginTimestamp?: number
endTimestamp?: number
quickMode?: boolean
includeGroupSenderCount?: boolean
}
): Promise<{ success: boolean; data?: Record<string, any>; error?: string }> {
return this.callWorker('getSessionMessageTypeStatsBatch', { sessionIds, options })
}
async getSessionMessageDateCounts(sessionId: string): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
return this.callWorker('getSessionMessageDateCounts', { sessionId })
}
async getSessionMessageDateCountsBatch(sessionIds: string[]): Promise<{ success: boolean; data?: Record<string, Record<string, number>>; error?: string }> {
return this.callWorker('getSessionMessageDateCountsBatch', { sessionIds })
}
async getMessagesByType(
sessionId: string,
localType: number,
ascending = false,
limit = 0,
offset = 0
): Promise<{ success: boolean; rows?: any[]; error?: string }> {
return this.callWorker('getMessagesByType', { sessionId, localType, ascending, limit, offset })
}
async getMediaStream(options?: {
sessionId?: string
mediaType?: 'image' | 'video' | 'all'
beginTimestamp?: number
endTimestamp?: number
limit?: number
offset?: number
}): Promise<{
success: boolean
items?: Array<{
sessionId: string
sessionDisplayName?: string
mediaType: 'image' | 'video'
localId: number
serverId?: string
createTime: number
localType: number
senderUsername?: string
isSend?: number | null
imageMd5?: string
imageDatName?: string
videoMd5?: string
content?: string
}>
hasMore?: boolean
nextOffset?: number
error?: string
}> {
return this.callWorker('getMediaStream', { options })
}
/**
* 获取联系人昵称
*/
async getDisplayNames(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
return this.callWorker('getDisplayNames', { usernames })
}
/**
* 获取头像 URL
*/
async getAvatarUrls(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
return this.callWorker('getAvatarUrls', { usernames })
}
/**
* 获取群成员数量
*/
async getGroupMemberCount(chatroomId: string): Promise<{ success: boolean; count?: number; error?: string }> {
return this.callWorker('getGroupMemberCount', { chatroomId })
}
/**
* 批量获取群成员数量
*/
async getGroupMemberCounts(chatroomIds: string[]): Promise<{ success: boolean; map?: Record<string, number>; error?: string }> {
return this.callWorker('getGroupMemberCounts', { chatroomIds })
}
/**
* 获取群成员列表
*/
async getGroupMembers(chatroomId: string): Promise<{ success: boolean; members?: any[]; error?: string }> {
return this.callWorker('getGroupMembers', { chatroomId })
}
// 获取群成员群名片昵称
async getGroupNicknames(chatroomId: string): Promise<{ success: boolean; nicknames?: Record<string, string>; error?: string }> {
return this.callWorker('getGroupNicknames', { chatroomId })
}
/**
* 获取消息表列表
*/
async getMessageTables(sessionId: string): Promise<{ success: boolean; tables?: any[]; error?: string }> {
return this.callWorker('getMessageTables', { sessionId })
}
/**
* 获取消息表统计
*/
async getMessageTableStats(sessionId: string): Promise<{ success: boolean; tables?: any[]; error?: string }> {
return this.callWorker('getMessageTableStats', { sessionId })
}
async getMessageDates(sessionId: string): Promise<{ success: boolean; dates?: string[]; error?: string }> {
return this.callWorker('getMessageDates', { sessionId })
}
/**
* 获取消息元数据
*/
async getMessageMeta(dbPath: string, tableName: string, limit: number, offset: number): Promise<{ success: boolean; rows?: any[]; error?: string }> {
return this.callWorker('getMessageMeta', { dbPath, tableName, limit, offset })
}
async getMessageTableColumns(dbPath: string, tableName: string): Promise<{ success: boolean; columns?: string[]; error?: string }> {
return this.callWorker('getMessageTableColumns', { dbPath, tableName })
}
async listTables(kind: string, dbPath: string = ''): Promise<{ success: boolean; tables?: string[]; error?: string }> {
return this.callWorker('listTables', { kind, dbPath })
}
async getTableSchema(kind: string, dbPath: string, tableName: string): Promise<{ success: boolean; schema?: string; error?: string }> {
return this.callWorker('getTableSchema', { kind, dbPath, tableName })
}
async exportTableSnapshot(kind: string, dbPath: string, tableName: string, outputPath: string): Promise<{ success: boolean; rows?: number; columns?: number; error?: string }> {
return this.callWorker('exportTableSnapshot', { kind, dbPath, tableName, outputPath })
}
async importTableSnapshot(kind: string, dbPath: string, tableName: string, inputPath: string): Promise<{ success: boolean; rows?: number; inserted?: number; ignored?: number; malformed?: number; columns?: number; error?: string }> {
return this.callWorker('importTableSnapshot', { kind, dbPath, tableName, inputPath })
}
async importTableSnapshotWithSchema(kind: string, dbPath: string, tableName: string, inputPath: string, createTableSql: string): Promise<{ success: boolean; rows?: number; inserted?: number; ignored?: number; malformed?: number; columns?: number; error?: string }> {
return this.callWorker('importTableSnapshotWithSchema', { kind, dbPath, tableName, inputPath, createTableSql })
}
async getMessageTableTimeRange(dbPath: string, tableName: string): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('getMessageTableTimeRange', { dbPath, tableName })
}
/**
* 获取联系人详情
*/
async getContact(username: string): Promise<{ success: boolean; contact?: any; error?: string }> {
return this.callWorker('getContact', { username })
}
/**
* 批量获取联系人 extra_buffer 状态isFolded/isMuted
*/
async getContactStatus(usernames: string[]): Promise<{ success: boolean; map?: Record<string, { isFolded: boolean; isMuted: boolean }>; error?: string }> {
return this.callWorker('getContactStatus', { usernames })
}
async getContactTypeCounts(): Promise<{ success: boolean; counts?: { private: number; group: number; official: number; former_friend: number }; error?: string }> {
return this.callWorker('getContactTypeCounts')
}
async getContactsCompact(usernames: string[] = []): Promise<{ success: boolean; contacts?: any[]; error?: string }> {
return this.callWorker('getContactsCompact', { usernames })
}
async getContactAliasMap(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
return this.callWorker('getContactAliasMap', { usernames })
}
async getContactFriendFlags(usernames: string[]): Promise<{ success: boolean; map?: Record<string, boolean>; error?: string }> {
return this.callWorker('getContactFriendFlags', { usernames })
}
async getChatRoomExtBuffer(chatroomId: string): Promise<{ success: boolean; extBuffer?: string; error?: string }> {
return this.callWorker('getChatRoomExtBuffer', { chatroomId })
}
/**
* 获取聚合统计数据
*/
async getAggregateStats(sessionIds: string[], beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('getAggregateStats', { sessionIds, beginTimestamp, endTimestamp })
}
/**
* 获取可用年份
*/
async getAvailableYears(sessionIds: string[]): Promise<{ success: boolean; data?: number[]; error?: string }> {
return this.callWorker('getAvailableYears', { sessionIds })
}
/**
* 获取年度报告统计
*/
async getAnnualReportStats(sessionIds: string[], beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('getAnnualReportStats', { sessionIds, beginTimestamp, endTimestamp })
}
/**
* 获取年度报告扩展数据
*/
async getAnnualReportExtras(sessionIds: string[], beginTimestamp: number, endTimestamp: number, peakDayBegin: number, peakDayEnd: number): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('getAnnualReportExtras', { sessionIds, beginTimestamp, endTimestamp, peakDayBegin, peakDayEnd })
}
/**
* 获取双人报告统计数据
*/
async getDualReportStats(sessionId: string, beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('getDualReportStats', { sessionId, beginTimestamp, endTimestamp })
}
/**
* 获取群聊统计
*/
async getGroupStats(chatroomId: string, beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('getGroupStats', { chatroomId, beginTimestamp, endTimestamp })
}
async getMyFootprintStats(options: {
beginTimestamp?: number
endTimestamp?: number
myWxid?: string
privateSessionIds?: string[]
groupSessionIds?: string[]
mentionLimit?: number
privateLimit?: number
mentionMode?: 'text_at_me' | string
}): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('getMyFootprintStats', { options })
}
/**
* 打开消息游标
*/
async openMessageCursor(sessionId: string, batchSize: number, ascending: boolean, beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; cursor?: number; error?: string }> {
return this.callWorker('openMessageCursor', { sessionId, batchSize, ascending, beginTimestamp, endTimestamp })
}
/**
* 打开轻量级消息游标
*/
async openMessageCursorLite(sessionId: string, batchSize: number, ascending: boolean, beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; cursor?: number; error?: string }> {
return this.callWorker('openMessageCursorLite', { sessionId, batchSize, ascending, beginTimestamp, endTimestamp })
}
/**
* 获取下一批消息
*/
async fetchMessageBatch(cursor: number): Promise<{ success: boolean; rows?: any[]; hasMore?: boolean; error?: string }> {
return this.callWorker('fetchMessageBatch', { cursor })
}
/**
* 关闭消息游标
*/
async closeMessageCursor(cursor: number): Promise<{ success: boolean; error?: string }> {
return this.callWorker('closeMessageCursor', { cursor })
}
/**
* 执行 SQL 查询仅主进程内部使用fallback/diagnostic/低频兼容)
*/
async execQuery(kind: string, path: string | null, sql: string, params: any[] = []): Promise<{ success: boolean; rows?: any[]; error?: string }> {
return this.callWorker('execQuery', { kind, path, sql, params })
}
/**
* 获取表情包 CDN URL
*/
async getEmoticonCdnUrl(dbPath: string, md5: string): Promise<{ success: boolean; url?: string; error?: string }> {
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 })
}
/**
* 获取表情包释义(严格数据服务接口)
*/
async getEmoticonCaptionStrict(md5: string): Promise<{ success: boolean; caption?: string; error?: string }> {
return this.callWorker('getEmoticonCaptionStrict', { md5 })
}
/**
* 列出消息数据库
*/
async listMessageDbs(): Promise<{ success: boolean; data?: string[]; error?: string }> {
return this.callWorker('listMessageDbs')
}
/**
* 列出媒体数据库
*/
async listMediaDbs(): Promise<{ success: boolean; data?: string[]; error?: string }> {
return this.callWorker('listMediaDbs')
}
/**
* 根据 ID 获取消息
*/
async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: any; error?: string }> {
return this.callWorker('getMessageById', { sessionId, localId })
}
async searchMessages(keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number): Promise<{ success: boolean; messages?: any[]; error?: string }> {
return this.callWorker('searchMessages', { keyword, sessionId, limit, offset, beginTimestamp, endTimestamp })
}
/**
* 获取语音数据
*/
async getVoiceData(sessionId: string, createTime: number, candidates: string[], localId: number = 0, svrId: string | number = 0): Promise<{ success: boolean; hex?: string; error?: string }> {
return this.callWorker('getVoiceData', { sessionId, createTime, candidates, localId, svrId })
}
async getVoiceDataBatch(
requests: Array<{ session_id: string; create_time: number; local_id?: number; svr_id?: string | number; candidates?: string[] }>
): Promise<{ success: boolean; rows?: Array<{ index: number; hex?: string }>; error?: string }> {
return this.callWorker('getVoiceDataBatch', { requests })
}
async getMediaSchemaSummary(dbPath: string): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('getMediaSchemaSummary', { dbPath })
}
async getHeadImageBuffers(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
return this.callWorker('getHeadImageBuffers', { usernames })
}
async resolveImageHardlink(md5: string, accountDir?: string): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('resolveImageHardlink', { md5, accountDir })
}
async resolveImageHardlinkBatch(
requests: Array<{ md5: string; accountDir?: string }>
): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> {
return this.callWorker('resolveImageHardlinkBatch', { requests })
}
async resolveVideoHardlinkMd5(md5: string, dbPath?: string): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('resolveVideoHardlinkMd5', { md5, dbPath })
}
async resolveVideoHardlinkMd5Batch(
requests: Array<{ md5: string; dbPath?: string }>
): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> {
return this.callWorker('resolveVideoHardlinkMd5Batch', { requests })
}
/**
* 获取朋友圈
*/
async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> {
return this.callWorker('getSnsTimeline', { limit, offset, usernames, keyword, startTime, endTime })
}
/**
* 获取朋友圈年度统计
*/
async getSnsAnnualStats(beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('getSnsAnnualStats', { beginTimestamp, endTimestamp })
}
async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> {
return this.callWorker('getSnsUsernames')
}
async getSnsExportStats(myWxid?: string): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> {
return this.callWorker('getSnsExportStats', { myWxid })
}
async checkMessageAntiRevokeTriggers(
sessionIds: string[]
): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }>; error?: string }> {
return this.callWorker('checkMessageAntiRevokeTriggers', { sessionIds })
}
async installMessageAntiRevokeTriggers(
sessionIds: string[]
): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }>; error?: string }> {
return this.callWorker('installMessageAntiRevokeTriggers', { sessionIds })
}
async uninstallMessageAntiRevokeTriggers(
sessionIds: string[]
): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; error?: string }>; error?: string }> {
return this.callWorker('uninstallMessageAntiRevokeTriggers', { sessionIds })
}
/**
* 安装朋友圈删除拦截
*/
async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> {
return this.callWorker('installSnsBlockDeleteTrigger')
}
/**
* 卸载朋友圈删除拦截
*/
async uninstallSnsBlockDeleteTrigger(): Promise<{ success: boolean; error?: string }> {
return this.callWorker('uninstallSnsBlockDeleteTrigger')
}
/**
* 查询朋友圈删除拦截是否已安装
*/
async checkSnsBlockDeleteTrigger(): Promise<{ success: boolean; installed?: boolean; error?: string }> {
return this.callWorker('checkSnsBlockDeleteTrigger')
}
/**
* 从数据库直接删除朋友圈记录
*/
async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> {
return this.callWorker('deleteSnsPost', { postId })
}
/**
* 获取数据服务内部日志
*/
async getLogs(): Promise<{ success: boolean; logs?: string[]; error?: string }> {
return this.callWorker('getLogs')
}
/**
* 验证 Windows Hello
*/
async verifyUser(message: string, hwnd?: string): Promise<{ success: boolean; error?: string }> {
return this.callWorker('verifyUser', { message, hwnd })
}
/**
* 修改消息内容
*/
async updateMessage(sessionId: string, localId: number, createTime: number, newContent: string): Promise<{ success: boolean; error?: string }> {
return this.callWorker('updateMessage', { sessionId, localId, createTime, newContent })
}
/**
* 删除消息
*/
async deleteMessage(sessionId: string, localId: number, createTime: number, dbPathHint?: string): Promise<{ success: boolean; error?: string }> {
return this.callWorker('deleteMessage', { sessionId, localId, createTime, dbPathHint })
}
/**
* 数据收集:初始化
*/
async cloudInit(intervalSeconds: number): Promise<{ success: boolean; error?: string }> {
return this.callWorker('cloudInit', { intervalSeconds })
}
/**
* 数据收集:上报数据
*/
async cloudReport(statsJson: string): Promise<{ success: boolean; error?: string }> {
return this.callWorker('cloudReport', { statsJson })
}
/**
* 数据收集:停止
*/
cloudStop(): Promise<{ success: boolean; error?: string }> {
return this.callWorker('cloudStop', {})
}
}
export const wcdbService = new WcdbService()

View File

@@ -1,32 +0,0 @@
import { wcdbService } from './wcdbService'
import { BrowserWindow } from 'electron'
export class WindowsHelloService {
private verificationPromise: Promise<{ success: boolean; error?: string }> | null = null
/**
* 验证 Windows Hello
* @param message 提示信息
*/
async verify(message: string = '请验证您的身份以解锁 WeFlow', targetWindow?: BrowserWindow): Promise<{ success: boolean; error?: string }> {
// Prevent concurrent verification requests
if (this.verificationPromise) {
return this.verificationPromise
}
// 获取窗口句柄: 优先使用传入的窗口,否则尝试获取焦点窗口,最后兜底主窗口
const window = targetWindow || BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0]
const hwndBuffer = window?.getNativeWindowHandle()
// Convert buffer to int string for transport
const hwndStr = hwndBuffer ? BigInt('0x' + hwndBuffer.toString('hex')).toString() : undefined
this.verificationPromise = wcdbService.verifyUser(message, hwndStr)
.finally(() => {
this.verificationPromise = null
})
return this.verificationPromise
}
}
export const windowsHelloService = new WindowsHelloService()

View File

@@ -1,249 +0,0 @@
import { parentPort, workerData } from 'worker_threads'
import { existsSync } from 'fs'
import { join } from 'path'
interface WorkerParams {
modelPath: string
tokensPath: string
wavData: Buffer | Uint8Array | { type: 'Buffer'; data: number[] }
sampleRate: number
languages?: string[]
}
function appendLibrarySearchPath(libDir: string): void {
if (!existsSync(libDir)) return
if (process.platform === 'darwin') {
const current = process.env.DYLD_LIBRARY_PATH || ''
const paths = current.split(':').filter(Boolean)
if (!paths.includes(libDir)) {
process.env.DYLD_LIBRARY_PATH = [libDir, ...paths].join(':')
}
return
}
if (process.platform === 'linux') {
const current = process.env.LD_LIBRARY_PATH || ''
const paths = current.split(':').filter(Boolean)
if (!paths.includes(libDir)) {
process.env.LD_LIBRARY_PATH = [libDir, ...paths].join(':')
}
}
}
function prepareSherpaRuntimeEnv(): void {
const platform = process.platform === 'win32' ? 'win' : process.platform
const platformPkg = `sherpa-onnx-${platform}-${process.arch}`
const resourcesPath = (process as any).resourcesPath as string | undefined
const candidates = [
// Dev: /project/dist-electron -> /project/node_modules/...
join(__dirname, '..', 'node_modules', platformPkg),
// Fallback for alternate layouts
join(__dirname, 'node_modules', platformPkg),
join(process.cwd(), 'node_modules', platformPkg),
// Packaged app: Resources/app.asar.unpacked/node_modules/...
resourcesPath ? join(resourcesPath, 'app.asar.unpacked', 'node_modules', platformPkg) : ''
].filter(Boolean)
for (const dir of candidates) {
appendLibrarySearchPath(dir)
}
}
// 语言标记映射
const LANGUAGE_TAGS: Record<string, string> = {
'zh': '<|zh|>',
'en': '<|en|>',
'ja': '<|ja|>',
'ko': '<|ko|>',
'yue': '<|yue|>' // 粤语
}
// 技术标签识别语言、语速、ITN等需要从最终文本中移除
const TECH_TAGS = [
'<|zh|>', '<|en|>', '<|ja|>', '<|ko|>', '<|yue|>',
'<|nospeech|>', '<|speech|>',
'<|itn|>', '<|wo_itn|>',
'<|NORMAL|>'
]
// 情感与事件标签映射,转换为直观的 Emoji
const RICH_TAG_MAP: Record<string, string> = {
'<|HAPPY|>': '😊',
'<|SAD|>': '😔',
'<|ANGRY|>': '😠',
'<|NEUTRAL|>': '', // 中性情感不特别标记
'<|FEARFUL|>': '😨',
'<|DISGUSTED|>': '🤢',
'<|SURPRISED|>': '😮',
'<|BGM|>': '🎵',
'<|Applause|>': '👏',
'<|Laughter|>': '😂',
'<|Cry|>': '😭',
'<|Cough|>': ' (咳嗽) ',
'<|Sneeze|>': ' (喷嚏) ',
}
/**
* 富文本后处理:移除技术标签,转换识别出的情感和声音事件
*/
function richTranscribePostProcess(text: string): string {
if (!text) return ''
let processed = text
// 1. 转换情感和事件标签
for (const [tag, replacement] of Object.entries(RICH_TAG_MAP)) {
// 使用正则全局替换,不区分大小写以防不同版本差异
const escapedTag = tag.replace(/[|<>]/g, '\\$&')
processed = processed.replace(new RegExp(escapedTag, 'gi'), replacement)
}
// 2. 移除所有剩余的技术标签
for (const tag of TECH_TAGS) {
const escapedTag = tag.replace(/[|<>]/g, '\\$&')
processed = processed.replace(new RegExp(escapedTag, 'gi'), '')
}
// 3. 清理多余空格并返回
return processed.replace(/\s+/g, ' ').trim()
}
// 检查识别结果是否在允许的语言列表中
function isLanguageAllowed(result: any, allowedLanguages: string[]): boolean {
if (!result || !result.lang) {
// 如果没有语言信息,默认允许(或从文本开头尝试提取)
return true
}
// 如果没有指定语言或语言列表为空,默认允许中文和粤语
if (!allowedLanguages || allowedLanguages.length === 0) {
allowedLanguages = ['zh', 'yue']
}
const langTag = result.lang
// 检查是否在允许的语言列表中
for (const lang of allowedLanguages) {
if (LANGUAGE_TAGS[lang] === langTag) {
return true
}
}
return false
}
async function run() {
const isForkProcess = !parentPort
const emit = (msg: any) => {
if (parentPort) {
parentPort.postMessage(msg)
return
}
if (typeof process.send === 'function') {
process.send(msg)
}
}
const normalizeBuffer = (data: WorkerParams['wavData']): Buffer => {
if (Buffer.isBuffer(data)) return data
if (data instanceof Uint8Array) return Buffer.from(data)
if (data && typeof data === 'object' && (data as any).type === 'Buffer' && Array.isArray((data as any).data)) {
return Buffer.from((data as any).data)
}
return Buffer.alloc(0)
}
const readParams = async (): Promise<WorkerParams | null> => {
if (parentPort) {
return workerData as WorkerParams
}
return new Promise((resolve) => {
let settled = false
const finish = (value: WorkerParams | null) => {
if (settled) return
settled = true
resolve(value)
}
process.once('message', (msg) => finish(msg as WorkerParams))
process.once('disconnect', () => finish(null))
})
}
try {
prepareSherpaRuntimeEnv()
const params = await readParams()
if (!params) return
// 动态加载以捕获可能的加载错误(如 C++ 运行库缺失等)
let sherpa: any;
try {
sherpa = require('sherpa-onnx-node');
} catch (requireError) {
emit({ type: 'error', error: 'Failed to load speech engine: ' + String(requireError) });
if (isForkProcess) process.exit(1)
return;
}
const { modelPath, tokensPath, wavData: rawWavData, sampleRate, languages } = params
const wavData = normalizeBuffer(rawWavData);
// 确保有有效的语言列表,默认只允许中文
let allowedLanguages = languages || ['zh']
if (allowedLanguages.length === 0) {
allowedLanguages = ['zh']
}
// 1. 初始化识别器 (SenseVoiceSmall)
const recognizerConfig = {
modelConfig: {
senseVoice: {
model: modelPath,
useInverseTextNormalization: 1
},
tokens: tokensPath,
numThreads: 2,
debug: 0
}
}
const recognizer = new sherpa.OfflineRecognizer(recognizerConfig)
// 2. 处理音频数据 (全量识别)
const pcmData = wavData.slice(44)
const samples = new Float32Array(pcmData.length / 2)
for (let i = 0; i < samples.length; i++) {
samples[i] = pcmData.readInt16LE(i * 2) / 32768.0
}
const stream = recognizer.createStream()
stream.acceptWaveform({ sampleRate, samples })
recognizer.decode(stream)
const result = recognizer.getResult(stream)
// 3. 检查语言是否在白名单中
if (isLanguageAllowed(result, allowedLanguages)) {
const processedText = richTranscribePostProcess(result.text)
emit({ type: 'final', text: processedText })
if (isForkProcess) process.exit(0)
} else {
emit({ type: 'final', text: '' })
if (isForkProcess) process.exit(0)
}
} catch (error) {
emit({ type: 'error', error: String(error) })
if (isForkProcess) process.exit(1)
}
}
run();

View File

@@ -1,4 +0,0 @@
declare module 'sherpa-onnx-node' {
const content: any;
export = content;
}

View File

@@ -1,22 +0,0 @@
declare module 'whisper-node' {
export type WhisperSegment = {
start: string
end: string
speech: string
}
export type WhisperOptions = {
modelName?: string
modelPath?: string
whisperOptions?: {
language?: string
gen_file_txt?: boolean
gen_file_subtitle?: boolean
gen_file_vtt?: boolean
word_timestamps?: boolean
timestamp_size?: number
}
}
export default function whisper(filePath: string, options?: WhisperOptions): Promise<WhisperSegment[]>
}

View File

@@ -1,114 +0,0 @@
/**
* LRU (Least Recently Used) Cache implementation for memory management
*/
export class LRUCache<K, V> {
private cache: Map<K, V>
private maxSize: number
constructor(maxSize: number = 100) {
this.maxSize = maxSize
this.cache = new Map()
}
/**
* Get value from cache
*/
get(key: K): V | undefined {
const value = this.cache.get(key)
if (value !== undefined) {
// Move to end (most recently used)
this.cache.delete(key)
this.cache.set(key, value)
}
return value
}
/**
* Set value in cache
*/
set(key: K, value: V): void {
if (this.cache.has(key)) {
// Update existing
this.cache.delete(key)
} else if (this.cache.size >= this.maxSize) {
// Remove least recently used (first item)
const firstKey = this.cache.keys().next().value
if (firstKey !== undefined) {
this.cache.delete(firstKey)
}
}
this.cache.set(key, value)
}
/**
* Check if key exists
*/
has(key: K): boolean {
return this.cache.has(key)
}
/**
* Delete key from cache
*/
delete(key: K): boolean {
return this.cache.delete(key)
}
/**
* Clear all cache entries
*/
clear(): void {
this.cache.clear()
}
/**
* Get current cache size
*/
get size(): number {
return this.cache.size
}
/**
* Get all keys (for debugging)
*/
keys(): IterableIterator<K> {
return this.cache.keys()
}
/**
* Get all values (for debugging)
*/
values(): IterableIterator<V> {
return this.cache.values()
}
/**
* Get all entries (for iteration support)
*/
entries(): IterableIterator<[K, V]> {
return this.cache.entries()
}
/**
* Make LRUCache iterable (for...of support)
*/
[Symbol.iterator](): IterableIterator<[K, V]> {
return this.cache.entries()
}
/**
* Force cleanup (optional method for explicit memory management)
*/
cleanup(): void {
// In JavaScript/TypeScript, this is mainly for consistency
// The garbage collector will handle actual memory cleanup
if (this.cache.size > this.maxSize * 1.5) {
// Emergency cleanup if cache somehow exceeds limit
const entries = Array.from(this.cache.entries())
this.cache.clear()
// Keep only the most recent half
const keepEntries = entries.slice(-Math.floor(this.maxSize / 2))
keepEntries.forEach(([key, value]) => this.cache.set(key, value))
}
}
}

View File

@@ -1,20 +0,0 @@
import { homedir } from 'os'
/**
* Expand "~" prefix to current user's home directory.
* Examples:
* - "~" => "/Users/alex"
* - "~/Library/..." => "/Users/alex/Library/..."
*/
export function expandHomePath(inputPath: string): string {
const raw = String(inputPath || '').trim()
if (!raw) return raw
if (raw === '~') return homedir()
if (/^~[\\/]/.test(raw)) {
return `${homedir()}${raw.slice(1)}`
}
return raw
}

View File

@@ -1,311 +0,0 @@
import { parentPort, workerData } from 'worker_threads'
import { WcdbCore } from './services/wcdbCore'
const core = new WcdbCore()
if (parentPort) {
parentPort.on('message', async (msg) => {
const { id, type, payload } = msg
try {
let result: any
switch (type) {
case 'setPaths':
core.setPaths(payload.resourcesPath, payload.userDataPath)
result = { success: true }
break
case 'setLogEnabled':
core.setLogEnabled(payload.enabled)
result = { success: true }
break
case 'setMonitor':
{
const monitorOk = core.setMonitor((type, json) => {
parentPort!.postMessage({
id: -1,
type: 'monitor',
payload: { type, json }
})
})
result = { success: monitorOk }
break
}
case 'testConnection':
result = await core.testConnection(payload.accountDir, payload.hexKey)
break
case 'open':
result = await core.open(payload.accountDir, payload.hexKey)
break
case 'getLastInitError':
result = core.getLastInitError()
break
case 'close':
core.close()
result = { success: true }
break
case 'isConnected':
result = core.isConnected()
break
case 'getSessions':
result = await core.getSessions()
break
case 'markAllSessionsRead':
result = await core.markAllSessionsRead()
break
case 'getMessages':
result = await core.getMessages(payload.sessionId, payload.limit, payload.offset)
break
case 'getNewMessages':
result = await core.getNewMessages(payload.sessionId, payload.minTime, payload.limit)
break
case 'getMessageCount':
result = await core.getMessageCount(payload.sessionId)
break
case 'getMessageByServerId':
result = await core.getMessageByServerId(payload.sessionId, payload.svrid)
break
case 'getMessageCounts':
result = await core.getMessageCounts(payload.sessionIds)
break
case 'getSessionMessageCounts':
result = await core.getSessionMessageCounts(payload.sessionIds)
break
case 'getSessionMessageTypeStats':
result = await core.getSessionMessageTypeStats(payload.sessionId, payload.beginTimestamp, payload.endTimestamp)
break
case 'getSessionMessageTypeStatsBatch':
result = await core.getSessionMessageTypeStatsBatch(payload.sessionIds, payload.options)
break
case 'getSessionMessageDateCounts':
result = await core.getSessionMessageDateCounts(payload.sessionId)
break
case 'getSessionMessageDateCountsBatch':
result = await core.getSessionMessageDateCountsBatch(payload.sessionIds)
break
case 'getMessagesByType':
result = await core.getMessagesByType(payload.sessionId, payload.localType, payload.ascending, payload.limit, payload.offset)
break
case 'getMediaStream':
result = await core.getMediaStream(payload.options)
break
case 'getDisplayNames':
result = await core.getDisplayNames(payload.usernames)
break
case 'getAvatarUrls':
result = await core.getAvatarUrls(payload.usernames)
break
case 'getGroupMemberCount':
result = await core.getGroupMemberCount(payload.chatroomId)
break
case 'getGroupMemberCounts':
result = await core.getGroupMemberCounts(payload.chatroomIds)
break
case 'getGroupMembers':
result = await core.getGroupMembers(payload.chatroomId)
break
case 'getGroupNicknames':
result = await core.getGroupNicknames(payload.chatroomId)
break
case 'getMessageTables':
result = await core.getMessageTables(payload.sessionId)
break
case 'getMessageTableStats':
result = await core.getMessageTableStats(payload.sessionId)
break
case 'getMessageDates':
result = await core.getMessageDates(payload.sessionId)
break
case 'getMessageMeta':
result = await core.getMessageMeta(payload.dbPath, payload.tableName, payload.limit, payload.offset)
break
case 'getMessageTableColumns':
result = await core.getMessageTableColumns(payload.dbPath, payload.tableName)
break
case 'listTables':
result = await core.listTables(payload.kind, payload.dbPath)
break
case 'getTableSchema':
result = await core.getTableSchema(payload.kind, payload.dbPath, payload.tableName)
break
case 'exportTableSnapshot':
result = await core.exportTableSnapshot(payload.kind, payload.dbPath, payload.tableName, payload.outputPath)
break
case 'importTableSnapshot':
result = await core.importTableSnapshot(payload.kind, payload.dbPath, payload.tableName, payload.inputPath)
break
case 'importTableSnapshotWithSchema':
result = await core.importTableSnapshotWithSchema(payload.kind, payload.dbPath, payload.tableName, payload.inputPath, payload.createTableSql)
break
case 'getMessageTableTimeRange':
result = await core.getMessageTableTimeRange(payload.dbPath, payload.tableName)
break
case 'getContact':
result = await core.getContact(payload.username)
break
case 'getContactStatus':
result = await core.getContactStatus(payload.usernames)
break
case 'getContactTypeCounts':
result = await core.getContactTypeCounts()
break
case 'getContactsCompact':
result = await core.getContactsCompact(payload.usernames)
break
case 'getContactAliasMap':
result = await core.getContactAliasMap(payload.usernames)
break
case 'getContactFriendFlags':
result = await core.getContactFriendFlags(payload.usernames)
break
case 'getChatRoomExtBuffer':
result = await core.getChatRoomExtBuffer(payload.chatroomId)
break
case 'getAggregateStats':
result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp)
break
case 'getAvailableYears':
result = await core.getAvailableYears(payload.sessionIds)
break
case 'getAnnualReportStats':
result = await core.getAnnualReportStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp)
break
case 'getAnnualReportExtras':
result = await core.getAnnualReportExtras(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp, payload.peakDayBegin, payload.peakDayEnd)
break
case 'getDualReportStats':
result = await core.getDualReportStats(payload.sessionId, payload.beginTimestamp, payload.endTimestamp)
break
case 'getGroupStats':
result = await core.getGroupStats(payload.chatroomId, payload.beginTimestamp, payload.endTimestamp)
break
case 'getMyFootprintStats':
result = await core.getMyFootprintStats(payload.options || {})
break
case 'openMessageCursor':
result = await core.openMessageCursor(payload.sessionId, payload.batchSize, payload.ascending, payload.beginTimestamp, payload.endTimestamp)
break
case 'openMessageCursorLite':
result = await core.openMessageCursorLite(payload.sessionId, payload.batchSize, payload.ascending, payload.beginTimestamp, payload.endTimestamp)
break
case 'fetchMessageBatch':
result = await core.fetchMessageBatch(payload.cursor)
break
case 'closeMessageCursor':
result = await core.closeMessageCursor(payload.cursor)
break
case 'execQuery':
result = await core.execQuery(payload.kind, payload.path, payload.sql, payload.params)
break
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
case 'listMediaDbs':
result = await core.listMediaDbs()
break
case 'getMessageById':
result = await core.getMessageById(payload.sessionId, payload.localId)
break
case 'searchMessages':
result = await core.searchMessages(payload.keyword, payload.sessionId, payload.limit, payload.offset, payload.beginTimestamp, payload.endTimestamp)
break
case 'getVoiceData':
result = await core.getVoiceData(payload.sessionId, payload.createTime, payload.candidates, payload.localId, payload.svrId)
if (!result.success) {
console.error('[wcdbWorker] getVoiceData failed:', result.error)
}
break
case 'getVoiceDataBatch':
result = await core.getVoiceDataBatch(payload.requests)
break
case 'getMediaSchemaSummary':
result = await core.getMediaSchemaSummary(payload.dbPath)
break
case 'getHeadImageBuffers':
result = await core.getHeadImageBuffers(payload.usernames)
break
case 'resolveImageHardlink':
result = await core.resolveImageHardlink(payload.md5, payload.accountDir)
break
case 'resolveImageHardlinkBatch':
result = await core.resolveImageHardlinkBatch(payload.requests)
break
case 'resolveVideoHardlinkMd5':
result = await core.resolveVideoHardlinkMd5(payload.md5, payload.dbPath)
break
case 'resolveVideoHardlinkMd5Batch':
result = await core.resolveVideoHardlinkMd5Batch(payload.requests)
break
case 'getSnsTimeline':
result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime)
break
case 'getSnsAnnualStats':
result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp)
break
case 'getSnsUsernames':
result = await core.getSnsUsernames()
break
case 'getSnsExportStats':
result = await core.getSnsExportStats(payload.myWxid)
break
case 'checkMessageAntiRevokeTriggers':
result = await core.checkMessageAntiRevokeTriggers(payload.sessionIds)
break
case 'installMessageAntiRevokeTriggers':
result = await core.installMessageAntiRevokeTriggers(payload.sessionIds)
break
case 'uninstallMessageAntiRevokeTriggers':
result = await core.uninstallMessageAntiRevokeTriggers(payload.sessionIds)
break
case 'installSnsBlockDeleteTrigger':
result = await core.installSnsBlockDeleteTrigger()
break
case 'uninstallSnsBlockDeleteTrigger':
result = await core.uninstallSnsBlockDeleteTrigger()
break
case 'checkSnsBlockDeleteTrigger':
result = await core.checkSnsBlockDeleteTrigger()
break
case 'deleteSnsPost':
result = await core.deleteSnsPost(payload.postId)
break
case 'getLogs':
result = await core.getLogs()
break
case 'verifyUser':
result = await core.verifyUser(payload.message, payload.hwnd)
break
case 'updateMessage':
result = await core.updateMessage(payload.sessionId, payload.localId, payload.createTime, payload.newContent)
break
case 'deleteMessage':
result = await core.deleteMessage(payload.sessionId, payload.localId, payload.createTime, payload.dbPathHint)
break
case 'cloudInit':
result = await core.cloudInit(payload.intervalSeconds)
break
case 'cloudReport':
result = await core.cloudReport(payload.statsJson)
break
case 'cloudStop':
result = core.cloudStop()
break
default:
result = { success: false, error: `Unknown method: ${type}` }
}
parentPort!.postMessage({ id, result })
} catch (e) {
parentPort!.postMessage({ id, error: String(e) })
}
})
}

View File

@@ -1,354 +0,0 @@
import { BrowserWindow, ipcMain, screen } from "electron";
import { join } from "path";
import { ConfigService } from "../services/config";
// Linux D-Bus通知服务
const isLinux = process.platform === "linux";
let linuxNotificationService:
| typeof import("../services/linuxNotificationService")
| null = null;
// 用于处理通知点击的回调函数在Linux上用于导航到会话
let onNotificationNavigate: ((payload: unknown) => void) | null = null;
export function setNotificationNavigateHandler(
callback: (payload: unknown) => void,
) {
onNotificationNavigate = callback;
}
let notificationWindow: BrowserWindow | null = null;
let closeTimer: NodeJS.Timeout | null = null;
export function destroyNotificationWindow() {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = null;
}
lastNotificationData = null;
// Linux:关闭通知服务并清理缓存fire-and-forget不阻塞退出
if (isLinux && linuxNotificationService) {
linuxNotificationService.shutdownLinuxNotificationService().catch((error) => {
console.warn("[NotificationWindow] Failed to shutdown Linux notification service:", error);
});
linuxNotificationService = null;
}
if (!notificationWindow || notificationWindow.isDestroyed()) {
notificationWindow = null;
return;
}
const win = notificationWindow;
notificationWindow = null;
try {
win.destroy();
} catch (error) {
console.warn("[NotificationWindow] Failed to destroy window:", error);
}
}
export function createNotificationWindow() {
if (notificationWindow && !notificationWindow.isDestroyed()) {
return notificationWindow;
}
const isDev = !!process.env.VITE_DEV_SERVER_URL;
const iconPath = isDev
? join(__dirname, "../../public/icon.ico")
: join(process.resourcesPath, "icon.ico");
console.log("[NotificationWindow] Creating window...");
const width = 344;
const height = 114;
// Update default creation size
notificationWindow = new BrowserWindow({
width: width,
height: height,
type: "toolbar", // 有助于在某些操作系统上保持置顶
frame: false,
transparent: true,
resizable: false,
show: false,
alwaysOnTop: true,
skipTaskbar: true,
focusable: false, // 不抢占焦点
icon: iconPath,
webPreferences: {
preload: join(__dirname, "preload.js"), // FIX: Use correct relative path (same dir in dist)
contextIsolation: true,
nodeIntegration: false,
// devTools: true // Enable DevTools
},
});
// notificationWindow.webContents.openDevTools({ mode: 'detach' }) // DEBUG: Force Open DevTools
notificationWindow.setIgnoreMouseEvents(true, { forward: true }); // 初始点击穿透
// 处理鼠标事件 (如果需要从渲染进程转发,但目前特定区域处理?)
// 实际上,我们希望窗口可点击。
// 我们将在显示时将忽略鼠标事件设为 false。
const loadUrl = isDev
? `${process.env.VITE_DEV_SERVER_URL}#/notification-window`
: `file://${join(__dirname, "../dist/index.html")}#/notification-window`;
console.log("[NotificationWindow] Loading URL:", loadUrl);
notificationWindow.loadURL(loadUrl);
notificationWindow.on("closed", () => {
notificationWindow = null;
});
return notificationWindow;
}
export async function showNotification(data: any) {
// 先检查配置
const config = ConfigService.getInstance();
const sessionId = typeof data.sessionId === "string" ? data.sessionId : "";
const channel = typeof data.channel === "string" ? data.channel : "";
const isAiInsightNotification = channel === "ai-insight";
if (isAiInsightNotification) {
const enabled = await config.get("aiInsightNotificationEnabled");
if (enabled === false) return; // 默认为 true
} else {
const enabled = await config.get("notificationEnabled");
if (enabled === false) return; // 默认为 true
// 检查会话过滤
const filterMode = config.get("notificationFilterMode") || "all";
const filterList = config.get("notificationFilterList") || [];
// 系统通知(如 "WeFlow 准备就绪")不是聊天消息,不应受会话白/黑名单影响
const isSystemNotification = sessionId.startsWith("weflow-");
if (!isSystemNotification && filterMode !== "all") {
const isInList = sessionId !== "" && filterList.includes(sessionId);
if (filterMode === "whitelist" && !isInList) {
// 白名单模式:不在列表中则不显示(空列表视为全部拦截)
return;
}
if (filterMode === "blacklist" && isInList) {
// 黑名单模式:在列表中则不显示
return;
}
}
}
// Linux 使用 D-Bus 通知
if (isLinux) {
await showLinuxNotification(data);
return;
}
let win = notificationWindow;
if (!win || win.isDestroyed()) {
win = createNotificationWindow();
}
if (!win) return;
// 确保加载完成
if (win.webContents.isLoading()) {
win.once("ready-to-show", () => {
showAndSend(win!, data);
});
} else {
showAndSend(win, data);
}
}
// 显示Linux通知
async function showLinuxNotification(data: any) {
if (!linuxNotificationService) {
try {
linuxNotificationService =
await import("../services/linuxNotificationService");
} catch (error) {
console.error(
"[NotificationWindow] Failed to load Linux notification service:",
error,
);
return;
}
}
const { showLinuxNotification: showNotification } = linuxNotificationService;
const notificationData = {
title: data.title,
content: data.content,
avatarUrl: data.avatarUrl,
sessionId: data.sessionId,
channel: data.channel,
insightRecordId: data.insightRecordId,
targetRoute: data.targetRoute,
expireTimeout: 5000,
};
showNotification(notificationData);
}
let lastNotificationData: any = null;
async function showAndSend(win: BrowserWindow, data: any) {
lastNotificationData = data;
const config = ConfigService.getInstance();
const position = (await config.get("notificationPosition")) || "top-right";
// 更新位置
const { width: screenWidth, height: screenHeight } =
screen.getPrimaryDisplay().workAreaSize;
const winWidth = position === "top-center" ? 280 : 344;
const winHeight = 114;
const padding = 20;
let x = 0;
let y = 0;
switch (position) {
case "top-center":
x = (screenWidth - winWidth) / 2;
y = padding;
break;
case "top-right":
x = screenWidth - winWidth - padding;
y = padding;
break;
case "bottom-right":
x = screenWidth - winWidth - padding;
y = screenHeight - winHeight - padding;
break;
case "top-left":
x = padding;
y = padding;
break;
case "bottom-left":
x = padding;
y = screenHeight - winHeight - padding;
break;
}
win.setPosition(Math.floor(x), Math.floor(y));
win.setSize(winWidth, winHeight); // 确保尺寸
// 设为可交互
win.setIgnoreMouseEvents(false);
win.showInactive(); // 显示但不聚焦
win.setAlwaysOnTop(true, "screen-saver"); // 最高层级
win.webContents.send("notification:show", { ...data, position });
// 自动关闭计时器通常由渲染进程管理
// 渲染进程发送 'notification:close' 来隐藏窗口
}
// 注册通知处理
export async function registerNotificationHandlers() {
// Linux: 初始化D-Bus服务
if (isLinux) {
try {
const linuxNotificationModule =
await import("../services/linuxNotificationService");
linuxNotificationService = linuxNotificationModule;
// 初始化服务
await linuxNotificationModule.initLinuxNotificationService();
// 在Linux上注册通知点击回调
linuxNotificationModule.onNotificationAction((payload: unknown) => {
console.log(
"[NotificationWindow] Linux notification clicked, sessionId:",
payload,
);
// 如果设置了导航处理程序则使用该处理程序否则回退到ipcMain方法。
if (onNotificationNavigate) {
onNotificationNavigate(payload);
} else {
// 如果尚未设置处理程序则通过ipcMain发出事件
// 正常流程中不应该发生这种情况,因为我们在初始化之前设置了处理程序。
console.warn(
"[NotificationWindow] onNotificationNavigate not set yet",
);
}
});
console.log(
"[NotificationWindow] Linux notification service initialized",
);
} catch (error) {
console.error(
"[NotificationWindow] Failed to initialize Linux notification service:",
error,
);
}
}
ipcMain.handle("notification:show", (_, data) => {
showNotification(data);
});
ipcMain.handle("notification:close", () => {
if (isLinux && linuxNotificationService) {
// 注册通知点击回调函数。Linux通知通过D-Bus自动关闭但我们可以根据需要进行跟踪
return;
}
if (notificationWindow && !notificationWindow.isDestroyed()) {
notificationWindow.hide();
notificationWindow.setIgnoreMouseEvents(true, { forward: true });
}
});
// Handle renderer ready event (fix race condition)
ipcMain.on("notification:ready", (event) => {
if (isLinux) {
// Linux不需要通知窗口拦截通知窗口渲染
return;
}
console.log("[NotificationWindow] Renderer ready, checking cached data");
if (
lastNotificationData &&
notificationWindow &&
!notificationWindow.isDestroyed()
) {
console.log("[NotificationWindow] Re-sending cached data");
notificationWindow.webContents.send(
"notification:show",
lastNotificationData,
);
}
});
// Handle resize request from renderer
ipcMain.on("notification:resize", (event, { width, height }) => {
if (isLinux) {
// Linux 通知通过D-Bus自动调整大小
return;
}
if (notificationWindow && !notificationWindow.isDestroyed()) {
// Enforce max-height if needed, or trust renderer
// Ensure it doesn't go off screen bottom?
// Logic in showAndSend handles position, but we need to keep anchor point (top-right usually).
// If we resize, we should re-calculate position to keep it anchored?
// Actually, setSize changes size. If it's top-right, x/y stays same -> window grows down. That's fine for top-right.
// If bottom-right, growing down pushes it off screen.
// Simple version: just setSize. For V1 we assume Top-Right.
// But wait, the config supports bottom-right.
// We can re-call setPosition or just let it be.
// If bottom-right, y needs to prevent overflow.
// Ideally we get current config position
const bounds = notificationWindow.getBounds();
// Check if we need to adjust Y?
// For now, let's just set the size as requested.
notificationWindow.setSize(Math.round(width), Math.round(height));
}
});
// 'notification-clicked' 在 main.ts 中处理 (导航)
}

View File

@@ -1,12 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WeFlow</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,65 +0,0 @@
; 高 DPI 支持
ManifestDPIAware true
!include "WordFunc.nsh"
!include "nsDialogs.nsh"
!macro customInit
; 设置 DPI 感知
System::Call 'USER32::SetProcessDPIAware()'
!macroend
; 在安装开始前修正安装目录
!macro preInit
; 如果安装目录不以 WeFlow 结尾,自动追加
${WordFind} "$INSTDIR" "\" "-1" $R0
${If} $R0 != "WeFlow"
StrCpy $INSTDIR "$INSTDIR\WeFlow"
${EndIf}
!macroend
; 安装完成后检测并安装 VC++ Redistributable
!macro customInstall
; 检查 VC++ 2015-2022 x64 是否已安装
ReadRegStr $0 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64" "Installed"
${If} $0 != "1"
; 未安装,显示提示并下载
MessageBox MB_YESNO|MB_ICONQUESTION "检测到系统缺少 Visual C++ 运行库,这可能导致程序无法正常运行。$\n$\n是否立即下载并安装约 24MB" IDYES downloadVC IDNO skipVC
downloadVC:
DetailPrint "正在下载 Visual C++ Redistributable..."
SetOutPath "$TEMP"
; 从微软官方下载 VC++ Redistributable x64
inetc::get /TIMEOUT=30000 /CAPTION "下载 Visual C++ 运行库" /BANNER "正在下载,请稍候..." \
"https://aka.ms/vs/17/release/vc_redist.x64.exe" "$TEMP\vc_redist.x64.exe" /END
Pop $0
${If} $0 == "OK"
DetailPrint "下载完成,正在安装..."
; 使用 ShellExecute 以管理员权限运行
ExecShell "runas" '"$TEMP\vc_redist.x64.exe"' "/install /quiet /norestart" SW_HIDE
; 等待安装完成
Sleep 5000
; 检查是否安装成功
ReadRegStr $1 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64" "Installed"
${If} $1 == "1"
DetailPrint "Visual C++ Redistributable 安装成功"
MessageBox MB_OK|MB_ICONINFORMATION "Visual C++ 运行库安装成功!"
${Else}
MessageBox MB_OK|MB_ICONEXCLAMATION "Visual C++ 运行库安装失败,你可能需要手动安装。"
${EndIf}
Delete "$TEMP\vc_redist.x64.exe"
${Else}
MessageBox MB_OK|MB_ICONEXCLAMATION "下载失败:$0$\n$\n你可以稍后手动下载安装 Visual C++ Redistributable。"
${EndIf}
Goto doneVC
skipVC:
DetailPrint "用户跳过 Visual C++ Redistributable 安装"
doneVC:
${Else}
DetailPrint "Visual C++ Redistributable 已安装"
${EndIf}
!macroend

10104
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,200 +0,0 @@
{
"name": "weflow",
"version": "4.3.0",
"description": "WeFlow",
"main": "dist-electron/main.js",
"author": {
"name": "cc",
"email": "yccccccy@proton.me"
},
"repository": {
"type": "git",
"url": "https://github.com/hicccc77/WeFlow"
},
"//": "二改不应改变此处的作者与应用信息",
"scripts": {
"postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs",
"rebuild": "electron-rebuild",
"dev": "node scripts/prepare-electron-runtime.cjs && vite",
"typecheck": "tsc --noEmit",
"build": "tsc && vite build && electron-builder",
"preview": "vite preview",
"electron:dev": "node scripts/prepare-electron-runtime.cjs && vite --mode electron",
"electron:build": "npm run build"
},
"dependencies": {
"@vscode/sudo-prompt": "^9.3.2",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.2",
"electron-store": "^11.0.2",
"electron-updater": "^6.3.9",
"exceljs": "^4.4.0",
"ffmpeg-static": "^5.3.0",
"fzstd": "^0.1.1",
"html2canvas": "^1.4.1",
"jieba-wasm": "^2.2.0",
"jszip": "^3.10.1",
"koffi": "^2.9.0",
"lucide-react": "^1.8.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.14.0",
"react-virtuoso": "^4.18.5",
"remark-gfm": "^4.0.1",
"sherpa-onnx-node": "^1.10.38",
"silk-wasm": "^3.7.1",
"wechat-emojis": "^1.0.2",
"zustand": "^5.0.2"
},
"devDependencies": {
"@electron/rebuild": "^4.0.2",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^6.0.1",
"electron": "^41.1.1",
"electron-builder": "^26.8.1",
"esbuild": "^0.28.0",
"sass": "^1.98.0",
"sharp": "^0.34.5",
"typescript": "^6.0.3",
"vite": "^8.0.10",
"vite-plugin-electron": "^0.28.8",
"vite-plugin-electron-renderer": "^0.14.6"
},
"pnpm": {
"overrides": {
"tar": ">=6.2.1",
"minimatch": ">=3.1.2",
"rollup": ">=4.0.0",
"immutable": ">=4.0.0",
"lodash": ">=4.17.21",
"brace-expansion": ">=1.1.11",
"picomatch": ">=2.3.1",
"ajv": ">=8.18.0"
}
},
"build": {
"appId": "com.WeFlow.app",
"publish": {
"provider": "github",
"owner": "hicccc77",
"repo": "WeFlow",
"releaseType": "release"
},
"productName": "WeFlow",
"artifactName": "${productName}-${version}-Setup.${ext}",
"directories": {
"output": "release"
},
"mac": {
"target": [
"dmg",
"zip"
],
"category": "public.app-category.utilities",
"hardenedRuntime": false,
"gatekeeperAssess": false,
"entitlements": "electron/entitlements.mac.plist",
"entitlementsInherit": "electron/entitlements.mac.plist",
"icon": "resources/icons/macos/icon.icns"
},
"win": {
"target": [
"nsis"
],
"icon": "public/icon.ico",
"extraFiles": [
{
"from": "resources/runtime/win32/msvcp140.dll",
"to": "."
},
{
"from": "resources/runtime/win32/msvcp140_1.dll",
"to": "."
},
{
"from": "resources/runtime/win32/vcruntime140.dll",
"to": "."
},
{
"from": "resources/runtime/win32/vcruntime140_1.dll",
"to": "."
}
]
},
"linux": {
"icon": "public/icon.png",
"target": [
"appimage",
"tar.gz"
],
"category": "Utility",
"executableName": "weflow",
"synopsis": "WeFlow for Linux",
"extraFiles": [
{
"from": "resources/installer/linux/install.sh",
"to": "install.sh"
}
]
},
"nsis": {
"oneClick": false,
"differentialPackage": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"unicode": true,
"installerLanguages": [
"zh_CN",
"en_US"
],
"language": "2052",
"displayLanguageSelector": false,
"include": "installer.nsh",
"installerIcon": "public/icon.ico",
"uninstallerIcon": "public/icon.ico",
"installerHeaderIcon": "public/icon.ico",
"perMachine": false,
"allowElevation": true,
"installerSidebar": null,
"uninstallerSidebar": null
},
"extraResources": [
{
"from": "resources/",
"to": "resources/"
},
{
"from": "public/icon.ico",
"to": "icon.ico"
},
{
"from": "public/icon.png",
"to": "icon.png"
},
{
"from": "electron/assets/wasm/",
"to": "assets/wasm/"
}
],
"files": [
"dist/**/*",
"dist-electron/**/*"
],
"asarUnpack": [
"node_modules/silk-wasm/**/*",
"node_modules/sherpa-onnx-node/**/*",
"node_modules/sherpa-onnx-*/*",
"node_modules/sherpa-onnx-*/**/*",
"node_modules/ffmpeg-static/**/*",
"resources/wedecrypt/**/*.node"
],
"icon": "resources/icons/macos/icon.icns"
},
"overrides": {
"picomatch": "^4.0.4",
"tar": "^7.5.13",
"immutable": "^5.1.5"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Some files were not shown because too many files have changed in this diff Show More