Compare commits
5 Commits
docs/decor
...
v0.0.15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a745519451 | ||
|
|
85b5465d2a | ||
|
|
b866d6eddd | ||
|
|
871ad50b3b | ||
|
|
b64a3a1caa |
121
DISCLAIMER.md
Normal file
121
DISCLAIMER.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Chatlog 免责声明
|
||||
|
||||
## 1. 定义
|
||||
|
||||
在本免责声明中,除非上下文另有说明,下列术语应具有以下含义:
|
||||
|
||||
- **"本项目"或"Chatlog"**:指本开源软件项目,包括其源代码、可执行程序、文档及相关资源。
|
||||
- **"开发者"**:指本项目的创建者、维护者及代码贡献者。
|
||||
- **"用户"**:指下载、安装、使用或以任何方式接触本项目的个人或实体。
|
||||
- **"聊天数据"**:指通过各类即时通讯软件生成的对话内容及相关元数据。
|
||||
- **"合法授权"**:指根据适用法律法规,由数据所有者或数据主体明确授予的处理其聊天数据的权限。
|
||||
- **"第三方服务"**:指由非本项目开发者提供的外部服务,如大型语言模型(LLM) API 服务。
|
||||
|
||||
## 2. 使用目的与法律遵守
|
||||
|
||||
本项目仅供学习、研究和个人合法使用。用户须严格遵守所在国家/地区的法律法规使用本工具。任何违反法律法规、侵犯他人合法权益的行为,均与本项目及其开发者无关,相关法律责任由用户自行承担。
|
||||
|
||||
⚠️ **用户应自行了解并遵守当地有关数据访问、隐私保护、计算机安全和网络安全的法律法规。不同司法管辖区对数据处理有不同的法律要求,用户有责任确保其使用行为符合所有适用法规。**
|
||||
|
||||
## 3. 授权范围与隐私保护
|
||||
|
||||
- 本工具仅限于处理用户自己合法拥有的聊天数据,或已获得数据所有者明确授权的数据。
|
||||
- 严禁将本工具用于未经授权获取、查看或分析他人聊天记录,或侵犯他人隐私权。
|
||||
- 用户应采取适当措施保护通过本工具获取和处理的聊天数据安全,包括但不限于加密存储、限制访问权限、定期删除不必要数据等。
|
||||
- 用户应确保其处理的聊天数据符合相关数据保护法规,包括但不限于获得必要的同意、保障数据主体权利、遵守数据最小化原则等。
|
||||
|
||||
## 4. 使用限制
|
||||
|
||||
- 本项目仅允许在合法授权情况下对聊天数据库进行备份与查看。
|
||||
- 未经明确授权,严禁将本项目用于访问、查看、分析或处理任何第三方聊天数据。
|
||||
- 使用第三方 LLM 服务时,用户应遵守相关服务提供商的服务条款和使用政策。
|
||||
- 用户不得规避本项目中的任何技术限制,或尝试反向工程、反编译或反汇编本项目,除非适用法律明确允许此类活动。
|
||||
|
||||
## 5. 技术风险声明
|
||||
|
||||
⚠️ **使用本项目存在以下技术风险,用户应充分了解并自行承担:**
|
||||
|
||||
- 本工具需要访问聊天软件的数据库文件,可能因聊天软件版本更新导致功能失效或数据不兼容。
|
||||
- 在 macOS 系统上使用时,需要临时关闭 SIP 安全机制,这可能降低系统安全性,用户应了解相关风险并自行决定是否使用。
|
||||
- 本项目可能存在未知的技术缺陷或安全漏洞,可能导致数据损坏、丢失或泄露。
|
||||
- 使用本项目处理大量数据可能导致系统性能下降或资源占用过高。
|
||||
- 第三方依赖库或 API 的变更可能影响本项目的功能或安全性。
|
||||
|
||||
## 6. 禁止非法用途
|
||||
|
||||
严禁将本项目用于以下用途:
|
||||
|
||||
- 从事任何形式的非法活动,包括但不限于未授权系统测试、网络渗透或其他违反法律法规的行为。
|
||||
- 监控、窃取或未经授权获取他人聊天记录或个人信息。
|
||||
- 将获取的数据用于骚扰、诈骗、敲诈、威胁或其他侵害他人合法权益的行为。
|
||||
- 规避任何安全措施或访问控制机制。
|
||||
- 传播虚假信息、仇恨言论或违反公序良俗的内容。
|
||||
- 侵犯任何第三方的知识产权、隐私权或其他合法权益。
|
||||
|
||||
**违反上述规定的,用户应自行承担全部法律责任,并赔偿因此给开发者或第三方造成的全部损失。**
|
||||
|
||||
## 7. 第三方服务集成
|
||||
|
||||
- 用户将聊天数据与第三方 LLM 服务(如 OpenAI、Claude 等)结合使用时,应仔细阅读并遵守这些服务的使用条款、隐私政策和数据处理协议。
|
||||
- 用户应了解,向第三方服务传输数据可能导致数据离开用户控制范围,并受第三方服务条款约束。
|
||||
- 本项目开发者不对第三方服务的可用性、安全性、准确性或数据处理行为负责,用户应自行评估相关风险。
|
||||
- 用户应确保其向第三方服务传输数据的行为符合适用的数据保护法规和第三方服务条款。
|
||||
|
||||
## 8. 责任限制
|
||||
|
||||
**在法律允许的最大范围内:**
|
||||
|
||||
- 本项目按"原样"和"可用"状态提供,不对功能的适用性、可靠性、准确性、完整性或及时性做任何明示或暗示的保证。
|
||||
- 开发者明确否认对适销性、特定用途适用性、不侵权以及任何其他明示或暗示的保证。
|
||||
- 本项目开发者和贡献者不对用户使用本工具的行为及后果承担任何法律责任。
|
||||
- 对于因使用本工具而可能导致的任何直接、间接、附带、特殊、惩罚性或后果性损失,包括但不限于数据丢失、业务中断、隐私泄露、声誉损害、利润损失、法律纠纷等,本项目开发者概不负责,即使开发者已被告知此类损失的可能性。
|
||||
- 在任何情况下,开发者对用户的全部责任累计不超过用户为获取本软件实际支付的金额(如为免费获取则为零)。
|
||||
|
||||
## 9. 知识产权声明
|
||||
|
||||
- 本项目基于 Apache-2.0 许可证开源,用户在使用、修改和分发时应严格遵守该许可证的所有条款。
|
||||
- 本项目的名称"Chatlog"、相关标识及商标权(如有)归开发者所有,未经明确授权,用户不得以任何方式使用这些标识进行商业活动。
|
||||
- 根据 Apache-2.0 许可证,用户可自由使用、修改和分发本项目代码,但须遵守许可证规定的归属声明等要求。
|
||||
- 用户对其修改版本自行承担全部责任,且不得以原项目名义发布,必须明确标明其为修改版本并与原项目区分。
|
||||
- 用户不得移除或更改本项目中的版权声明、商标或其他所有权声明。
|
||||
|
||||
## 10. 数据处理合规性
|
||||
|
||||
- 用户在使用本项目处理个人数据时,应遵守适用的数据保护法规,包括但不限于《中华人民共和国个人信息保护法》、《通用数据保护条例》(GDPR)等。
|
||||
- 用户应确保其具有处理相关数据的合法依据,如获得数据主体的明确同意。
|
||||
- 用户应实施适当的技术和组织措施,确保数据安全,防止未授权访问、意外丢失或泄露。
|
||||
- 在跨境传输数据时,用户应确保符合相关法律对数据出境的要求。
|
||||
- 用户应尊重数据主体权利,包括访问权、更正权、删除权等。
|
||||
|
||||
## 11. 免责声明接受
|
||||
|
||||
下载、安装、使用本项目,表示用户已阅读、理解并同意遵守本免责声明的所有条款。如不同意,请立即停止使用本工具并删除相关代码和程序。
|
||||
|
||||
**用户确认:**
|
||||
- 已完整阅读并理解本免责声明的全部内容
|
||||
- 自愿接受本免责声明的全部条款
|
||||
- 具有完全民事行为能力,能够理解并承担使用本项目的风险和责任
|
||||
- 将遵守本免责声明中规定的所有义务和限制
|
||||
|
||||
## 12. 免责声明修改与通知
|
||||
|
||||
- 本免责声明可能根据项目发展和法律法规变化进行修改和调整,修改后的声明将在项目官方仓库页面公布。
|
||||
- 开发者没有义务个别通知用户免责声明的变更,用户应定期查阅最新版本。
|
||||
- 重大变更将通过项目仓库的 Release Notes 或 README 文件更新进行通知。
|
||||
- 在免责声明更新后继续使用本项目,即视为接受修改后的条款。
|
||||
|
||||
## 13. 法律适用与管辖
|
||||
|
||||
- 本免责声明受中华人民共和国法律管辖,并按其解释。
|
||||
- 任何与本免责声明有关的争议,应首先通过友好协商解决;协商不成的,提交至本项目开发者所在地有管辖权的人民法院诉讼解决。
|
||||
- 对于中国境外用户,如本免责声明与用户所在地强制性法律规定冲突,应以不违反该强制性规定的方式解释和适用本声明,但本声明的其余部分仍然有效。
|
||||
|
||||
## 14. 可分割性
|
||||
|
||||
如本免责声明中的任何条款被有管辖权的法院或其他权威机构认定为无效、不合法或不可执行,不影响其余条款的有效性和可执行性。无效条款应被视为从本声明中分割,并在法律允许的最大范围内由最接近原条款意图的有效条款替代。
|
||||
|
||||
## 15. 完整协议
|
||||
|
||||
本免责声明构成用户与开发者之间关于本项目使用的完整协议,取代先前或同时期关于本项目的所有口头或书面协议、提议和陈述。本声明的任何豁免、修改或补充均应以书面形式作出并经开发者签署方为有效。
|
||||
|
||||
|
||||
233
README.md
233
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
# Chatlog
|
||||
|
||||

|
||||

|
||||
|
||||
_聊天记录工具,帮助大家轻松使用自己的聊天数据_
|
||||
|
||||
@@ -13,7 +13,7 @@ _聊天记录工具,帮助大家轻松使用自己的聊天数据_
|
||||
|
||||
</div>
|
||||
|
||||

|
||||

|
||||
|
||||
## Feature
|
||||
|
||||
@@ -23,16 +23,35 @@ _聊天记录工具,帮助大家轻松使用自己的聊天数据_
|
||||
- 提供 Terminal UI 界面 & 命令行工具
|
||||
- 提供 HTTP API 服务,支持查询聊天记录、联系人、群聊、最近会话等信息
|
||||
- 支持 MCP SSE 协议,可与支持 MCP 的 AI 助手无缝集成
|
||||
- 支持多媒体消息,支持解密图片、语音
|
||||
- 支持自动解密数据,简化使用流程
|
||||
- 支持多账号管理,可在不同账号间切换
|
||||
|
||||
|
||||
## TODO
|
||||
|
||||
- 支持多媒体数据
|
||||
- 聊天数据全文索引
|
||||
- 聊天数据统计 & Dashboard
|
||||
|
||||
## Quick Start
|
||||
|
||||
## Install
|
||||
### 基本步骤
|
||||
|
||||
1. **安装 Chatlog**:[下载预编译版本](#下载预编译版本) 或 [使用 Go 安装](#从源码安装)
|
||||
2. **运行程序**:执行 `chatlog` 启动 Terminal UI 界面
|
||||
3. **解密数据**:选择 `解密数据` 菜单项
|
||||
4. **开启 HTTP 服务**:选择 `开启 HTTP 服务` 菜单项
|
||||
5. **访问数据**:通过 [HTTP API](#http-api) 或 [MCP 集成](#mcp-集成) 访问聊天记录
|
||||
|
||||
> 💡 **提示**:如果电脑端微信聊天记录不全,可以[从手机端迁移数据](#从手机迁移聊天记录)
|
||||
|
||||
### 常见问题快速解决
|
||||
|
||||
- **macOS 用户**:获取密钥前需[临时关闭 SIP](#macos-版本说明)
|
||||
- **Windows 用户**:遇到界面显示问题请[使用 Windows Terminal](#windows-版本说明)
|
||||
- **集成 AI 助手**:查看 [MCP 集成指南](#mcp-集成)
|
||||
|
||||
## 安装指南
|
||||
|
||||
### 从源码安装
|
||||
|
||||
@@ -44,150 +63,164 @@ go install github.com/sjzar/chatlog@latest
|
||||
|
||||
访问 [Releases](https://github.com/sjzar/chatlog/releases) 页面下载适合您系统的预编译版本。
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 操作流程
|
||||
|
||||
1. 下载并安装微信客户端
|
||||
|
||||
2. 手机微信上操作 `我 - 设置 - 通用 - 聊天记录迁移与备份 - 迁移 - 迁移到电脑`,这一步的目的是将手机中的聊天记录传输到电脑上。可以放心操作,不会影响到手机上的聊天记录。
|
||||
|
||||
3. 下载 `chatlog` 预编译版本或从源码安装,推荐使用 go 进行安装。
|
||||
|
||||
4. 运行 `chatlog`,按照提示进行操作,解密数据并开启 HTTP 服务后,即可通过浏览器或 AI 助手访问聊天记录。
|
||||
|
||||
### macOS 版本提示
|
||||
|
||||
1. macOS 用户在获取密钥前,需要确认已经关闭 SIP 并安装 Xcode Command Line Tools。由于 macOS 的安全机制,在正常情况在无法读取微信进程的内存数据,所以需要临时关闭 SIP。关闭 SIP 的方法:
|
||||
|
||||
```shell
|
||||
# 1. 进入恢复模式
|
||||
Apple Intel Mac: 关机后,按住 Command + R 键开机,直到出现苹果标志和进度条。
|
||||
Apple Silicon Mac: 关机后,按住开机键不松开,直到出现苹果标志和进度条。
|
||||
# 2. 打开终端
|
||||
选项 - 实用工具 - 终端
|
||||
# 3. 关闭 SIP
|
||||
输入以下命令关闭 SIP:
|
||||
csrutil disable
|
||||
# 4. 重启系统
|
||||
```
|
||||
|
||||
2. 目前的 macOS 版本方案依赖 `lldb` 工具,所以需要安装 Xcode Command Line Tools。
|
||||
|
||||
```shell
|
||||
# 在 terminal 执行以下命令安装 Xcode Command Line Tools:
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
3. 仅获取数据密钥步骤需要关闭 SIP;获取数据密钥后即可重新打开 SIP,不影响解密数据和 HTTP 服务的运行。
|
||||
|
||||
4. 如果是 Apple Silicon 芯片的 mac 用户,请检查 微信、chatlog、terminal 均不要运行在 Rosetta 模式下运行,否则可能无法获取密钥。
|
||||
## 使用指南
|
||||
|
||||
### Terminal UI 模式
|
||||
|
||||
1. 启动程序:
|
||||
最简单的使用方式是通过 Terminal UI 界面操作:
|
||||
|
||||
```bash
|
||||
./chatlog
|
||||
chatlog
|
||||
```
|
||||
|
||||
2. 使用界面操作:
|
||||
- 使用方向键导航菜单
|
||||
- 按 `Enter` 选择菜单项
|
||||
- 按 `Esc` 返回上一级菜单
|
||||
- 按 `Ctrl+C` 退出程序
|
||||
操作方法:
|
||||
- 使用 `↑` `↓` 键选择菜单项
|
||||
- 按 `Enter` 确认选择
|
||||
- 按 `Esc` 返回上级菜单
|
||||
- 按 `Ctrl+C` 退出程序
|
||||
|
||||
### 命令行模式
|
||||
|
||||
获取微信数据密钥:
|
||||
对于熟悉命令行的用户,可以直接使用以下命令:
|
||||
|
||||
```bash
|
||||
./chatlog key
|
||||
# 获取微信数据密钥
|
||||
chatlog key
|
||||
|
||||
# 解密数据库文件
|
||||
chatlog decrypt
|
||||
|
||||
# 启动 HTTP 服务
|
||||
chatlog server
|
||||
```
|
||||
|
||||
解密数据库文件:
|
||||
### 从手机迁移聊天记录
|
||||
|
||||
```bash
|
||||
./chatlog decrypt
|
||||
```
|
||||
如果电脑端微信聊天记录不全,可以从手机端迁移数据:
|
||||
|
||||
1. 打开手机微信,进入 `我 - 设置 - 通用 - 聊天记录迁移与备份`
|
||||
2. 选择 `迁移 - 迁移到电脑`,按照提示操作
|
||||
3. 完成迁移后,重新运行 `chatlog` 获取密钥并解密数据
|
||||
|
||||
> 此操作不会影响手机上的聊天记录,只是将数据复制到电脑端
|
||||
|
||||
## 平台特定说明
|
||||
|
||||
### Windows 版本说明
|
||||
|
||||
如遇到界面显示异常(如花屏、乱码等),请使用 [Windows Terminal](https://github.com/microsoft/terminal) 运行程序
|
||||
|
||||
### macOS 版本说明
|
||||
|
||||
macOS 用户在获取密钥前需要临时关闭 SIP(系统完整性保护):
|
||||
|
||||
1. **关闭 SIP**:
|
||||
```shell
|
||||
# 进入恢复模式
|
||||
# Intel Mac: 重启时按住 Command + R
|
||||
# Apple Silicon: 重启时长按电源键
|
||||
|
||||
# 在恢复模式中打开终端并执行
|
||||
csrutil disable
|
||||
|
||||
# 重启系统
|
||||
```
|
||||
|
||||
2. **安装必要工具**:
|
||||
```shell
|
||||
# 安装 Xcode Command Line Tools
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
3. **获取密钥后**:可以重新启用 SIP(`csrutil enable`),不影响后续使用
|
||||
|
||||
> Apple Silicon 用户注意:确保微信、chatlog 和终端都不在 Rosetta 模式下运行
|
||||
|
||||
## HTTP API
|
||||
|
||||
启动 HTTP 服务后,可以通过以下 API 访问数据:
|
||||
启动 HTTP 服务后(默认地址 `http://127.0.0.1:5030`),可通过以下 API 访问数据:
|
||||
|
||||
### 聊天记录
|
||||
### 聊天记录查询
|
||||
|
||||
```
|
||||
GET /api/v1/chatlog?time=2023-01-01&talker=wxid_xxx&limit=100&offset=0&format=json
|
||||
GET /api/v1/chatlog?time=2023-01-01&talker=wxid_xxx
|
||||
```
|
||||
|
||||
参数说明:
|
||||
- `time`: 时间范围,格式为 `YYYY-MM-DD` 或 `YYYY-MM-DD~YYYY-MM-DD`
|
||||
- `talker`: 聊天对象的 ID,不知道 ID 的话也可以尝试备注名、昵称、群聊 ID等
|
||||
- `limit`: 返回记录数量限制
|
||||
- `talker`: 聊天对象标识(支持 wxid、群聊 ID、备注名、昵称等)
|
||||
- `limit`: 返回记录数量
|
||||
- `offset`: 分页偏移量
|
||||
- `format`: 输出格式,支持 `json`、`csv` 或纯文本
|
||||
|
||||
### 联系人列表
|
||||
### 其他 API 接口
|
||||
|
||||
```
|
||||
GET /api/v1/contact
|
||||
```
|
||||
- **联系人列表**:`GET /api/v1/contact`
|
||||
- **群聊列表**:`GET /api/v1/chatroom`
|
||||
- **会话列表**:`GET /api/v1/session`
|
||||
- **多媒体内容**:`GET /api/v1/media?msgid=xxx`
|
||||
|
||||
### 群聊列表
|
||||
|
||||
```
|
||||
GET /api/v1/chatroom
|
||||
```
|
||||
## MCP 集成
|
||||
|
||||
### 会话列表
|
||||
|
||||
```
|
||||
GET /api/v1/session
|
||||
```
|
||||
|
||||
## MCP
|
||||
|
||||
支持 MCP SSE 协议,启动 HTTP 服务后,通过 SSE Endpoint 访问服务:
|
||||
Chatlog 支持 MCP (Model Context Protocol) SSE 协议,可与支持 MCP 的 AI 助手无缝集成。
|
||||
启动 HTTP 服务后,通过 SSE Endpoint 访问服务:
|
||||
|
||||
```
|
||||
GET /sse
|
||||
```
|
||||
|
||||
提供了 4 个 tool 用于与 AI 助手集成:
|
||||
- `chatlog`: 查询聊天记录
|
||||
- `query_contact`: 查询联系人
|
||||
- `query_chat_room`: 查询群聊
|
||||
- `query_recent_chat`: 查询最近会话
|
||||
### 快速集成
|
||||
|
||||
### 示例
|
||||
Chatlog 可以与多种支持 MCP 的 AI 助手集成,包括:
|
||||
|
||||
以 [ChatWise](https://chatwise.app/) 工具为例,在 `设置 - 工具` 下新建工具,类型为 `sse`,ID 为 `chatlog`,URL 为 `http://127.0.0.1:5030/sse`,勾选自动执行工具,即可使用。
|
||||
- **ChatWise**: 直接支持 SSE,在工具设置中添加 `http://127.0.0.1:5030/sse`
|
||||
- **Cherry Studio**: 直接支持 SSE,在 MCP 服务器设置中添加 `http://127.0.0.1:5030/sse`
|
||||
|
||||
部分 AI 聊天工具暂时不支持 MCP SSE 协议,可以通过 [`mcp-proxy`](https://github.com/sparfenyuk/mcp-proxy) 工具转发请求,以 [Claude Desktop](https://claude.ai/download) 为例,在安装好 `mcp-proxy` 后,将 `mcp-proxy` 配置到 `Claude Desktop` 的 `config.json` 文件中,即可使用:
|
||||
对于不直接支持 SSE 的客户端,可以使用 [mcp-proxy](https://github.com/sparfenyuk/mcp-proxy) 工具转发请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mcp-proxy": {
|
||||
"command": "/Users/sarv/.local/bin/mcp-proxy",
|
||||
"args": [
|
||||
"http://localhost:5030/sse"
|
||||
],
|
||||
"env": {}
|
||||
}
|
||||
},
|
||||
"globalShortcut": ""
|
||||
}
|
||||
```
|
||||
- **Claude Desktop**: 通过 mcp-proxy 支持,需要配置 `claude_desktop_config.json`
|
||||
- **Monica Code**: 通过 mcp-proxy 支持,需要配置 VSCode 插件设置
|
||||
|
||||
### 详细集成指南
|
||||
|
||||
查看 [MCP 集成指南](docs/mcp.md) 获取各平台的详细配置步骤和注意事项。
|
||||
|
||||
## Prompt 示例
|
||||
|
||||
为了帮助大家更好地利用 Chatlog 与 AI 助手,我们整理了一些 prompt 示例。希望这些 prompt 可以启发大家更有效地查询和分析聊天记录,获取更精准的信息。
|
||||
|
||||
查看 [Prompt 指南](docs/prompt.md) 获取详细示例。
|
||||
|
||||
同时欢迎大家分享使用经验和 prompt!如果您有好的 prompt 示例或使用技巧,请通过 [Discussions](https://github.com/sjzar/chatlog/discussions) 进行分享,共同进步。
|
||||
|
||||
## 免责声明
|
||||
|
||||
⚠️ **重要提示:使用本项目前,请务必阅读并理解完整的 [免责声明](./DISCLAIMER.md)。**
|
||||
|
||||
本项目仅供学习、研究和个人合法使用,禁止用于任何非法目的或未授权访问他人数据。下载、安装或使用本工具即表示您同意遵守免责声明中的所有条款,并自行承担使用过程中的全部风险和法律责任。
|
||||
|
||||
### 摘要(请阅读完整免责声明)
|
||||
|
||||
- 仅限处理您自己合法拥有的聊天数据或已获授权的数据
|
||||
- 严禁用于未经授权获取、查看或分析他人聊天记录
|
||||
- 开发者不对使用本工具可能导致的任何损失承担责任
|
||||
- 使用第三方 LLM 服务时,您应遵守这些服务的使用条款和隐私政策
|
||||
|
||||
**本项目完全免费开源,任何以本项目名义收费的行为均与本项目无关。**
|
||||
|
||||
## License
|
||||
|
||||
`chatlog` 是在 Apache-2.0 许可下的开源软件。
|
||||
本项目基于 [Apache-2.0 许可证](./LICENSE) 开源。
|
||||
|
||||
## 隐私政策
|
||||
|
||||
本项目不收集任何用户数据。所有数据处理均在用户本地设备上进行。使用第三方服务时,请参阅相应服务的隐私政策。
|
||||
|
||||
## Thanks
|
||||
|
||||
- [@0xlane](https://github.com/0xlane) 的 [wechat-dump-rs](https://github.com/0xlane/wechat-dump-rs) 项目
|
||||
- [@xaoyaoo](https://github.com/xaoyaoo) 的 [PyWxDump](https://github.com/xaoyaoo/PyWxDump) 项目
|
||||
- [@git-jiadong](https://github.com/git-jiadong) 的 [go-lame](https://github.com/git-jiadong/go-lame) 和 [go-silk](https://github.com/git-jiadong/go-silk) 项目
|
||||
- [Anthropic](https://www.anthropic.com/) 的 [MCP]((https://github.com/modelcontextprotocol) ) 协议
|
||||
- 各个 Go 开源库的贡献者们
|
||||
43
cmd/chatlog/cmd_server.go
Normal file
43
cmd/chatlog/cmd_server.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package chatlog
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/chatlog"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(serverCmd)
|
||||
serverCmd.Flags().StringVarP(&serverAddr, "addr", "a", "127.0.0.1:5030", "server address")
|
||||
serverCmd.Flags().StringVarP(&serverDataDir, "data-dir", "d", "", "data dir")
|
||||
serverCmd.Flags().StringVarP(&serverWorkDir, "work-dir", "w", "", "work dir")
|
||||
serverCmd.Flags().StringVarP(&serverPlatform, "platform", "p", runtime.GOOS, "platform")
|
||||
serverCmd.Flags().IntVarP(&serverVer, "version", "v", 3, "version")
|
||||
}
|
||||
|
||||
var (
|
||||
serverAddr string
|
||||
serverDataDir string
|
||||
serverWorkDir string
|
||||
serverPlatform string
|
||||
serverVer int
|
||||
)
|
||||
|
||||
var serverCmd = &cobra.Command{
|
||||
Use: "server",
|
||||
Short: "Start HTTP server",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
m, err := chatlog.New("")
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to create chatlog instance")
|
||||
return
|
||||
}
|
||||
if err := m.CommandHTTPServer(serverAddr, serverDataDir, serverWorkDir, serverPlatform, serverVer); err != nil {
|
||||
log.Err(err).Msg("failed to start server")
|
||||
return
|
||||
}
|
||||
},
|
||||
}
|
||||
151
docs/mcp.md
Normal file
151
docs/mcp.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# MCP 集成指南
|
||||
|
||||
## 目录
|
||||
- [MCP 集成指南](#mcp-集成指南)
|
||||
- [目录](#目录)
|
||||
- [前期准备](#前期准备)
|
||||
- [mcp-proxy](#mcp-proxy)
|
||||
- [ChatWise](#chatwise)
|
||||
- [Cherry Studio](#cherry-studio)
|
||||
- [Claude Desktop](#claude-desktop)
|
||||
- [Monica Code](#monica-code)
|
||||
|
||||
|
||||
## 前期准备
|
||||
|
||||
运行 `chatlog`,完成数据解密并开启 HTTP 服务
|
||||
|
||||
### mcp-proxy
|
||||
如果遇到不支持 `SSE` 的客户端,可以尝试使用 `mcp-proxy` 将 `stdio` 的请求转换为 `SSE`。
|
||||
|
||||
项目地址:https://github.com/sparfenyuk/mcp-proxy
|
||||
|
||||
安装方式:
|
||||
```shell
|
||||
# 使用 uv 工具安装,也可参考项目文档的其他安装方式
|
||||
uv tool install mcp-proxy
|
||||
|
||||
# 查询 mcp-proxy 的路径,后续可直接使用该路径
|
||||
which mcp-proxy
|
||||
/Users/sarv/.local/bin/mcp-proxy
|
||||
```
|
||||
|
||||
## ChatWise
|
||||
|
||||
- 官网:https://chatwise.app/
|
||||
- 使用方式:MCP SSE
|
||||
- 注意事项:使用 ChatWise 的 MCP 功能需要 Pro 权限
|
||||
|
||||
1. 在 `设置 - 工具` 下新建 `SSE 请求` 工具
|
||||
|
||||

|
||||
|
||||
1. 在 URL 中填写 `http://127.0.0.1:5030/sse`,并勾选 `自动执行工具`,点击 `查看工具` 即可检查连接 `chatlog` 是否正常
|
||||
|
||||

|
||||
|
||||
3. 返回主页,选择支持 MCP 调用的模型,打开 `chatlog` 工具选项
|
||||
|
||||

|
||||
|
||||
4. 测试功能是否正常
|
||||
|
||||

|
||||
|
||||
## Cherry Studio
|
||||
|
||||
- 官网:https://cherry-ai.com/
|
||||
- 使用方式:MCP SSE
|
||||
|
||||
1. 在 `设置 - MCP 服务器` 下点击 `添加服务器`,输入名称为 `chatlog`,选择类型为 `服务器发送事件(sse)`,填写 URL 为 `http://127.0.0.1:5030/sse`,点击 `保存`。(注意:点击保存前不要先点击左侧的开启按钮)
|
||||
|
||||

|
||||
|
||||
2. 选择支持 MCP 调用的模型,打开 `chatlog` 工具选项
|
||||
|
||||

|
||||
|
||||
3. 测试功能是否正常
|
||||
|
||||

|
||||
|
||||
## Claude Desktop
|
||||
|
||||
- 官网:https://claude.ai/download
|
||||
- 使用方式:mcp-proxy
|
||||
- 参考资料:https://modelcontextprotocol.io/quickstart/user#2-add-the-filesystem-mcp-server
|
||||
|
||||
1. 请先参考 [mcp-proxy](#mcp-proxy) 安装 `mcp-proxy`
|
||||
|
||||
2. 进入 Claude Desktop `Settings - Developer`,点击 `Edit Config` 按钮,这样会创建一个 `claude_desktop_config.json` 配置文件,并引导你编辑该文件
|
||||
|
||||
3. 编辑 `claude_desktop_config.json` 文件,配置名称为 `chatlog`,command 为 `mcp-proxy` 的路径,args 为 `http://127.0.0.1:5030/sse`,如下所示:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"chatlog": {
|
||||
"command": "/Users/sarv/.local/bin/mcp-proxy",
|
||||
"args": [
|
||||
"http://localhost:5030/sse"
|
||||
]
|
||||
}
|
||||
},
|
||||
"globalShortcut": ""
|
||||
}
|
||||
```
|
||||
|
||||
4. 保存 `claude_desktop_config.json` 文件,重启 Claude Desktop,可以看到 `chatlog` 已经添加成功
|
||||
|
||||

|
||||
|
||||
5. 测试功能是否正常
|
||||
|
||||

|
||||
|
||||
|
||||
## Monica Code
|
||||
|
||||
- 官网:https://monica.im/en/code
|
||||
- 使用方式:mcp-proxy
|
||||
- 参考资料:https://github.com/Monica-IM/Monica-Code/blob/main/Reference/config.md#modelcontextprotocolserver
|
||||
|
||||
1. 请先参考 [mcp-proxy](#mcp-proxy) 安装 `mcp-proxy`
|
||||
|
||||
2. 在 vscode 插件文件夹(`~/.vscode/extensions`)下找到 Monica Code 的目录,编辑 `config_schema.json` 文件。将 `experimental - modelContextProtocolServer` 中 `transport` 设置为如下内容:
|
||||
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"type": "object",
|
||||
"title": "Experimental",
|
||||
"description": "Experimental properties are subject to change.",
|
||||
"properties": {
|
||||
"modelContextProtocolServer": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"transport": {
|
||||
"type": "stdio",
|
||||
"command": "/Users/sarv/.local/bin/mcp-proxy",
|
||||
"args": [
|
||||
"http://localhost:5030/sse"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"transport"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. 重启 vscode,可以看到 `chatlog` 已经添加成功
|
||||
|
||||

|
||||
|
||||
4. 测试功能是否正常
|
||||
|
||||

|
||||
|
||||
70
docs/prompt.md
Normal file
70
docs/prompt.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Prompt 指南
|
||||
|
||||
## 概述
|
||||
优秀的 `prompt` 可以极大的提高 `chatlog` 使用体验,收集了部分群友分享的 `prompt`,供大家参考。
|
||||
在处理聊天记录时,尽量选择上下文长度足够的 LLM,例如 `Gemini 2.5 Pro`、`Claude 3.5 Sonnet` 等。
|
||||
欢迎大家在 [Discussions](https://github.com/sjzar/chatlog/discussions/47) 中分享自己的使用方式,共同进步。
|
||||
|
||||
|
||||
## 群聊总结
|
||||
作者:@eyaeya
|
||||
|
||||
```md
|
||||
你是一个中文的群聊总结的助手,你可以为一个微信的群聊记录,提取并总结每个时间段大家在重点讨论的话题内容。
|
||||
|
||||
请帮我将 "<talker>" 在 <Time> 的群聊内容总结成一个群聊报告,包含不多于5个的话题的总结(如果还有更多话题,可以在后面简单补充)。每个话题包含以下内容:
|
||||
- 话题名(50字以内,带序号1️⃣2️⃣3️⃣,同时附带热度,以🔥数量表示)
|
||||
- 参与者(不超过5个人,将重复的人名去重)
|
||||
- 时间段(从几点到几点)
|
||||
- 过程(50到200字左右)
|
||||
- 评价(50字以下)
|
||||
- 分割线: ------------
|
||||
|
||||
另外有以下要求:
|
||||
1. 每个话题结束使用 ------------ 分割
|
||||
2. 使用中文冒号
|
||||
3. 无需大标题
|
||||
4. 开始给出本群讨论风格的整体评价,例如活跃、太水、太黄、太暴力、话题不集中、无聊诸如此类
|
||||
|
||||
最后总结下最活跃的前五个发言者。
|
||||
```
|
||||
|
||||
## 微信聊天记录可视化
|
||||
作者:@数字声明卡兹克
|
||||
原文地址:https://mp.weixin.qq.com/s/Z66YRjY1EnC_hMgXE9_nnw
|
||||
Prompt:[微信聊天记录可视化prompt.txt](https://github.com/user-attachments/files/19773263/prompt.txt)
|
||||
|
||||
这份 prompt 可以使用聊天记录生成 HTML 网页,再使用 [YOURWARE](https://www.yourware.so/) 部署为可分享的静态网页。
|
||||
|
||||
### 技术讨论分析
|
||||
作者:@eyaeya
|
||||
|
||||
```md
|
||||
你作为一个专业的技术讨论分析者,请对以下聊天记录进行分析和结构化总结:
|
||||
|
||||
1. 基础信息提取:
|
||||
- 将每个主题分成独立的问答对
|
||||
- 保持原始对话的时间顺序
|
||||
|
||||
1. 问题分析要点:
|
||||
- 提取问题的具体场景和背景
|
||||
- 识别问题的核心技术难点
|
||||
- 突出问题的实际影响
|
||||
|
||||
1. 解决方案总结:
|
||||
- 列出具体的解决步骤
|
||||
- 提取关键工具和资源
|
||||
- 包含实践经验和注意事项
|
||||
- 保留重要的链接和参考资料
|
||||
|
||||
1. 输出格式:
|
||||
- 不要输出"日期:YYYY-MM-DD"这一行,直接从问题1开始
|
||||
- 问题1:<简明扼要的问题描述>
|
||||
- 回答1:<完整的解决方案>
|
||||
- 补充:<额外的讨论要点或注意事项>
|
||||
|
||||
1. 额外要求(严格执行):
|
||||
- 如果有多个相关问题,保持逻辑顺序
|
||||
- 标记重要的警告和建议、突出经验性的分享内容、保留有价值的专业术语解释、移除"我来分析"等过渡语确保链接的完整性
|
||||
- 直接以日期开始,不要添加任何开场白
|
||||
```
|
||||
@@ -40,8 +40,8 @@ func (s *Service) GetDB() *wechatdb.DB {
|
||||
return s.db
|
||||
}
|
||||
|
||||
func (s *Service) GetMessages(start, end time.Time, talker string, limit, offset int) ([]*model.Message, error) {
|
||||
return s.db.GetMessages(start, end, talker, limit, offset)
|
||||
func (s *Service) GetMessages(start, end time.Time, talker string, sender string, keyword string, limit, offset int) ([]*model.Message, error) {
|
||||
return s.db.GetMessages(start, end, talker, sender, keyword, limit, offset)
|
||||
}
|
||||
|
||||
func (s *Service) GetContacts(key string, limit, offset int) (*wechatdb.GetContactsResp, error) {
|
||||
|
||||
@@ -75,11 +75,13 @@ func (s *Service) NoRoute(c *gin.Context) {
|
||||
func (s *Service) GetChatlog(c *gin.Context) {
|
||||
|
||||
q := struct {
|
||||
Time string `form:"time"`
|
||||
Talker string `form:"talker"`
|
||||
Limit int `form:"limit"`
|
||||
Offset int `form:"offset"`
|
||||
Format string `form:"format"`
|
||||
Time string `form:"time"`
|
||||
Talker string `form:"talker"`
|
||||
Sender string `form:"sender"`
|
||||
Keyword string `form:"keyword"`
|
||||
Limit int `form:"limit"`
|
||||
Offset int `form:"offset"`
|
||||
Format string `form:"format"`
|
||||
}{}
|
||||
|
||||
if err := c.BindQuery(&q); err != nil {
|
||||
@@ -100,7 +102,7 @@ func (s *Service) GetChatlog(c *gin.Context) {
|
||||
q.Offset = 0
|
||||
}
|
||||
|
||||
messages, err := s.db.GetMessages(start, end, q.Talker, q.Limit, q.Offset)
|
||||
messages, err := s.db.GetMessages(start, end, q.Talker, q.Sender, q.Keyword, q.Limit, q.Offset)
|
||||
if err != nil {
|
||||
errors.Err(c, err)
|
||||
return
|
||||
@@ -119,7 +121,7 @@ func (s *Service) GetChatlog(c *gin.Context) {
|
||||
c.Writer.Flush()
|
||||
|
||||
for _, m := range messages {
|
||||
c.Writer.WriteString(m.PlainText(len(q.Talker) == 0, c.Request.Host))
|
||||
c.Writer.WriteString(m.PlainText(strings.Contains(q.Talker, ","), util.PerfectTimeFormat(start, end), c.Request.Host))
|
||||
c.Writer.WriteString("\n")
|
||||
c.Writer.Flush()
|
||||
}
|
||||
@@ -129,10 +131,10 @@ func (s *Service) GetChatlog(c *gin.Context) {
|
||||
func (s *Service) GetContacts(c *gin.Context) {
|
||||
|
||||
q := struct {
|
||||
Key string `form:"key"`
|
||||
Limit int `form:"limit"`
|
||||
Offset int `form:"offset"`
|
||||
Format string `form:"format"`
|
||||
Keyword string `form:"keyword"`
|
||||
Limit int `form:"limit"`
|
||||
Offset int `form:"offset"`
|
||||
Format string `form:"format"`
|
||||
}{}
|
||||
|
||||
if err := c.BindQuery(&q); err != nil {
|
||||
@@ -140,7 +142,7 @@ func (s *Service) GetContacts(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
list, err := s.db.GetContacts(q.Key, q.Limit, q.Offset)
|
||||
list, err := s.db.GetContacts(q.Keyword, q.Limit, q.Offset)
|
||||
if err != nil {
|
||||
errors.Err(c, err)
|
||||
return
|
||||
@@ -174,10 +176,10 @@ func (s *Service) GetContacts(c *gin.Context) {
|
||||
func (s *Service) GetChatRooms(c *gin.Context) {
|
||||
|
||||
q := struct {
|
||||
Key string `form:"key"`
|
||||
Limit int `form:"limit"`
|
||||
Offset int `form:"offset"`
|
||||
Format string `form:"format"`
|
||||
Keyword string `form:"keyword"`
|
||||
Limit int `form:"limit"`
|
||||
Offset int `form:"offset"`
|
||||
Format string `form:"format"`
|
||||
}{}
|
||||
|
||||
if err := c.BindQuery(&q); err != nil {
|
||||
@@ -185,7 +187,7 @@ func (s *Service) GetChatRooms(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
list, err := s.db.GetChatRooms(q.Key, q.Limit, q.Offset)
|
||||
list, err := s.db.GetChatRooms(q.Keyword, q.Limit, q.Offset)
|
||||
if err != nil {
|
||||
errors.Err(c, err)
|
||||
return
|
||||
@@ -218,10 +220,10 @@ func (s *Service) GetChatRooms(c *gin.Context) {
|
||||
func (s *Service) GetSessions(c *gin.Context) {
|
||||
|
||||
q := struct {
|
||||
Key string `form:"key"`
|
||||
Limit int `form:"limit"`
|
||||
Offset int `form:"offset"`
|
||||
Format string `form:"format"`
|
||||
Keyword string `form:"keyword"`
|
||||
Limit int `form:"limit"`
|
||||
Offset int `form:"offset"`
|
||||
Format string `form:"format"`
|
||||
}{}
|
||||
|
||||
if err := c.BindQuery(&q); err != nil {
|
||||
@@ -229,7 +231,7 @@ func (s *Service) GetSessions(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
sessions, err := s.db.GetSessions(q.Key, q.Limit, q.Offset)
|
||||
sessions, err := s.db.GetSessions(q.Keyword, q.Limit, q.Offset)
|
||||
if err != nil {
|
||||
errors.Err(c, err)
|
||||
return
|
||||
|
||||
@@ -77,6 +77,21 @@ func (s *Service) Start() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ListenAndServe() error {
|
||||
|
||||
if s.ctx.HTTPAddr == "" {
|
||||
s.ctx.HTTPAddr = DefalutHTTPAddr
|
||||
}
|
||||
|
||||
s.server = &http.Server{
|
||||
Addr: s.ctx.HTTPAddr,
|
||||
Handler: s.router,
|
||||
}
|
||||
|
||||
log.Info().Msg("Starting HTTP server on " + s.ctx.HTTPAddr)
|
||||
return s.server.ListenAndServe()
|
||||
}
|
||||
|
||||
func (s *Service) Stop() error {
|
||||
|
||||
if s.server == nil {
|
||||
|
||||
@@ -1,156 +1,757 @@
|
||||
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Chatlog</title>
|
||||
<style>
|
||||
.random-paragraph {
|
||||
display: none;
|
||||
:root {
|
||||
--primary-color: #3498db;
|
||||
--primary-dark: #2980b9;
|
||||
--success-color: #2ecc71;
|
||||
--success-dark: #27ae60;
|
||||
--error-color: #e74c3c;
|
||||
--bg-light: #f5f5f5;
|
||||
--bg-white: #ffffff;
|
||||
--text-color: #333333;
|
||||
--border-color: #dddddd;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--text-color);
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.welcome-text {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.api-section {
|
||||
background-color: var(--bg-light);
|
||||
border-radius: 10px;
|
||||
padding: 25px;
|
||||
width: 100%;
|
||||
max-width: 850px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #2c3e50;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: var(--primary-color);
|
||||
margin-top: 20px;
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
padding-bottom: 8px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-top: 20px;
|
||||
color: #34495e;
|
||||
}
|
||||
|
||||
.docs-link {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.docs-link:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.api-tester {
|
||||
background-color: var(--bg-white);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 25px;
|
||||
margin-top: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
color: #34495e;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 18px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: var(--primary-dark);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.result-container {
|
||||
margin-top: 20px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
background-color: #f9f9f9;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo,
|
||||
monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.request-url {
|
||||
background-color: #f0f0f0;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 10px;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo,
|
||||
monospace;
|
||||
font-size: 14px;
|
||||
word-break: break-all;
|
||||
border: 1px dashed #ccc;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.url-text {
|
||||
flex-grow: 1;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.copy-url-button {
|
||||
background-color: #9b59b6;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.loading::after {
|
||||
content: "...";
|
||||
animation: dots 1.5s steps(5, end) infinite;
|
||||
}
|
||||
|
||||
@keyframes dots {
|
||||
0%,
|
||||
20% {
|
||||
content: ".";
|
||||
}
|
||||
40% {
|
||||
content: "..";
|
||||
}
|
||||
60% {
|
||||
content: "...";
|
||||
}
|
||||
80%,
|
||||
100% {
|
||||
content: "";
|
||||
}
|
||||
}
|
||||
|
||||
.tab-container {
|
||||
display: flex;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 12px 20px;
|
||||
cursor: pointer;
|
||||
margin-right: 5px;
|
||||
border-radius: 6px 6px 0 0;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: none;
|
||||
position: relative;
|
||||
bottom: -1px;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background-color: #f0f8ff;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background-color: var(--bg-white);
|
||||
border-color: #e0e0e0;
|
||||
color: var(--primary-color);
|
||||
border-bottom: 1px solid white;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
animation: fadeIn 0.3s;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
background-color: var(--success-color);
|
||||
padding: 8px 15px;
|
||||
font-size: 14px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.copy-button:hover {
|
||||
background-color: var(--success-dark);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--error-color);
|
||||
font-weight: bold;
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(231, 76, 60, 0.1);
|
||||
border-left: 4px solid var(--error-color);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.api-description {
|
||||
margin-bottom: 15px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-left: 8px;
|
||||
background-color: rgba(52, 152, 219, 0.1);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.optional-param {
|
||||
font-size: 12px;
|
||||
color: #777;
|
||||
margin-left: 5px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.required-field {
|
||||
color: var(--error-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="paragraphContainer">
|
||||
<pre style="float:left;white-space:pre-wrap;" class="random-paragraph">
|
||||
('-. .-. ('-. .-') _
|
||||
( OO ) / ( OO ).-. ( OO) )
|
||||
.-----. ,--. ,--. / . --. / / '._ ,--. .-'),-----. ,----.
|
||||
' .--./ | | | | | \-. \ |'--...__) | |.-') ( OO' .-. ' ' .-./-')
|
||||
| |('-. | .| | .-'-' | | '--. .--' | | OO ) / | | | | | |_( O- )
|
||||
/_) |OO ) | | \| |_.' | | | | |`-' | \_) | |\| | | | .--, \
|
||||
|| |`-'| | .-. | | .-. | | | (| '---.' \ | | | |(| | '. (_/
|
||||
(_' '--'\ | | | | | | | | | | | | `' '-' ' | '--' |
|
||||
`-----' `--' `--' `--' `--' `--' `------' `-----' `------'
|
||||
</pre>
|
||||
<pre style="float:left;white-space:pre-wrap;" class="random-paragraph">
|
||||
_____ _ _ _
|
||||
/ __ \| | | | | |
|
||||
| / \/| |__ __ _ | |_ | | ___ __ _
|
||||
| | | '_ \ / _` || __|| | / _ \ / _` |
|
||||
| \__/\| | | || (_| || |_ | || (_) || (_| |
|
||||
\____/|_| |_| \__,_| \__||_| \___/ \__, |
|
||||
__/ |
|
||||
|___/
|
||||
</pre>
|
||||
<pre style="float:left;white-space:pre-wrap;" class="random-paragraph">
|
||||
|
||||
,-----. ,--. ,--. ,--.
|
||||
' .--./ | ,---. ,--,--. ,-' '-. | | ,---. ,---.
|
||||
| | | .-. | ' ,-. | '-. .-' | | | .-. | | .-. |
|
||||
' '--'\ | | | | \ '-' | | | | | ' '-' ' ' '-' '
|
||||
`-----' `--' `--' `--`--' `--' `--' `---' .`- /
|
||||
`---'
|
||||
</pre>
|
||||
<pre style="float:left;white-space:pre-wrap;" class="random-paragraph">
|
||||
____ _ _ _
|
||||
/ ___| | |__ __ _ | |_ | | ___ __ _
|
||||
| | | '_ \ / _` | | __| | | / _ \ / _` |
|
||||
| |___ | | | | | (_| | | |_ | | | (_) | | (_| |
|
||||
\____| |_| |_| \__,_| \__| |_| \___/ \__, |
|
||||
|___/
|
||||
</pre>
|
||||
<pre style="float:left;white-space:pre-wrap;" class="random-paragraph">
|
||||
_____ _____ _____ _____ _____ _______ _____
|
||||
/\ \ /\ \ /\ \ /\ \ /\ \ /::\ \ /\ \
|
||||
/::\ \ /::\____\ /::\ \ /::\ \ /::\____\ /::::\ \ /::\ \
|
||||
/::::\ \ /:::/ / /::::\ \ \:::\ \ /:::/ / /::::::\ \ /::::\ \
|
||||
/::::::\ \ /:::/ / /::::::\ \ \:::\ \ /:::/ / /::::::::\ \ /::::::\ \
|
||||
/:::/\:::\ \ /:::/ / /:::/\:::\ \ \:::\ \ /:::/ / /:::/~~\:::\ \ /:::/\:::\ \
|
||||
/:::/ \:::\ \ /:::/____/ /:::/__\:::\ \ \:::\ \ /:::/ / /:::/ \:::\ \ /:::/ \:::\ \
|
||||
/:::/ \:::\ \ /::::\ \ /::::\ \:::\ \ /::::\ \ /:::/ / /:::/ / \:::\ \ /:::/ \:::\ \
|
||||
/:::/ / \:::\ \ /::::::\ \ _____ /::::::\ \:::\ \ /::::::\ \ /:::/ / /:::/____/ \:::\____\ /:::/ / \:::\ \
|
||||
/:::/ / \:::\ \ /:::/\:::\ \ /\ \ /:::/\:::\ \:::\ \ /:::/\:::\ \ /:::/ / |:::| | |:::| | /:::/ / \:::\ ___\
|
||||
/:::/____/ \:::\____\/:::/ \:::\ /::\____\/:::/ \:::\ \:::\____\ /:::/ \:::\____\/:::/____/ |:::|____| |:::| |/:::/____/ ___\:::| |
|
||||
\:::\ \ \::/ /\::/ \:::\ /:::/ /\::/ \:::\ /:::/ / /:::/ \::/ /\:::\ \ \:::\ \ /:::/ / \:::\ \ /\ /:::|____|
|
||||
\:::\ \ \/____/ \/____/ \:::\/:::/ / \/____/ \:::\/:::/ / /:::/ / \/____/ \:::\ \ \:::\ \ /:::/ / \:::\ /::\ \::/ /
|
||||
\:::\ \ \::::::/ / \::::::/ / /:::/ / \:::\ \ \:::\ /:::/ / \:::\ \:::\ \/____/
|
||||
\:::\ \ \::::/ / \::::/ / /:::/ / \:::\ \ \:::\__/:::/ / \:::\ \:::\____\
|
||||
\:::\ \ /:::/ / /:::/ / \::/ / \:::\ \ \::::::::/ / \:::\ /:::/ /
|
||||
\:::\ \ /:::/ / /:::/ / \/____/ \:::\ \ \::::::/ / \:::\/:::/ /
|
||||
\:::\ \ /:::/ / /:::/ / \:::\ \ \::::/ / \::::::/ /
|
||||
\:::\____\ /:::/ / /:::/ / \:::\____\ \::/____/ \::::/ /
|
||||
\::/ / \::/ / \::/ / \::/ / ~~ \::/____/
|
||||
\/____/ \/____/ \/____/ \/____/
|
||||
|
||||
</pre>
|
||||
<pre style="float:left;white-space:pre-wrap;" class="random-paragraph">
|
||||
___ _ _ __ ____ __ _____ ___
|
||||
/ __)( )_( ) /__\ (_ _)( ) ( _ ) / __)
|
||||
( (__ ) _ ( /(__)\ )( )(__ )(_)( ( (_-.
|
||||
\___)(_) (_)(__)(__) (__) (____)(_____) \___/
|
||||
</pre>
|
||||
<pre style="float:left;white-space:pre-wrap;" class="random-paragraph">
|
||||
________ ___ ___ ________ _________ ___ ________ ________
|
||||
|\ ____\ |\ \|\ \ |\ __ \ |\___ ___\ |\ \ |\ __ \ |\ ____\
|
||||
\ \ \___| \ \ \\\ \ \ \ \|\ \ \|___ \ \_| \ \ \ \ \ \|\ \ \ \ \___|
|
||||
\ \ \ \ \ __ \ \ \ __ \ \ \ \ \ \ \ \ \ \\\ \ \ \ \ ___
|
||||
\ \ \____ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \____ \ \ \\\ \ \ \ \|\ \
|
||||
\ \_______\ \ \__\ \__\ \ \__\ \__\ \ \__\ \ \_______\ \ \_______\ \ \_______\
|
||||
\|_______| \|__|\|__| \|__|\|__| \|__| \|_______| \|_______| \|_______|
|
||||
</pre>
|
||||
<pre style="float:left;white-space:pre-wrap;" class="random-paragraph">
|
||||
╔═╗┬ ┬┌─┐┌┬┐┬ ┌─┐┌─┐
|
||||
║ ├─┤├─┤ │ │ │ ││ ┬
|
||||
╚═╝┴ ┴┴ ┴ ┴ ┴─┘└─┘└─┘
|
||||
</pre>
|
||||
<pre style="float:left;white-space:pre-wrap;" class="random-paragraph">
|
||||
▄▄· ▄ .▄ ▄▄▄· ▄▄▄▄▄▄▄▌ ▄▄ •
|
||||
▐█ ▌▪██▪▐█▐█ ▀█ •██ ██• ▪ ▐█ ▀ ▪
|
||||
██ ▄▄██▀▐█▄█▀▀█ ▐█.▪██▪ ▄█▀▄ ▄█ ▀█▄
|
||||
▐███▌██▌▐▀▐█ ▪▐▌ ▐█▌·▐█▌▐▌▐█▌.▐▌▐█▄▪▐█
|
||||
·▀▀▀ ▀▀▀ · ▀ ▀ ▀▀▀ .▀▀▀ ▀█▄▀▪·▀▀▀▀
|
||||
</pre>
|
||||
<pre style="float:left;white-space:pre-wrap;" class="random-paragraph">
|
||||
,. - ., .·¨'`; ,.·´¨;\ ,., ' , . ., ° ,. ' , ·. ,.-·~·., ‘ ,.-·^*ª'` ·,
|
||||
,·'´ ,. - , ';\ '; ;'\ '; ;::\ ;´ '· ., ;'´ , ., _';\' / ';\ / ·'´,.-·-., `,'‚ .·´ ,·'´:¯'`·, '\‘
|
||||
,·´ .'´\:::::;' ;:'\ ' ; ;::'\ ,' ;::'; .´ .-, ';\ \:´¨¯:;' `;::'\:'\ ,' ,'::'\ / .'´\:::::::'\ '\ ° ,´ ,'\:::::::::\,.·\'
|
||||
/ ,'´::::'\;:-/ ,' ::; ' ; ;::_';,. ,.' ;:::';° / /:\:'; ;:'\' \::::; ,'::_'\;' ,' ;:::';' ,·' ,'::::\:;:-·-:'; ';\‚ / /:::\;·'´¯'`·;\:::\°
|
||||
,' ;':::::;'´ '; /\::;' ' .' ,. -·~-·, ;:::'; ' ,' ,'::::'\'; ;::'; ,' ,'::;' ‘ '; ,':::;' ;. ';:::;´ ,' ,':'\‚ ; ;:::;' '\;:·´
|
||||
; ;:::::; '\*'´\::\' ° '; ;'\::::::::; '/::::; ,.-·' '·~^*'´¨, ';::; ; ;:::; ° ; ,':::;' ' '; ;::; ,'´ .'´\::';‚ '; ;::/ ,·´¯'; °
|
||||
'; ';::::'; '\::'\/.' ; ';:;\;::-··; ;::::; ':, ,·:²*´¨¯'`; ;::'; ; ;::;' ‘ ,' ,'::;' '; ':;: ,.·´,.·´::::\;'° '; '·;' ,.·´, ;'\
|
||||
\ '·:;:'_ ,. -·'´.·´\‘ ':,.·´\;' ;' ,' :::/ ' ,' / \::::::::'; ;::'; ; ;::;'‚ ; ';_:,.-·´';\‘ \·, `*´,.·'´::::::;·´ \'·. `'´,.·:´'; ;::\'
|
||||
'\:` · .,. -·:´::::::\' \:::::\ \·.'::::; ,' ,'::::\·²*'´¨¯':,'\:; ',.'\::;'‚ ', _,.-·'´:\:\‘ \\:¯::\:::::::;:·´ '\::\¯::::::::'; ;::'; ‘
|
||||
\:::::::\:::::::;:·'´' \;:·´ \:\::'; \`¨\:::/ \::\' \::\:;'‚ \¨:::::::::::\'; `\:::::\;::·'´ ° `·:\:::;:·´';.·´\::;'
|
||||
`· :;::\;::-·´ `·\;' '\::\;' '\;' ' \;:' ‘ '\;::_;:-·'´‘ ¯ ¯ \::::\;'‚
|
||||
' `¨' ° '¨ ‘ '\:·´'
|
||||
</pre>
|
||||
<pre style="float:left;white-space:pre-wrap;" class="random-paragraph">
|
||||
▄████▄ ██░ ██ ▄▄▄ ▄▄▄█████▓ ██▓ ▒█████ ▄████
|
||||
▒██▀ ▀█ ▓██░ ██▒▒████▄ ▓ ██▒ ▓▒▓██▒ ▒██▒ ██▒ ██▒ ▀█▒
|
||||
▒▓█ ▄ ▒██▀▀██░▒██ ▀█▄ ▒ ▓██░ ▒░▒██░ ▒██░ ██▒▒██░▄▄▄░
|
||||
▒▓▓▄ ▄██▒░▓█ ░██ ░██▄▄▄▄██ ░ ▓██▓ ░ ▒██░ ▒██ ██░░▓█ ██▓
|
||||
▒ ▓███▀ ░░▓█▒░██▓ ▓█ ▓██▒ ▒██▒ ░ ░██████▒░ ████▓▒░░▒▓███▀▒
|
||||
░ ░▒ ▒ ░ ▒ ░░▒░▒ ▒▒ ▓▒█░ ▒ ░░ ░ ▒░▓ ░░ ▒░▒░▒░ ░▒ ▒
|
||||
░ ▒ ▒ ░▒░ ░ ▒ ▒▒ ░ ░ ░ ░ ▒ ░ ░ ▒ ▒░ ░ ░
|
||||
░ ░ ░░ ░ ░ ▒ ░ ░ ░ ░ ░ ░ ▒ ░ ░ ░
|
||||
░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░
|
||||
░
|
||||
</pre>
|
||||
<pre style="float:left;white-space:pre-wrap;" class="random-paragraph">
|
||||
▄█▄ ▄ █ ██ ▄▄▄▄▀ █ ████▄ ▄▀
|
||||
█▀ ▀▄ █ █ █ █ ▀▀▀ █ █ █ █ ▄▀
|
||||
█ ▀ ██▀▀█ █▄▄█ █ █ █ █ █ ▀▄
|
||||
█▄ ▄▀ █ █ █ █ █ ███▄ ▀████ █ █
|
||||
▀███▀ █ █ ▀ ▀ ███
|
||||
▀ █
|
||||
▀
|
||||
</pre>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="welcome-text">
|
||||
<h1>🎉 恭喜!Chatlog 服务已成功启动</h1>
|
||||
<p>
|
||||
Chatlog 是一个帮助你轻松使用自己聊天数据的工具,现在你可以通过 HTTP
|
||||
API 访问你的聊天记录、联系人和群聊信息。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="api-section">
|
||||
<h2>🔍 API 接口与调试</h2>
|
||||
|
||||
<div class="api-tester">
|
||||
<div class="tab-container">
|
||||
<div class="tab active" data-tab="session">最近会话</div>
|
||||
<div class="tab" data-tab="chatroom">群聊</div>
|
||||
<div class="tab" data-tab="contact">联系人</div>
|
||||
<div class="tab" data-tab="chatlog">聊天记录</div>
|
||||
</div>
|
||||
|
||||
<!-- 会话查询表单 -->
|
||||
<div class="tab-content active" id="session-tab">
|
||||
<div class="api-description">
|
||||
<p>
|
||||
查询最近会话列表。<span class="badge">GET /api/v1/session</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="session-format"
|
||||
>输出格式:<span class="optional-param">可选</span></label
|
||||
>
|
||||
<select id="session-format">
|
||||
<option value="">默认</option>
|
||||
<option value="json">JSON</option>
|
||||
<option value="text">纯文本</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 群聊查询表单 -->
|
||||
<div class="tab-content" id="chatroom-tab">
|
||||
<div class="api-description">
|
||||
<p>
|
||||
查询群聊列表,可选择性地按关键词搜索。<span class="badge"
|
||||
>GET /api/v1/chatroom</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="chatroom-keyword"
|
||||
>搜索群聊:<span class="optional-param">可选</span></label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="chatroom-keyword"
|
||||
placeholder="输入关键词搜索群聊"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="chatroom-format"
|
||||
>输出格式:<span class="optional-param">可选</span></label
|
||||
>
|
||||
<select id="chatroom-format">
|
||||
<option value="">默认</option>
|
||||
<option value="json">JSON</option>
|
||||
<option value="text">纯文本</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 联系人查询表单 -->
|
||||
<div class="tab-content" id="contact-tab">
|
||||
<div class="api-description">
|
||||
<p>
|
||||
查询联系人列表,可选择性地按关键词搜索。<span class="badge"
|
||||
>GET /api/v1/contact</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="contact-keyword"
|
||||
>搜索联系人:<span class="optional-param">可选</span></label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="contact-keyword"
|
||||
placeholder="输入关键词搜索联系人"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="contact-format"
|
||||
>输出格式:<span class="optional-param">可选</span></label
|
||||
>
|
||||
<select id="contact-format">
|
||||
<option value="">默认</option>
|
||||
<option value="json">JSON</option>
|
||||
<option value="text">纯文本</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 聊天记录查询表单 -->
|
||||
<div class="tab-content" id="chatlog-tab">
|
||||
<div class="api-description">
|
||||
<p>
|
||||
查询指定时间范围内与特定联系人或群聊的聊天记录。<span
|
||||
class="badge"
|
||||
>GET /api/v1/chatlog</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="time"
|
||||
>时间范围:<span class="required-field">*</span></label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="time"
|
||||
placeholder="例如:2023-01-01 或 2023-01-01~2023-01-31"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="talker"
|
||||
>聊天对象:<span class="required-field">*</span></label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="talker"
|
||||
placeholder="wxid、群ID、备注名或昵称"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sender"
|
||||
>发送者:<span class="optional-param">可选</span></label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="sender"
|
||||
placeholder="指定消息发送者"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="keyword"
|
||||
>关键词:<span class="optional-param">可选</span></label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="keyword"
|
||||
placeholder="搜索消息内容中的关键词"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="limit"
|
||||
>返回数量:<span class="optional-param">可选</span></label
|
||||
>
|
||||
<input type="number" id="limit" placeholder="默认不做限制" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="offset"
|
||||
>偏移量:<span class="optional-param">可选</span></label
|
||||
>
|
||||
<input type="number" id="offset" placeholder="默认 0" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="format"
|
||||
>输出格式:<span class="optional-param">可选</span></label
|
||||
>
|
||||
<select id="format">
|
||||
<option value="">默认</option>
|
||||
<option value="text">纯文本</option>
|
||||
<option value="json">JSON</option>
|
||||
<option value="csv">CSV</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="test-api">执行查询</button>
|
||||
|
||||
<div id="result-wrapper" style="display: none; margin-top: 20px">
|
||||
<div class="request-url" id="request-url-container">
|
||||
<span class="url-text" id="request-url"></span>
|
||||
<button class="copy-button copy-url-button" id="copy-url">
|
||||
复制请求URL
|
||||
</button>
|
||||
</div>
|
||||
<div class="result-container" id="api-result">
|
||||
<p>查询结果将显示在这里...</p>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<button class="copy-button" id="copy-result">复制结果</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="error-message" id="error-message"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="api-section">
|
||||
<h2>🤖 MCP 集成</h2>
|
||||
<p>
|
||||
Chatlog 支持 MCP (Model Context Protocol) SSE 协议,可与支持 MCP 的 AI
|
||||
助手无缝集成。
|
||||
</p>
|
||||
<p>SSE 端点:<strong>/sse</strong></p>
|
||||
<p>
|
||||
详细集成指南请参考
|
||||
<a
|
||||
href="https://github.com/sjzar/chatlog/blob/main/docs/mcp.md"
|
||||
class="docs-link"
|
||||
target="_blank"
|
||||
>MCP 集成指南</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="api-section">
|
||||
<h2>📚 更多资源</h2>
|
||||
<p>
|
||||
查看
|
||||
<a
|
||||
href="https://github.com/sjzar/chatlog"
|
||||
class="docs-link"
|
||||
target="_blank"
|
||||
>GitHub 项目</a
|
||||
>
|
||||
获取完整文档和使用指南。
|
||||
</p>
|
||||
<p>
|
||||
如果你有任何问题或建议,欢迎通过
|
||||
<a
|
||||
href="https://github.com/sjzar/chatlog/discussions"
|
||||
class="docs-link"
|
||||
target="_blank"
|
||||
>Discussions</a
|
||||
>
|
||||
进行交流。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.onload = function() {
|
||||
showRandomParagraph();
|
||||
};
|
||||
function showRandomParagraph() {
|
||||
const paragraphs = document.getElementsByClassName("random-paragraph");
|
||||
for (let i = 0; i < paragraphs.length; i++) {
|
||||
paragraphs[i].style.display = "none";
|
||||
// 标签切换功能
|
||||
document.querySelectorAll(".tab").forEach((tab) => {
|
||||
tab.addEventListener("click", function () {
|
||||
// 移除所有标签的活动状态
|
||||
document
|
||||
.querySelectorAll(".tab")
|
||||
.forEach((t) => t.classList.remove("active"));
|
||||
// 设置当前标签为活动状态
|
||||
this.classList.add("active");
|
||||
|
||||
// 隐藏所有内容区域
|
||||
document.querySelectorAll(".tab-content").forEach((content) => {
|
||||
content.classList.remove("active");
|
||||
});
|
||||
|
||||
// 显示当前标签对应的内容
|
||||
const tabId = this.getAttribute("data-tab") + "-tab";
|
||||
document.getElementById(tabId).classList.add("active");
|
||||
|
||||
// 清空结果区域
|
||||
document.getElementById("result-wrapper").style.display = "none";
|
||||
document.getElementById("api-result").innerHTML =
|
||||
"<p>查询结果将显示在这里...</p>";
|
||||
document.getElementById("request-url").textContent = "";
|
||||
document.getElementById("error-message").style.display = "none";
|
||||
document.getElementById("error-message").textContent = "";
|
||||
});
|
||||
});
|
||||
|
||||
// API 测试功能
|
||||
document
|
||||
.getElementById("test-api")
|
||||
.addEventListener("click", async function () {
|
||||
const resultContainer = document.getElementById("api-result");
|
||||
const requestUrlContainer = document.getElementById("request-url");
|
||||
const errorMessage = document.getElementById("error-message");
|
||||
const resultWrapper = document.getElementById("result-wrapper");
|
||||
|
||||
errorMessage.style.display = "none";
|
||||
errorMessage.textContent = "";
|
||||
|
||||
try {
|
||||
// 获取当前活动的标签
|
||||
const activeTab = document
|
||||
.querySelector(".tab.active")
|
||||
.getAttribute("data-tab");
|
||||
let url = "/api/v1/";
|
||||
let params = new URLSearchParams();
|
||||
|
||||
// 根据不同的标签构建不同的请求
|
||||
switch (activeTab) {
|
||||
case "chatlog":
|
||||
url += "chatlog";
|
||||
const time = document.getElementById("time").value;
|
||||
const talker = document.getElementById("talker").value;
|
||||
const sender = document.getElementById("sender").value;
|
||||
const keyword = document.getElementById("keyword").value;
|
||||
const limit = document.getElementById("limit").value;
|
||||
const offset = document.getElementById("offset").value;
|
||||
const format = document.getElementById("format").value;
|
||||
|
||||
// 验证必填项
|
||||
if (!time || !talker) {
|
||||
errorMessage.textContent =
|
||||
"错误: 时间范围和聊天对象为必填项!";
|
||||
errorMessage.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
if (time) params.append("time", time);
|
||||
if (talker) params.append("talker", talker);
|
||||
if (sender) params.append("sender", sender);
|
||||
if (keyword) params.append("keyword", keyword);
|
||||
if (limit) params.append("limit", limit);
|
||||
if (offset) params.append("offset", offset);
|
||||
if (format) params.append("format", format);
|
||||
break;
|
||||
|
||||
case "contact":
|
||||
url += "contact";
|
||||
const contactKeyword =
|
||||
document.getElementById("contact-keyword").value;
|
||||
const contactFormat =
|
||||
document.getElementById("contact-format").value;
|
||||
|
||||
if (contactKeyword) params.append("keyword", contactKeyword);
|
||||
if (contactFormat) params.append("format", contactFormat);
|
||||
break;
|
||||
|
||||
case "chatroom":
|
||||
url += "chatroom";
|
||||
const chatroomKeyword =
|
||||
document.getElementById("chatroom-keyword").value;
|
||||
const chatroomFormat =
|
||||
document.getElementById("chatroom-format").value;
|
||||
|
||||
if (chatroomKeyword) params.append("keyword", chatroomKeyword);
|
||||
if (chatroomFormat) params.append("format", chatroomFormat);
|
||||
break;
|
||||
|
||||
case "session":
|
||||
url += "session";
|
||||
const sessionFormat =
|
||||
document.getElementById("session-format").value;
|
||||
|
||||
if (sessionFormat) params.append("format", sessionFormat);
|
||||
break;
|
||||
}
|
||||
const randomIndex = Math.floor(Math.random() * paragraphs.length);
|
||||
paragraphs[randomIndex].style.display = "block";
|
||||
}
|
||||
|
||||
// 添加参数到URL
|
||||
const apiUrl = params.toString()
|
||||
? `${url}?${params.toString()}`
|
||||
: url;
|
||||
|
||||
// 获取完整URL(包含域名部分)
|
||||
const fullUrl = window.location.origin + apiUrl;
|
||||
|
||||
// 显示完整请求URL
|
||||
requestUrlContainer.textContent = fullUrl;
|
||||
resultWrapper.style.display = "block";
|
||||
|
||||
// 显示加载中
|
||||
resultContainer.innerHTML = '<div class="loading">加载中</div>';
|
||||
|
||||
// 发送请求
|
||||
const response = await fetch(apiUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
// 获取响应内容类型
|
||||
const contentType = response.headers.get("content-type");
|
||||
let result;
|
||||
|
||||
if (contentType && contentType.includes("application/json")) {
|
||||
// 如果是JSON,格式化显示
|
||||
result = await response.json();
|
||||
resultContainer.innerHTML = JSON.stringify(result, null, 2);
|
||||
} else {
|
||||
// 其他格式直接显示文本
|
||||
result = await response.text();
|
||||
resultContainer.innerHTML = result;
|
||||
}
|
||||
} catch (error) {
|
||||
resultContainer.innerHTML = "";
|
||||
errorMessage.textContent = `查询出错: ${error.message}`;
|
||||
errorMessage.style.display = "block";
|
||||
console.error("API查询出错:", error);
|
||||
}
|
||||
});
|
||||
|
||||
// 复制结果功能
|
||||
document
|
||||
.getElementById("copy-result")
|
||||
.addEventListener("click", function () {
|
||||
const resultText = document.getElementById("api-result").innerText;
|
||||
copyToClipboard(resultText, this, "已复制结果!");
|
||||
});
|
||||
|
||||
// 复制URL功能
|
||||
document
|
||||
.getElementById("copy-url")
|
||||
.addEventListener("click", function () {
|
||||
// 获取完整URL(包含域名部分)
|
||||
const urlText = document.getElementById("request-url").innerText;
|
||||
copyToClipboard(urlText, this, "已复制URL!");
|
||||
});
|
||||
|
||||
// 通用复制功能
|
||||
function copyToClipboard(text, button, successMessage) {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
const originalText = button.textContent;
|
||||
button.textContent = successMessage;
|
||||
setTimeout(() => {
|
||||
button.textContent = originalText;
|
||||
}, 2000);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("复制失败:", err);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</body>
|
||||
</html>
|
||||
@@ -306,3 +306,44 @@ func (m *Manager) CommandDecrypt(dataDir string, workDir string, key string, pla
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) CommandHTTPServer(addr string, dataDir string, workDir string, platform string, version int) error {
|
||||
|
||||
if addr == "" {
|
||||
addr = "127.0.0.1:5030"
|
||||
}
|
||||
|
||||
if workDir == "" {
|
||||
return fmt.Errorf("workDir is required")
|
||||
}
|
||||
|
||||
if platform == "" {
|
||||
return fmt.Errorf("platform is required")
|
||||
}
|
||||
|
||||
if version == 0 {
|
||||
return fmt.Errorf("version is required")
|
||||
}
|
||||
|
||||
m.ctx.HTTPAddr = addr
|
||||
m.ctx.DataDir = dataDir
|
||||
m.ctx.WorkDir = workDir
|
||||
m.ctx.Platform = platform
|
||||
m.ctx.Version = version
|
||||
|
||||
// 如果是 4.0 版本,更新下 xorkey
|
||||
if m.ctx.Version == 4 && m.ctx.DataDir != "" {
|
||||
go dat2img.ScanAndSetXorKey(m.ctx.DataDir)
|
||||
}
|
||||
|
||||
// 按依赖顺序启动服务
|
||||
if err := m.db.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.mcp.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return m.http.ListenAndServe()
|
||||
}
|
||||
|
||||
@@ -21,12 +21,12 @@ var (
|
||||
InputSchema: mcp.ToolSchema{
|
||||
Type: "object",
|
||||
Properties: mcp.M{
|
||||
"query": mcp.M{
|
||||
"keyword": mcp.M{
|
||||
"type": "string",
|
||||
"description": "联系人的搜索关键词,可以是姓名、备注名或ID。",
|
||||
},
|
||||
},
|
||||
Required: []string{"query"},
|
||||
Required: []string{"keyword"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -36,12 +36,12 @@ var (
|
||||
InputSchema: mcp.ToolSchema{
|
||||
Type: "object",
|
||||
Properties: mcp.M{
|
||||
"query": mcp.M{
|
||||
"keyword": mcp.M{
|
||||
"type": "string",
|
||||
"description": "群聊的搜索关键词,可以是群名称、群ID或相关描述",
|
||||
},
|
||||
},
|
||||
Required: []string{"query"},
|
||||
Required: []string{"keyword"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -55,24 +55,136 @@ var (
|
||||
}
|
||||
|
||||
ToolChatLog = mcp.Tool{
|
||||
Name: "chatlog",
|
||||
Description: "查询特定时间或时间段内与特定联系人或群组的聊天记录。当用户需要回顾过去的对话内容、查找特定信息或想了解与某人/某群的历史交流时使用此工具。",
|
||||
Name: "chatlog",
|
||||
Description: `检索历史聊天记录,可根据时间、对话方、发送者和关键词等条件进行精确查询。当用户需要查找特定信息或想了解与某人/某群的历史交流时使用此工具。
|
||||
|
||||
【强制多步查询流程!】
|
||||
当查询特定话题或特定发送者发言时,必须严格按照以下流程使用,任何偏离都会导致错误的结果:
|
||||
|
||||
步骤1: 初步定位相关消息
|
||||
- 使用keyword参数查找特定话题
|
||||
- 使用sender参数查找特定发送者的消息
|
||||
- 使用较宽时间范围初步查询
|
||||
|
||||
步骤2: 【必须执行】针对每个关键结果点分别获取上下文
|
||||
- 必须对步骤1返回的每个时间点T1, T2, T3...分别执行独立查询(时间范围接近的消息可以合并为一个查询)
|
||||
- 每次独立查询必须移除keyword参数
|
||||
- 每次独立查询必须移除sender参数
|
||||
- 每次独立查询使用"Tn前后15-30分钟"的窄范围
|
||||
- 每次独立查询仅保留talker参数
|
||||
|
||||
步骤3: 【必须执行】综合分析所有上下文
|
||||
- 必须等待所有步骤2的查询结果返回后再进行分析
|
||||
- 必须综合考虑所有上下文信息后再回答用户
|
||||
|
||||
【严格执行规则!】
|
||||
- 禁止仅凭步骤1的结果直接回答用户
|
||||
- 禁止在步骤2使用过大的时间范围一次性查询所有上下文
|
||||
- 禁止跳过步骤2或步骤3
|
||||
- 必须对每个关键结果点分别执行独立的上下文查询
|
||||
|
||||
【执行示例】
|
||||
正确流程示例:
|
||||
1. 步骤1: chatlog(time="2023-04-01~2023-04-30", talker="工作群", keyword="项目进度")
|
||||
返回结果: 4月5日、4月12日、4月20日有相关消息
|
||||
2. 步骤2:
|
||||
- 查询1: chatlog(time="2023-04-05/09:30~2023-04-05/10:30", talker="工作群") // 注意没有keyword
|
||||
- 查询2: chatlog(time="2023-04-12/14:00~2023-04-12/15:00", talker="工作群") // 注意没有keyword
|
||||
- 查询3: chatlog(time="2023-04-20/16:00~2023-04-20/17:00", talker="工作群") // 注意没有keyword
|
||||
3. 步骤3: 综合分析所有上下文后回答用户
|
||||
|
||||
错误流程示例:
|
||||
- 仅执行步骤1后直接回答
|
||||
- 步骤2使用time="2023-04-01~2023-04-30"一次性查询
|
||||
- 步骤2仍然保留keyword或sender参数
|
||||
|
||||
【自我检查】回答用户前必须自问:
|
||||
- 我是否对每个关键时间点都执行了独立的上下文查询?
|
||||
- 我是否在上下文查询中移除了keyword和sender参数?
|
||||
- 我是否分析了所有上下文后再回答?
|
||||
- 如果上述任一问题答案为"否",则必须纠正流程
|
||||
|
||||
返回格式:"昵称(ID) 时间\n消息内容\n昵称(ID) 时间\n消息内容"
|
||||
当查询多个Talker时,返回格式为:"昵称(ID)\n[TalkerName(Talker)] 时间\n消息内容"
|
||||
|
||||
重要提示:
|
||||
1. 当用户询问特定时间段内的聊天记录时,必须使用正确的时间格式,特别是包含小时和分钟的查询
|
||||
2. 对于"今天下午4点到5点聊了啥"这类查询,正确的时间参数格式应为"2023-04-18/16:00~2023-04-18/17:00"
|
||||
3. 当用户询问具体群聊中某人的聊天记录时,使用"sender"参数
|
||||
4. 当用户询问包含特定关键词的聊天记录时,使用"keyword"参数`,
|
||||
InputSchema: mcp.ToolSchema{
|
||||
Type: "object",
|
||||
Properties: mcp.M{
|
||||
"time": mcp.M{
|
||||
"type": "string",
|
||||
"description": "查询的时间点或时间段。可以是具体时间,例如 YYYY-MM-DD,也可以是时间段,例如 YYYY-MM-DD~YYYY-MM-DD,时间段之间用\"~\"分隔。",
|
||||
"type": "string",
|
||||
"description": `指定查询的时间点或时间范围,格式必须严格遵循以下规则:
|
||||
|
||||
【单一时间点格式】
|
||||
- 精确到日:"2023-04-18"或"20230418"
|
||||
- 精确到分钟(必须包含斜杠和冒号):"2023-04-18/14:30"或"20230418/14:30"(表示2023年4月18日14点30分)
|
||||
|
||||
【时间范围格式】(使用"~"分隔起止时间)
|
||||
- 日期范围:"2023-04-01~2023-04-18"
|
||||
- 同一天的时间段:"2023-04-18/14:30~2023-04-18/15:45"
|
||||
* 表示2023年4月18日14点30分到15点45分之间
|
||||
|
||||
【重要提示】包含小时分钟的格式必须使用斜杠和冒号:"/"和":"
|
||||
正确示例:"2023-04-18/16:30"(4月18日下午4点30分)
|
||||
错误示例:"2023-04-18 16:30"、"2023-04-18T16:30"
|
||||
|
||||
【其他支持的格式】
|
||||
- 年份:"2023"
|
||||
- 月份:"2023-04"或"202304"`,
|
||||
},
|
||||
"talker": mcp.M{
|
||||
"type": "string",
|
||||
"description": "交谈对象,可以是联系人或群聊。支持使用ID、昵称、备注名等进行查询。",
|
||||
"type": "string",
|
||||
"description": `指定对话方(联系人或群组)
|
||||
- 可使用ID、昵称或备注名
|
||||
- 多个对话方用","分隔,如:"张三,李四,工作群"
|
||||
- 【重要】这是多步查询中唯一应保留的参数`,
|
||||
},
|
||||
"sender": mcp.M{
|
||||
"type": "string",
|
||||
"description": `指定群聊中的发送者
|
||||
- 仅在查询群聊记录时有效
|
||||
- 多个发送者用","分隔,如:"张三,李四"
|
||||
- 可使用ID、昵称或备注名
|
||||
【重要】查询特定发送者的消息时:
|
||||
1. 第一步:使用sender参数初步定位多个相关消息时间点
|
||||
2. 后续步骤:必须移除sender参数,分别查询每个时间点前后的完整对话
|
||||
3. 错误示例:对所有找到的消息一次性查询大范围上下文
|
||||
4. 正确示例:对每个时间点T分别执行查询"T前后15-30分钟"(不带sender)`,
|
||||
},
|
||||
"keyword": mcp.M{
|
||||
"type": "string",
|
||||
"description": `搜索内容中的关键词
|
||||
- 支持正则表达式匹配
|
||||
- 【重要】查询特定话题时:
|
||||
1. 第一步:使用keyword参数初步定位多个相关消息时间点
|
||||
2. 后续步骤:必须移除keyword参数,分别查询每个时间点前后的完整对话
|
||||
3. 错误示例:对所有找到的关键词消息一次性查询大范围上下文
|
||||
4. 正确示例:对每个时间点T分别执行查询"T前后15-30分钟"(不带keyword)`,
|
||||
},
|
||||
},
|
||||
Required: []string{"time", "talker"},
|
||||
},
|
||||
}
|
||||
|
||||
ToolCurrentTime = mcp.Tool{
|
||||
Name: "current_time",
|
||||
Description: `获取当前系统时间,返回RFC3339格式的时间字符串(包含用户本地时区信息)。
|
||||
使用场景:
|
||||
- 当用户询问"总结今日聊天记录"、"本周都聊了啥"等当前时间问题
|
||||
- 当用户提及"昨天"、"上周"、"本月"等相对时间概念,需要确定基准时间点
|
||||
- 需要执行依赖当前时间的计算(如"上个月5号我们有开会吗")
|
||||
返回示例:2025-04-18T21:29:00+08:00
|
||||
注意:此工具不需要任何输入参数,直接调用即可获取当前时间。`,
|
||||
InputSchema: mcp.ToolSchema{
|
||||
Type: "object",
|
||||
Properties: mcp.M{},
|
||||
},
|
||||
}
|
||||
|
||||
ResourceRecentChat = mcp.Resource{
|
||||
Name: "最近会话",
|
||||
URI: "session://recent",
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/chatlog/ctx"
|
||||
"github.com/sjzar/chatlog/internal/chatlog/database"
|
||||
@@ -83,6 +84,7 @@ func (s *Service) processMCP(session *mcp.Session, req *mcp.Request) {
|
||||
ToolChatRoom,
|
||||
ToolRecentChat,
|
||||
ToolChatLog,
|
||||
ToolCurrentTime,
|
||||
}})
|
||||
case mcp.MethodToolsCall:
|
||||
err = s.toolsCall(session, req)
|
||||
@@ -130,13 +132,13 @@ func (s *Service) toolsCall(session *mcp.Session, req *mcp.Request) error {
|
||||
buf := &bytes.Buffer{}
|
||||
switch callReq.Name {
|
||||
case "query_contact":
|
||||
query := ""
|
||||
if v, ok := callReq.Arguments["query"]; ok {
|
||||
query = v.(string)
|
||||
keyword := ""
|
||||
if v, ok := callReq.Arguments["keyword"]; ok {
|
||||
keyword = v.(string)
|
||||
}
|
||||
limit := util.MustAnyToInt(callReq.Arguments["limit"])
|
||||
offset := util.MustAnyToInt(callReq.Arguments["offset"])
|
||||
list, err := s.db.GetContacts(query, limit, offset)
|
||||
list, err := s.db.GetContacts(keyword, limit, offset)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法获取联系人列表: %v", err)
|
||||
}
|
||||
@@ -145,13 +147,13 @@ func (s *Service) toolsCall(session *mcp.Session, req *mcp.Request) error {
|
||||
buf.WriteString(fmt.Sprintf("%s,%s,%s,%s\n", contact.UserName, contact.Alias, contact.Remark, contact.NickName))
|
||||
}
|
||||
case "query_chat_room":
|
||||
query := ""
|
||||
if v, ok := callReq.Arguments["query"]; ok {
|
||||
query = v.(string)
|
||||
keyword := ""
|
||||
if v, ok := callReq.Arguments["keyword"]; ok {
|
||||
keyword = v.(string)
|
||||
}
|
||||
limit := util.MustAnyToInt(callReq.Arguments["limit"])
|
||||
offset := util.MustAnyToInt(callReq.Arguments["offset"])
|
||||
list, err := s.db.GetChatRooms(query, limit, offset)
|
||||
list, err := s.db.GetChatRooms(keyword, limit, offset)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法获取群聊列表: %v", err)
|
||||
}
|
||||
@@ -160,13 +162,13 @@ func (s *Service) toolsCall(session *mcp.Session, req *mcp.Request) error {
|
||||
buf.WriteString(fmt.Sprintf("%s,%s,%s,%s,%d\n", chatRoom.Name, chatRoom.Remark, chatRoom.NickName, chatRoom.Owner, len(chatRoom.Users)))
|
||||
}
|
||||
case "query_recent_chat":
|
||||
query := ""
|
||||
if v, ok := callReq.Arguments["query"]; ok {
|
||||
query = v.(string)
|
||||
keyword := ""
|
||||
if v, ok := callReq.Arguments["keyword"]; ok {
|
||||
keyword = v.(string)
|
||||
}
|
||||
limit := util.MustAnyToInt(callReq.Arguments["limit"])
|
||||
offset := util.MustAnyToInt(callReq.Arguments["offset"])
|
||||
data, err := s.db.GetSessions(query, limit, offset)
|
||||
data, err := s.db.GetSessions(keyword, limit, offset)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法获取会话列表: %v", err)
|
||||
}
|
||||
@@ -190,16 +192,29 @@ func (s *Service) toolsCall(session *mcp.Session, req *mcp.Request) error {
|
||||
if v, ok := callReq.Arguments["talker"]; ok {
|
||||
talker = v.(string)
|
||||
}
|
||||
sender := ""
|
||||
if v, ok := callReq.Arguments["sender"]; ok {
|
||||
sender = v.(string)
|
||||
}
|
||||
keyword := ""
|
||||
if v, ok := callReq.Arguments["keyword"]; ok {
|
||||
keyword = v.(string)
|
||||
}
|
||||
limit := util.MustAnyToInt(callReq.Arguments["limit"])
|
||||
offset := util.MustAnyToInt(callReq.Arguments["offset"])
|
||||
messages, err := s.db.GetMessages(start, end, talker, limit, offset)
|
||||
messages, err := s.db.GetMessages(start, end, talker, sender, keyword, limit, offset)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法获取聊天记录: %v", err)
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
buf.WriteString("未找到符合查询条件的聊天记录")
|
||||
}
|
||||
for _, m := range messages {
|
||||
buf.WriteString(m.PlainText(len(talker) == 0, ""))
|
||||
buf.WriteString(m.PlainText(strings.Contains(talker, ","), util.PerfectTimeFormat(start, end), ""))
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
case "current_time":
|
||||
buf.WriteString(time.Now().Local().Format(time.RFC3339))
|
||||
default:
|
||||
return fmt.Errorf("未支持的工具: %s", callReq.Name)
|
||||
}
|
||||
@@ -228,7 +243,6 @@ func (s *Service) resourcesRead(session *mcp.Session, req *mcp.Request) error {
|
||||
buf := &bytes.Buffer{}
|
||||
switch u.Scheme {
|
||||
case "contact":
|
||||
|
||||
list, err := s.db.GetContacts(u.Host, 0, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法获取联系人列表: %v", err)
|
||||
@@ -262,12 +276,15 @@ func (s *Service) resourcesRead(session *mcp.Session, req *mcp.Request) error {
|
||||
}
|
||||
limit := util.MustAnyToInt(u.Query().Get("limit"))
|
||||
offset := util.MustAnyToInt(u.Query().Get("offset"))
|
||||
messages, err := s.db.GetMessages(start, end, u.Host, limit, offset)
|
||||
messages, err := s.db.GetMessages(start, end, u.Host, "", "", limit, offset)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法获取聊天记录: %v", err)
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
buf.WriteString("未找到符合查询条件的聊天记录")
|
||||
}
|
||||
for _, m := range messages {
|
||||
buf.WriteString(m.PlainText(len(u.Host) == 0, ""))
|
||||
buf.WriteString(m.PlainText(strings.Contains(u.Host, ","), util.PerfectTimeFormat(start, end), ""))
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -65,7 +65,7 @@ func Newf(cause error, code int, format string, args ...interface{}) *Error {
|
||||
return &Error{
|
||||
Message: fmt.Sprintf(format, args...),
|
||||
Cause: cause,
|
||||
Code: http.StatusInternalServerError,
|
||||
Code: code,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package errors
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
@@ -50,7 +51,7 @@ func RecoveryMiddleware() gin.HandlerFunc {
|
||||
}
|
||||
|
||||
// 记录错误日志
|
||||
log.Err(err).Msg("PANIC RECOVERED")
|
||||
log.Err(err).Msgf("PANIC RECOVERED\n%s", string(debug.Stack()))
|
||||
|
||||
// 返回 500 错误
|
||||
c.JSON(http.StatusInternalServerError, err)
|
||||
|
||||
@@ -183,7 +183,11 @@ func (m *Message) SetContent(key string, value interface{}) {
|
||||
m.Contents[key] = value
|
||||
}
|
||||
|
||||
func (m *Message) PlainText(showChatRoom bool, host string) string {
|
||||
func (m *Message) PlainText(showChatRoom bool, timeFormat string, host string) string {
|
||||
|
||||
if timeFormat == "" {
|
||||
timeFormat = "01-02 15:04:05"
|
||||
}
|
||||
|
||||
m.SetContent("host", host)
|
||||
|
||||
@@ -216,7 +220,7 @@ func (m *Message) PlainText(showChatRoom bool, host string) string {
|
||||
buf.WriteString("] ")
|
||||
}
|
||||
|
||||
buf.WriteString(m.Time.Format("2006-01-02 15:04:05"))
|
||||
buf.WriteString(m.Time.Format(timeFormat))
|
||||
buf.WriteString("\n")
|
||||
|
||||
buf.WriteString(m.PlainTextContent())
|
||||
@@ -262,7 +266,11 @@ func (m *Message) PlainTextContent() string {
|
||||
if !ok {
|
||||
return "[合并转发]"
|
||||
}
|
||||
return recordInfo.String("", m.Contents["host"].(string))
|
||||
host := ""
|
||||
if m.Contents["host"] != nil {
|
||||
host = m.Contents["host"].(string)
|
||||
}
|
||||
return recordInfo.String("", host)
|
||||
case 33, 36:
|
||||
if m.Contents["title"] == "" {
|
||||
return "[小程序]"
|
||||
@@ -290,7 +298,11 @@ func (m *Message) PlainTextContent() string {
|
||||
return "> [引用]\n" + m.Content
|
||||
}
|
||||
buf := strings.Builder{}
|
||||
referContent := refer.PlainText(false, m.Contents["host"].(string))
|
||||
host := ""
|
||||
if m.Contents["host"] != nil {
|
||||
host = m.Contents["host"].(string)
|
||||
}
|
||||
referContent := refer.PlainText(false, "", host)
|
||||
for _, line := range strings.Split(referContent, "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
|
||||
@@ -22,7 +22,7 @@ const (
|
||||
var V3KeyPatterns = []KeyPatternInfo{
|
||||
{
|
||||
Pattern: []byte{0x72, 0x74, 0x72, 0x65, 0x65, 0x5f, 0x69, 0x33, 0x32},
|
||||
Offset: 24,
|
||||
Offsets: []int{24},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -122,16 +122,73 @@ func (e *V3Extractor) findMemory(ctx context.Context, pid uint32, memoryChannel
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debug().Msgf("Read memory region, size: %d bytes", len(memory))
|
||||
totalSize := len(memory)
|
||||
log.Debug().Msgf("Read memory region, size: %d bytes", totalSize)
|
||||
|
||||
// Send memory data to channel for processing
|
||||
select {
|
||||
case memoryChannel <- memory:
|
||||
log.Debug().Msg("Memory region sent for analysis")
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
// If memory is small enough, process it as a single chunk
|
||||
if totalSize <= MinChunkSize {
|
||||
select {
|
||||
case memoryChannel <- memory:
|
||||
log.Debug().Msg("Memory sent as a single chunk for analysis")
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
chunkCount := MaxWorkers * ChunkMultiplier
|
||||
|
||||
// Calculate chunk size based on fixed chunk count
|
||||
chunkSize := totalSize / chunkCount
|
||||
if chunkSize < MinChunkSize {
|
||||
// Reduce number of chunks if each would be too small
|
||||
chunkCount = totalSize / MinChunkSize
|
||||
if chunkCount == 0 {
|
||||
chunkCount = 1
|
||||
}
|
||||
chunkSize = totalSize / chunkCount
|
||||
}
|
||||
|
||||
// Process memory in chunks from end to beginning
|
||||
for i := chunkCount - 1; i >= 0; i-- {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
// Calculate start and end positions for this chunk
|
||||
start := i * chunkSize
|
||||
end := (i + 1) * chunkSize
|
||||
|
||||
// Ensure the last chunk includes all remaining memory
|
||||
if i == chunkCount-1 {
|
||||
end = totalSize
|
||||
}
|
||||
|
||||
// Add overlap area to catch patterns at chunk boundaries
|
||||
if i > 0 {
|
||||
start -= ChunkOverlapBytes
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
}
|
||||
|
||||
chunk := memory[start:end]
|
||||
|
||||
log.Debug().
|
||||
Int("chunk_index", i+1).
|
||||
Int("total_chunks", chunkCount).
|
||||
Int("chunk_size", len(chunk)).
|
||||
Int("start_offset", start).
|
||||
Int("end_offset", end).
|
||||
Msg("Processing memory chunk")
|
||||
|
||||
select {
|
||||
case memoryChannel <- chunk:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -173,24 +230,26 @@ func (e *V3Extractor) SearchKey(ctx context.Context, memory []byte) (string, boo
|
||||
break // No more matches found
|
||||
}
|
||||
|
||||
// Check if we have enough space for the key
|
||||
keyOffset := index + keyPattern.Offset
|
||||
if keyOffset < 0 || keyOffset+32 > len(memory) {
|
||||
index -= 1
|
||||
continue
|
||||
}
|
||||
// Try each offset for this pattern
|
||||
for _, offset := range keyPattern.Offsets {
|
||||
// Check if we have enough space for the key
|
||||
keyOffset := index + offset
|
||||
if keyOffset < 0 || keyOffset+32 > len(memory) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract the key data, which is 32 bytes long
|
||||
keyData := memory[keyOffset : keyOffset+32]
|
||||
// Extract the key data, which is at the offset position and 32 bytes long
|
||||
keyData := memory[keyOffset : keyOffset+32]
|
||||
|
||||
// Validate key against database header
|
||||
if e.validator.Validate(keyData) {
|
||||
log.Debug().
|
||||
Str("pattern", hex.EncodeToString(keyPattern.Pattern)).
|
||||
Int("offset", keyPattern.Offset).
|
||||
Str("key", hex.EncodeToString(keyData)).
|
||||
Msg("Key found")
|
||||
return hex.EncodeToString(keyData), true
|
||||
// Validate key against database header
|
||||
if e.validator.Validate(keyData) {
|
||||
log.Debug().
|
||||
Str("pattern", hex.EncodeToString(keyPattern.Pattern)).
|
||||
Int("offset", offset).
|
||||
Str("key", hex.EncodeToString(keyData)).
|
||||
Msg("Key found")
|
||||
return hex.EncodeToString(keyData), true
|
||||
}
|
||||
}
|
||||
|
||||
index -= 1
|
||||
|
||||
@@ -16,17 +16,16 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
MaxWorkers = 8
|
||||
MaxWorkers = 8
|
||||
MinChunkSize = 1 * 1024 * 1024 // 1MB
|
||||
ChunkOverlapBytes = 1024 // Greater than all offsets
|
||||
ChunkMultiplier = 2 // Number of chunks = MaxWorkers * ChunkMultiplier
|
||||
)
|
||||
|
||||
var V4KeyPatterns = []KeyPatternInfo{
|
||||
{
|
||||
Pattern: []byte{0x20, 0x66, 0x74, 0x73, 0x35, 0x28, 0x25, 0x00},
|
||||
Offset: 16,
|
||||
},
|
||||
{
|
||||
Pattern: []byte{0x20, 0x66, 0x74, 0x73, 0x35, 0x28, 0x25, 0x00},
|
||||
Offset: -80,
|
||||
Offsets: []int{16, -80, 64},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -126,14 +125,72 @@ func (e *V4Extractor) findMemory(ctx context.Context, pid uint32, memoryChannel
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debug().Msgf("Read memory region, size: %d bytes", len(memory))
|
||||
totalSize := len(memory)
|
||||
log.Debug().Msgf("Read memory region, size: %d bytes", totalSize)
|
||||
|
||||
// Send memory data to channel for processing
|
||||
select {
|
||||
case memoryChannel <- memory:
|
||||
log.Debug().Msg("Memory region sent for analysis")
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
// If memory is small enough, process it as a single chunk
|
||||
if totalSize <= MinChunkSize {
|
||||
select {
|
||||
case memoryChannel <- memory:
|
||||
log.Debug().Msg("Memory sent as a single chunk for analysis")
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
chunkCount := MaxWorkers * ChunkMultiplier
|
||||
|
||||
// Calculate chunk size based on fixed chunk count
|
||||
chunkSize := totalSize / chunkCount
|
||||
if chunkSize < MinChunkSize {
|
||||
// Reduce number of chunks if each would be too small
|
||||
chunkCount = totalSize / MinChunkSize
|
||||
if chunkCount == 0 {
|
||||
chunkCount = 1
|
||||
}
|
||||
chunkSize = totalSize / chunkCount
|
||||
}
|
||||
|
||||
// Process memory in chunks from end to beginning
|
||||
for i := chunkCount - 1; i >= 0; i-- {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
// Calculate start and end positions for this chunk
|
||||
start := i * chunkSize
|
||||
end := (i + 1) * chunkSize
|
||||
|
||||
// Ensure the last chunk includes all remaining memory
|
||||
if i == chunkCount-1 {
|
||||
end = totalSize
|
||||
}
|
||||
|
||||
// Add overlap area to catch patterns at chunk boundaries
|
||||
if i > 0 {
|
||||
start -= ChunkOverlapBytes
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
}
|
||||
|
||||
chunk := memory[start:end]
|
||||
|
||||
log.Debug().
|
||||
Int("chunk_index", i+1).
|
||||
Int("total_chunks", chunkCount).
|
||||
Int("chunk_size", len(chunk)).
|
||||
Int("start_offset", start).
|
||||
Int("end_offset", end).
|
||||
Msg("Processing memory chunk")
|
||||
|
||||
select {
|
||||
case memoryChannel <- chunk:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -177,24 +234,26 @@ func (e *V4Extractor) SearchKey(ctx context.Context, memory []byte) (string, boo
|
||||
break // No more matches found
|
||||
}
|
||||
|
||||
// Check if we have enough space for the key
|
||||
keyOffset := index + keyPattern.Offset
|
||||
if keyOffset < 0 || keyOffset+32 > len(memory) {
|
||||
index -= 1
|
||||
continue
|
||||
}
|
||||
// Try each offset for this pattern
|
||||
for _, offset := range keyPattern.Offsets {
|
||||
// Check if we have enough space for the key
|
||||
keyOffset := index + offset
|
||||
if keyOffset < 0 || keyOffset+32 > len(memory) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract the key data, which is 16 bytes after the pattern and 32 bytes long
|
||||
keyData := memory[keyOffset : keyOffset+32]
|
||||
// Extract the key data, which is at the offset position and 32 bytes long
|
||||
keyData := memory[keyOffset : keyOffset+32]
|
||||
|
||||
// Validate key against database header
|
||||
if e.validator.Validate(keyData) {
|
||||
log.Debug().
|
||||
Str("pattern", hex.EncodeToString(keyPattern.Pattern)).
|
||||
Int("offset", keyPattern.Offset).
|
||||
Str("key", hex.EncodeToString(keyData)).
|
||||
Msg("Key found")
|
||||
return hex.EncodeToString(keyData), true
|
||||
// Validate key against database header
|
||||
if keyData, ok := e.validate(ctx, keyData); ok {
|
||||
log.Debug().
|
||||
Str("pattern", hex.EncodeToString(keyPattern.Pattern)).
|
||||
Int("offset", offset).
|
||||
Str("key", hex.EncodeToString(keyData)).
|
||||
Msg("Key found")
|
||||
return hex.EncodeToString(keyData), true
|
||||
}
|
||||
}
|
||||
|
||||
index -= 1
|
||||
@@ -204,11 +263,19 @@ func (e *V4Extractor) SearchKey(ctx context.Context, memory []byte) (string, boo
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (e *V4Extractor) validate(ctx context.Context, keyDate []byte) ([]byte, bool) {
|
||||
if e.validator.Validate(keyDate) {
|
||||
return keyDate, true
|
||||
}
|
||||
// Try to find a valid key by ***
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (e *V4Extractor) SetValidate(validator *decrypt.Validator) {
|
||||
e.validator = validator
|
||||
}
|
||||
|
||||
type KeyPatternInfo struct {
|
||||
Pattern []byte
|
||||
Offset int
|
||||
Offsets []int
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
const (
|
||||
V3ProcessName = "WeChat"
|
||||
V4ProcessName = "Weixin"
|
||||
V3DBFile = "Msg\\Misc.db"
|
||||
V4DBFile = "db_storage\\message\\message_0.db"
|
||||
V3DBFile = `Msg\Misc.db`
|
||||
V4DBFile = `db_storage\session\session.db`
|
||||
)
|
||||
|
||||
// Detector 实现 Windows 平台的进程检测器
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +17,7 @@ import (
|
||||
"github.com/sjzar/chatlog/internal/errors"
|
||||
"github.com/sjzar/chatlog/internal/model"
|
||||
"github.com/sjzar/chatlog/internal/wechatdb/datasource/dbm"
|
||||
"github.com/sjzar/chatlog/pkg/util"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -25,7 +28,7 @@ const (
|
||||
Media = "media"
|
||||
)
|
||||
|
||||
var Groups = []dbm.Group{
|
||||
var Groups = []*dbm.Group{
|
||||
{
|
||||
Name: Message,
|
||||
Pattern: `^msg_([0-9]?[0-9])?\.db$`,
|
||||
@@ -114,6 +117,10 @@ func (ds *DataSource) initMessageDbs() error {
|
||||
|
||||
dbPaths, err := ds.dbm.GetDBPath(Message)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "db file not found") {
|
||||
ds.talkerDBMap = make(map[string]string)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
// 处理每个数据库文件
|
||||
@@ -155,6 +162,10 @@ func (ds *DataSource) initMessageDbs() error {
|
||||
func (ds *DataSource) initChatRoomDb() error {
|
||||
db, err := ds.dbm.GetDB(ChatRoom)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "db file not found") {
|
||||
ds.user2DisplayName = make(map[string]string)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -180,70 +191,162 @@ func (ds *DataSource) initChatRoomDb() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMessages 实现获取消息的方法
|
||||
func (ds *DataSource) GetMessages(ctx context.Context, startTime, endTime time.Time, talker string, limit, offset int) ([]*model.Message, error) {
|
||||
// 在 darwinv3 中,每个联系人/群聊的消息存储在单独的表中,表名为 Chat_md5(talker)
|
||||
// 首先需要找到对应的表名
|
||||
func (ds *DataSource) GetMessages(ctx context.Context, startTime, endTime time.Time, talker string, sender string, keyword string, limit, offset int) ([]*model.Message, error) {
|
||||
if talker == "" {
|
||||
return nil, errors.ErrTalkerEmpty
|
||||
}
|
||||
|
||||
_talkerMd5Bytes := md5.Sum([]byte(talker))
|
||||
talkerMd5 := hex.EncodeToString(_talkerMd5Bytes[:])
|
||||
dbPath, ok := ds.talkerDBMap[talkerMd5]
|
||||
if !ok {
|
||||
return nil, errors.TalkerNotFound(talker)
|
||||
// 解析talker参数,支持多个talker(以英文逗号分隔)
|
||||
talkers := util.Str2List(talker, ",")
|
||||
if len(talkers) == 0 {
|
||||
return nil, errors.ErrTalkerEmpty
|
||||
}
|
||||
db, err := ds.dbm.OpenDB(dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tableName := fmt.Sprintf("Chat_%s", talkerMd5)
|
||||
|
||||
// 构建查询条件
|
||||
query := fmt.Sprintf(`
|
||||
SELECT msgCreateTime, msgContent, messageType, mesDes
|
||||
FROM %s
|
||||
WHERE msgCreateTime >= ? AND msgCreateTime <= ?
|
||||
ORDER BY msgCreateTime ASC
|
||||
`, tableName)
|
||||
// 解析sender参数,支持多个发送者(以英文逗号分隔)
|
||||
senders := util.Str2List(sender, ",")
|
||||
|
||||
if limit > 0 {
|
||||
query += fmt.Sprintf(" LIMIT %d", limit)
|
||||
|
||||
if offset > 0 {
|
||||
query += fmt.Sprintf(" OFFSET %d", offset)
|
||||
// 预编译正则表达式(如果有keyword)
|
||||
var regex *regexp.Regexp
|
||||
if keyword != "" {
|
||||
var err error
|
||||
regex, err = regexp.Compile(keyword)
|
||||
if err != nil {
|
||||
return nil, errors.QueryFailed("invalid regex pattern", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
rows, err := db.QueryContext(ctx, query, startTime.Unix(), endTime.Unix())
|
||||
if err != nil {
|
||||
return nil, errors.QueryFailed(query, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
// 从每个相关数据库中查询消息,并在读取时进行过滤
|
||||
filteredMessages := []*model.Message{}
|
||||
|
||||
// 处理查询结果
|
||||
messages := []*model.Message{}
|
||||
for rows.Next() {
|
||||
var msg model.MessageDarwinV3
|
||||
err := rows.Scan(
|
||||
&msg.MsgCreateTime,
|
||||
&msg.MsgContent,
|
||||
&msg.MessageType,
|
||||
&msg.MesDes,
|
||||
)
|
||||
if err != nil {
|
||||
log.Err(err).Msgf("扫描消息行失败")
|
||||
// 对每个talker进行查询
|
||||
for _, talkerItem := range talkers {
|
||||
// 检查上下文是否已取消
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 在 darwinv3 中,需要先找到对应的数据库
|
||||
_talkerMd5Bytes := md5.Sum([]byte(talkerItem))
|
||||
talkerMd5 := hex.EncodeToString(_talkerMd5Bytes[:])
|
||||
dbPath, ok := ds.talkerDBMap[talkerMd5]
|
||||
if !ok {
|
||||
// 如果找不到对应的数据库,跳过此talker
|
||||
continue
|
||||
}
|
||||
|
||||
// 将消息包装为通用模型
|
||||
message := msg.Wrap(talker)
|
||||
messages = append(messages, message)
|
||||
db, err := ds.dbm.OpenDB(dbPath)
|
||||
if err != nil {
|
||||
log.Error().Msgf("数据库 %s 未打开", dbPath)
|
||||
continue
|
||||
}
|
||||
|
||||
tableName := fmt.Sprintf("Chat_%s", talkerMd5)
|
||||
|
||||
// 构建查询条件
|
||||
query := fmt.Sprintf(`
|
||||
SELECT msgCreateTime, msgContent, messageType, mesDes
|
||||
FROM %s
|
||||
WHERE msgCreateTime >= ? AND msgCreateTime <= ?
|
||||
ORDER BY msgCreateTime ASC
|
||||
`, tableName)
|
||||
|
||||
// 执行查询
|
||||
rows, err := db.QueryContext(ctx, query, startTime.Unix(), endTime.Unix())
|
||||
if err != nil {
|
||||
// 如果表不存在,跳过此talker
|
||||
if strings.Contains(err.Error(), "no such table") {
|
||||
continue
|
||||
}
|
||||
log.Err(err).Msgf("从数据库 %s 查询消息失败", dbPath)
|
||||
continue
|
||||
}
|
||||
|
||||
// 处理查询结果,在读取时进行过滤
|
||||
for rows.Next() {
|
||||
var msg model.MessageDarwinV3
|
||||
err := rows.Scan(
|
||||
&msg.MsgCreateTime,
|
||||
&msg.MsgContent,
|
||||
&msg.MessageType,
|
||||
&msg.MesDes,
|
||||
)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
log.Err(err).Msgf("扫描消息行失败")
|
||||
continue
|
||||
}
|
||||
|
||||
// 将消息包装为通用模型
|
||||
message := msg.Wrap(talkerItem)
|
||||
|
||||
// 应用sender过滤
|
||||
if len(senders) > 0 {
|
||||
senderMatch := false
|
||||
for _, s := range senders {
|
||||
if message.Sender == s {
|
||||
senderMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !senderMatch {
|
||||
continue // 不匹配sender,跳过此消息
|
||||
}
|
||||
}
|
||||
|
||||
// 应用keyword过滤
|
||||
if regex != nil {
|
||||
plainText := message.PlainTextContent()
|
||||
if !regex.MatchString(plainText) {
|
||||
continue // 不匹配keyword,跳过此消息
|
||||
}
|
||||
}
|
||||
|
||||
// 通过所有过滤条件,保留此消息
|
||||
filteredMessages = append(filteredMessages, message)
|
||||
|
||||
// 检查是否已经满足分页处理数量
|
||||
if limit > 0 && len(filteredMessages) >= offset+limit {
|
||||
// 已经获取了足够的消息,可以提前返回
|
||||
rows.Close()
|
||||
|
||||
// 对所有消息按时间排序
|
||||
sort.Slice(filteredMessages, func(i, j int) bool {
|
||||
return filteredMessages[i].Seq < filteredMessages[j].Seq
|
||||
})
|
||||
|
||||
// 处理分页
|
||||
if offset >= len(filteredMessages) {
|
||||
return []*model.Message{}, nil
|
||||
}
|
||||
end := offset + limit
|
||||
if end > len(filteredMessages) {
|
||||
end = len(filteredMessages)
|
||||
}
|
||||
return filteredMessages[offset:end], nil
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
// 对所有消息按时间排序
|
||||
// FIXME 不同 talker 需要使用 Time 排序
|
||||
sort.Slice(filteredMessages, func(i, j int) bool {
|
||||
return filteredMessages[i].Time.Before(filteredMessages[j].Time)
|
||||
})
|
||||
|
||||
// 处理分页
|
||||
if limit > 0 {
|
||||
if offset >= len(filteredMessages) {
|
||||
return []*model.Message{}, nil
|
||||
}
|
||||
end := offset + limit
|
||||
if end > len(filteredMessages) {
|
||||
end = len(filteredMessages)
|
||||
}
|
||||
return filteredMessages[offset:end], nil
|
||||
}
|
||||
|
||||
return filteredMessages, nil
|
||||
}
|
||||
|
||||
// 从表名中提取 talker
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
type DataSource interface {
|
||||
|
||||
// 消息
|
||||
GetMessages(ctx context.Context, startTime, endTime time.Time, talker string, limit, offset int) ([]*model.Message, error)
|
||||
GetMessages(ctx context.Context, startTime, endTime time.Time, talker string, sender string, keyword string, limit, offset int) ([]*model.Message, error)
|
||||
|
||||
// 联系人
|
||||
GetContacts(ctx context.Context, key string, limit, offset int) ([]*model.Contact, error)
|
||||
|
||||
@@ -34,7 +34,7 @@ func NewDBManager(path string) *DBManager {
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DBManager) AddGroup(g Group) error {
|
||||
func (d *DBManager) AddGroup(g *Group) error {
|
||||
fg, err := filemonitor.NewFileGroup(g.Name, d.path, g.Pattern, g.BlackList)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
func TestXxx(t *testing.T) {
|
||||
path := "/Users/sarv/Documents/chatlog/bigjun_9e7a"
|
||||
|
||||
g := Group{
|
||||
g := &Group{
|
||||
Name: "session",
|
||||
Pattern: `session\.db$`,
|
||||
BlackList: []string{},
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
"github.com/sjzar/chatlog/internal/errors"
|
||||
"github.com/sjzar/chatlog/internal/model"
|
||||
"github.com/sjzar/chatlog/internal/wechatdb/datasource/dbm"
|
||||
"github.com/sjzar/chatlog/pkg/util"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -27,7 +29,7 @@ const (
|
||||
Voice = "voice"
|
||||
)
|
||||
|
||||
var Groups = []dbm.Group{
|
||||
var Groups = []*dbm.Group{
|
||||
{
|
||||
Name: Message,
|
||||
Pattern: `^message_([0-9]?[0-9])?\.db$`,
|
||||
@@ -113,6 +115,10 @@ func (ds *DataSource) SetCallback(name string, callback func(event fsnotify.Even
|
||||
func (ds *DataSource) initMessageDbs() error {
|
||||
dbPaths, err := ds.dbm.GetDBPath(Message)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "db file not found") {
|
||||
ds.messageInfos = make([]MessageDBInfo, 0)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -171,11 +177,16 @@ func (ds *DataSource) getDBInfosForTimeRange(startTime, endTime time.Time) []Mes
|
||||
return dbs
|
||||
}
|
||||
|
||||
func (ds *DataSource) GetMessages(ctx context.Context, startTime, endTime time.Time, talker string, limit, offset int) ([]*model.Message, error) {
|
||||
func (ds *DataSource) GetMessages(ctx context.Context, startTime, endTime time.Time, talker string, sender string, keyword string, limit, offset int) ([]*model.Message, error) {
|
||||
if talker == "" {
|
||||
return nil, errors.ErrTalkerEmpty
|
||||
}
|
||||
log.Debug().Msg(talker)
|
||||
|
||||
// 解析talker参数,支持多个talker(以英文逗号分隔)
|
||||
talkers := util.Str2List(talker, ",")
|
||||
if len(talkers) == 0 {
|
||||
return nil, errors.ErrTalkerEmpty
|
||||
}
|
||||
|
||||
// 找到时间范围内的数据库文件
|
||||
dbInfos := ds.getDBInfosForTimeRange(startTime, endTime)
|
||||
@@ -183,13 +194,21 @@ func (ds *DataSource) GetMessages(ctx context.Context, startTime, endTime time.T
|
||||
return nil, errors.TimeRangeNotFound(startTime, endTime)
|
||||
}
|
||||
|
||||
if len(dbInfos) == 1 {
|
||||
// LIMIT 和 OFFSET 逻辑在单文件情况下可以直接在 SQL 里处理
|
||||
return ds.getMessagesSingleFile(ctx, dbInfos[0], startTime, endTime, talker, limit, offset)
|
||||
// 解析sender参数,支持多个发送者(以英文逗号分隔)
|
||||
senders := util.Str2List(sender, ",")
|
||||
|
||||
// 预编译正则表达式(如果有keyword)
|
||||
var regex *regexp.Regexp
|
||||
if keyword != "" {
|
||||
var err error
|
||||
regex, err = regexp.Compile(keyword)
|
||||
if err != nil {
|
||||
return nil, errors.QueryFailed("invalid regex pattern", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 从每个相关数据库中查询消息
|
||||
totalMessages := []*model.Message{}
|
||||
// 从每个相关数据库中查询消息,并在读取时进行过滤
|
||||
filteredMessages := []*model.Message{}
|
||||
|
||||
for _, dbInfo := range dbInfos {
|
||||
// 检查上下文是否已取消
|
||||
@@ -203,183 +222,141 @@ func (ds *DataSource) GetMessages(ctx context.Context, startTime, endTime time.T
|
||||
continue
|
||||
}
|
||||
|
||||
messages, err := ds.getMessagesFromDB(ctx, db, startTime, endTime, talker)
|
||||
if err != nil {
|
||||
log.Err(err).Msgf("从数据库 %s 获取消息失败", dbInfo.FilePath)
|
||||
continue
|
||||
}
|
||||
// 对每个talker进行查询
|
||||
for _, talkerItem := range talkers {
|
||||
// 构建表名
|
||||
_talkerMd5Bytes := md5.Sum([]byte(talkerItem))
|
||||
talkerMd5 := hex.EncodeToString(_talkerMd5Bytes[:])
|
||||
tableName := "Msg_" + talkerMd5
|
||||
|
||||
totalMessages = append(totalMessages, messages...)
|
||||
// 检查表是否存在
|
||||
var exists bool
|
||||
err = db.QueryRowContext(ctx,
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
|
||||
tableName).Scan(&exists)
|
||||
|
||||
if limit+offset > 0 && len(totalMessages) >= limit+offset {
|
||||
break
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
// 表不存在,继续下一个talker
|
||||
continue
|
||||
}
|
||||
return nil, errors.QueryFailed("", err)
|
||||
}
|
||||
|
||||
// 构建查询条件
|
||||
conditions := []string{"create_time >= ? AND create_time <= ?"}
|
||||
args := []interface{}{startTime.Unix(), endTime.Unix()}
|
||||
log.Debug().Msgf("Table name: %s", tableName)
|
||||
log.Debug().Msgf("Start time: %d, End time: %d", startTime.Unix(), endTime.Unix())
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT m.sort_seq, m.server_id, m.local_type, n.user_name, m.create_time, m.message_content, m.packed_info_data, m.status
|
||||
FROM %s m
|
||||
LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid
|
||||
WHERE %s
|
||||
ORDER BY m.sort_seq ASC
|
||||
`, tableName, strings.Join(conditions, " AND "))
|
||||
|
||||
// 执行查询
|
||||
rows, err := db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
// 如果表不存在,SQLite 会返回错误
|
||||
if strings.Contains(err.Error(), "no such table") {
|
||||
continue
|
||||
}
|
||||
log.Err(err).Msgf("从数据库 %s 查询消息失败", dbInfo.FilePath)
|
||||
continue
|
||||
}
|
||||
|
||||
// 处理查询结果,在读取时进行过滤
|
||||
for rows.Next() {
|
||||
var msg model.MessageV4
|
||||
err := rows.Scan(
|
||||
&msg.SortSeq,
|
||||
&msg.ServerID,
|
||||
&msg.LocalType,
|
||||
&msg.UserName,
|
||||
&msg.CreateTime,
|
||||
&msg.MessageContent,
|
||||
&msg.PackedInfoData,
|
||||
&msg.Status,
|
||||
)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, errors.ScanRowFailed(err)
|
||||
}
|
||||
|
||||
// 将消息转换为标准格式
|
||||
message := msg.Wrap(talkerItem)
|
||||
|
||||
// 应用sender过滤
|
||||
if len(senders) > 0 {
|
||||
senderMatch := false
|
||||
for _, s := range senders {
|
||||
if message.Sender == s {
|
||||
senderMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !senderMatch {
|
||||
continue // 不匹配sender,跳过此消息
|
||||
}
|
||||
}
|
||||
|
||||
// 应用keyword过滤
|
||||
if regex != nil {
|
||||
plainText := message.PlainTextContent()
|
||||
if !regex.MatchString(plainText) {
|
||||
continue // 不匹配keyword,跳过此消息
|
||||
}
|
||||
}
|
||||
|
||||
// 通过所有过滤条件,保留此消息
|
||||
filteredMessages = append(filteredMessages, message)
|
||||
|
||||
// 检查是否已经满足分页处理数量
|
||||
if limit > 0 && len(filteredMessages) >= offset+limit {
|
||||
// 已经获取了足够的消息,可以提前返回
|
||||
rows.Close()
|
||||
|
||||
// 对所有消息按时间排序
|
||||
sort.Slice(filteredMessages, func(i, j int) bool {
|
||||
return filteredMessages[i].Seq < filteredMessages[j].Seq
|
||||
})
|
||||
|
||||
// 处理分页
|
||||
if offset >= len(filteredMessages) {
|
||||
return []*model.Message{}, nil
|
||||
}
|
||||
end := offset + limit
|
||||
if end > len(filteredMessages) {
|
||||
end = len(filteredMessages)
|
||||
}
|
||||
return filteredMessages[offset:end], nil
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// 对所有消息按时间排序
|
||||
sort.Slice(totalMessages, func(i, j int) bool {
|
||||
return totalMessages[i].Seq < totalMessages[j].Seq
|
||||
sort.Slice(filteredMessages, func(i, j int) bool {
|
||||
return filteredMessages[i].Seq < filteredMessages[j].Seq
|
||||
})
|
||||
|
||||
// 处理分页
|
||||
if limit > 0 {
|
||||
if offset >= len(totalMessages) {
|
||||
if offset >= len(filteredMessages) {
|
||||
return []*model.Message{}, nil
|
||||
}
|
||||
end := offset + limit
|
||||
if end > len(totalMessages) {
|
||||
end = len(totalMessages)
|
||||
if end > len(filteredMessages) {
|
||||
end = len(filteredMessages)
|
||||
}
|
||||
return totalMessages[offset:end], nil
|
||||
return filteredMessages[offset:end], nil
|
||||
}
|
||||
|
||||
return totalMessages, nil
|
||||
}
|
||||
|
||||
// getMessagesSingleFile 从单个数据库文件获取消息
|
||||
func (ds *DataSource) getMessagesSingleFile(ctx context.Context, dbInfo MessageDBInfo, startTime, endTime time.Time, talker string, limit, offset int) ([]*model.Message, error) {
|
||||
db, err := ds.dbm.OpenDB(dbInfo.FilePath)
|
||||
if err != nil {
|
||||
return nil, errors.DBConnectFailed(dbInfo.FilePath, nil)
|
||||
}
|
||||
|
||||
// 构建表名
|
||||
_talkerMd5Bytes := md5.Sum([]byte(talker))
|
||||
talkerMd5 := hex.EncodeToString(_talkerMd5Bytes[:])
|
||||
tableName := "Msg_" + talkerMd5
|
||||
|
||||
// 检查表是否存在
|
||||
var exists bool
|
||||
err = db.QueryRowContext(ctx,
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
|
||||
tableName).Scan(&exists)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
// 表不存在,返回空结果
|
||||
return []*model.Message{}, nil
|
||||
}
|
||||
return nil, errors.QueryFailed("", err)
|
||||
}
|
||||
|
||||
// 构建查询条件
|
||||
conditions := []string{"create_time >= ? AND create_time <= ?"}
|
||||
args := []interface{}{startTime.Unix(), endTime.Unix()}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT m.sort_seq, m.server_id, m.local_type, n.user_name, m.create_time, m.message_content, m.packed_info_data, m.status
|
||||
FROM %s m
|
||||
LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid
|
||||
WHERE %s
|
||||
ORDER BY m.sort_seq ASC
|
||||
`, tableName, strings.Join(conditions, " AND "))
|
||||
|
||||
if limit > 0 {
|
||||
query += fmt.Sprintf(" LIMIT %d", limit)
|
||||
if offset > 0 {
|
||||
query += fmt.Sprintf(" OFFSET %d", offset)
|
||||
}
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
rows, err := db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, errors.QueryFailed(query, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// 处理查询结果
|
||||
messages := []*model.Message{}
|
||||
|
||||
for rows.Next() {
|
||||
var msg model.MessageV4
|
||||
err := rows.Scan(
|
||||
&msg.SortSeq,
|
||||
&msg.ServerID,
|
||||
&msg.LocalType,
|
||||
&msg.UserName,
|
||||
&msg.CreateTime,
|
||||
&msg.MessageContent,
|
||||
&msg.PackedInfoData,
|
||||
&msg.Status,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.ScanRowFailed(err)
|
||||
}
|
||||
|
||||
messages = append(messages, msg.Wrap(talker))
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// getMessagesFromDB 从数据库获取消息
|
||||
func (ds *DataSource) getMessagesFromDB(ctx context.Context, db *sql.DB, startTime, endTime time.Time, talker string) ([]*model.Message, error) {
|
||||
// 构建表名
|
||||
_talkerMd5Bytes := md5.Sum([]byte(talker))
|
||||
talkerMd5 := hex.EncodeToString(_talkerMd5Bytes[:])
|
||||
tableName := "Msg_" + talkerMd5
|
||||
|
||||
// 检查表是否存在
|
||||
var exists bool
|
||||
err := db.QueryRowContext(ctx,
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
|
||||
tableName).Scan(&exists)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
// 表不存在,返回空结果
|
||||
return []*model.Message{}, nil
|
||||
}
|
||||
return nil, errors.QueryFailed("", err)
|
||||
}
|
||||
|
||||
// 构建查询条件
|
||||
conditions := []string{"create_time >= ? AND create_time <= ?"}
|
||||
args := []interface{}{startTime.Unix(), endTime.Unix()}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT m.sort_seq, m.server_id, m.local_type, n.user_name, m.create_time, m.message_content, m.packed_info_data, m.status
|
||||
FROM %s m
|
||||
LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid
|
||||
WHERE %s
|
||||
ORDER BY m.sort_seq ASC
|
||||
`, tableName, strings.Join(conditions, " AND "))
|
||||
|
||||
// 执行查询
|
||||
rows, err := db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
// 如果表不存在,SQLite 会返回错误
|
||||
if strings.Contains(err.Error(), "no such table") {
|
||||
return []*model.Message{}, nil
|
||||
}
|
||||
return nil, errors.QueryFailed(query, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// 处理查询结果
|
||||
messages := []*model.Message{}
|
||||
|
||||
for rows.Next() {
|
||||
var msg model.MessageV4
|
||||
err := rows.Scan(
|
||||
&msg.SortSeq,
|
||||
&msg.ServerID,
|
||||
&msg.LocalType,
|
||||
&msg.UserName,
|
||||
&msg.CreateTime,
|
||||
&msg.MessageContent,
|
||||
&msg.PackedInfoData,
|
||||
&msg.Status,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.ScanRowFailed(err)
|
||||
}
|
||||
|
||||
messages = append(messages, msg.Wrap(talker))
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
return filteredMessages, nil
|
||||
}
|
||||
|
||||
// 联系人
|
||||
|
||||
@@ -2,9 +2,9 @@ package windowsv3
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/sjzar/chatlog/internal/errors"
|
||||
"github.com/sjzar/chatlog/internal/model"
|
||||
"github.com/sjzar/chatlog/internal/wechatdb/datasource/dbm"
|
||||
"github.com/sjzar/chatlog/pkg/util"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -27,7 +28,7 @@ const (
|
||||
Voice = "voice"
|
||||
)
|
||||
|
||||
var Groups = []dbm.Group{
|
||||
var Groups = []*dbm.Group{
|
||||
{
|
||||
Name: Message,
|
||||
Pattern: `^MSG([0-9]?[0-9])?\.db$`,
|
||||
@@ -35,7 +36,7 @@ var Groups = []dbm.Group{
|
||||
},
|
||||
{
|
||||
Name: Contact,
|
||||
Pattern: `^MicroMsg.db$`,
|
||||
Pattern: `^MicroMsg\.db$`,
|
||||
BlackList: []string{},
|
||||
},
|
||||
{
|
||||
@@ -122,6 +123,10 @@ func (ds *DataSource) initMessageDbs() error {
|
||||
// 获取所有消息数据库文件路径
|
||||
dbPaths, err := ds.dbm.GetDBPath(Message)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "db file not found") {
|
||||
ds.messageInfos = make([]MessageDBInfo, 0)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -217,21 +222,38 @@ func (ds *DataSource) getDBInfosForTimeRange(startTime, endTime time.Time) []Mes
|
||||
return dbs
|
||||
}
|
||||
|
||||
// GetMessages 实现 DataSource 接口的 GetMessages 方法
|
||||
func (ds *DataSource) GetMessages(ctx context.Context, startTime, endTime time.Time, talker string, limit, offset int) ([]*model.Message, error) {
|
||||
func (ds *DataSource) GetMessages(ctx context.Context, startTime, endTime time.Time, talker string, sender string, keyword string, limit, offset int) ([]*model.Message, error) {
|
||||
if talker == "" {
|
||||
return nil, errors.ErrTalkerEmpty
|
||||
}
|
||||
|
||||
// 解析talker参数,支持多个talker(以英文逗号分隔)
|
||||
talkers := util.Str2List(talker, ",")
|
||||
if len(talkers) == 0 {
|
||||
return nil, errors.ErrTalkerEmpty
|
||||
}
|
||||
|
||||
// 找到时间范围内的数据库文件
|
||||
dbInfos := ds.getDBInfosForTimeRange(startTime, endTime)
|
||||
if len(dbInfos) == 0 {
|
||||
return nil, errors.TimeRangeNotFound(startTime, endTime)
|
||||
}
|
||||
|
||||
if len(dbInfos) == 1 {
|
||||
// LIMIT 和 OFFSET 逻辑在单文件情况下可以直接在 SQL 里处理
|
||||
return ds.getMessagesSingleFile(ctx, dbInfos[0], startTime, endTime, talker, limit, offset)
|
||||
// 解析sender参数,支持多个发送者(以英文逗号分隔)
|
||||
senders := util.Str2List(sender, ",")
|
||||
|
||||
// 预编译正则表达式(如果有keyword)
|
||||
var regex *regexp.Regexp
|
||||
if keyword != "" {
|
||||
var err error
|
||||
regex, err = regexp.Compile(keyword)
|
||||
if err != nil {
|
||||
return nil, errors.QueryFailed("invalid regex pattern", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 从每个相关数据库中查询消息
|
||||
totalMessages := []*model.Message{}
|
||||
filteredMessages := []*model.Message{}
|
||||
|
||||
for _, dbInfo := range dbInfos {
|
||||
// 检查上下文是否已取消
|
||||
@@ -245,172 +267,137 @@ func (ds *DataSource) GetMessages(ctx context.Context, startTime, endTime time.T
|
||||
continue
|
||||
}
|
||||
|
||||
messages, err := ds.getMessagesFromDB(ctx, db, dbInfo, startTime, endTime, talker)
|
||||
if err != nil {
|
||||
log.Err(err).Msgf("从数据库 %s 获取消息失败", dbInfo.FilePath)
|
||||
continue
|
||||
}
|
||||
// 对每个talker进行查询
|
||||
for _, talkerItem := range talkers {
|
||||
// 构建查询条件
|
||||
conditions := []string{"Sequence >= ? AND Sequence <= ?"}
|
||||
args := []interface{}{startTime.Unix() * 1000, endTime.Unix() * 1000}
|
||||
|
||||
totalMessages = append(totalMessages, messages...)
|
||||
// 添加talker条件
|
||||
talkerID, ok := dbInfo.TalkerMap[talkerItem]
|
||||
if ok {
|
||||
conditions = append(conditions, "TalkerId = ?")
|
||||
args = append(args, talkerID)
|
||||
} else {
|
||||
conditions = append(conditions, "StrTalker = ?")
|
||||
args = append(args, talkerItem)
|
||||
}
|
||||
|
||||
if limit+offset > 0 && len(totalMessages) >= limit+offset {
|
||||
break
|
||||
query := fmt.Sprintf(`
|
||||
SELECT MsgSvrID, Sequence, CreateTime, StrTalker, IsSender,
|
||||
Type, SubType, StrContent, CompressContent, BytesExtra
|
||||
FROM MSG
|
||||
WHERE %s
|
||||
ORDER BY Sequence ASC
|
||||
`, strings.Join(conditions, " AND "))
|
||||
|
||||
// 执行查询
|
||||
rows, err := db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
// 如果表不存在,跳过此talker
|
||||
if strings.Contains(err.Error(), "no such table") {
|
||||
continue
|
||||
}
|
||||
log.Err(err).Msgf("从数据库 %s 查询消息失败", dbInfo.FilePath)
|
||||
continue
|
||||
}
|
||||
|
||||
// 处理查询结果,在读取时进行过滤
|
||||
for rows.Next() {
|
||||
var msg model.MessageV3
|
||||
var compressContent []byte
|
||||
var bytesExtra []byte
|
||||
|
||||
err := rows.Scan(
|
||||
&msg.MsgSvrID,
|
||||
&msg.Sequence,
|
||||
&msg.CreateTime,
|
||||
&msg.StrTalker,
|
||||
&msg.IsSender,
|
||||
&msg.Type,
|
||||
&msg.SubType,
|
||||
&msg.StrContent,
|
||||
&compressContent,
|
||||
&bytesExtra,
|
||||
)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, errors.ScanRowFailed(err)
|
||||
}
|
||||
msg.CompressContent = compressContent
|
||||
msg.BytesExtra = bytesExtra
|
||||
|
||||
// 将消息转换为标准格式
|
||||
message := msg.Wrap()
|
||||
|
||||
// 应用sender过滤
|
||||
if len(senders) > 0 {
|
||||
senderMatch := false
|
||||
for _, s := range senders {
|
||||
if message.Sender == s {
|
||||
senderMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !senderMatch {
|
||||
continue // 不匹配sender,跳过此消息
|
||||
}
|
||||
}
|
||||
|
||||
// 应用keyword过滤
|
||||
if regex != nil {
|
||||
plainText := message.PlainTextContent()
|
||||
if !regex.MatchString(plainText) {
|
||||
continue // 不匹配keyword,跳过此消息
|
||||
}
|
||||
}
|
||||
|
||||
// 通过所有过滤条件,保留此消息
|
||||
filteredMessages = append(filteredMessages, message)
|
||||
|
||||
// 检查是否已经满足分页处理数量
|
||||
if limit > 0 && len(filteredMessages) >= offset+limit {
|
||||
// 已经获取了足够的消息,可以提前返回
|
||||
rows.Close()
|
||||
|
||||
// 对所有消息按时间排序
|
||||
sort.Slice(filteredMessages, func(i, j int) bool {
|
||||
return filteredMessages[i].Seq < filteredMessages[j].Seq
|
||||
})
|
||||
|
||||
// 处理分页
|
||||
if offset >= len(filteredMessages) {
|
||||
return []*model.Message{}, nil
|
||||
}
|
||||
end := offset + limit
|
||||
if end > len(filteredMessages) {
|
||||
end = len(filteredMessages)
|
||||
}
|
||||
return filteredMessages[offset:end], nil
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// 对所有消息按时间排序
|
||||
sort.Slice(totalMessages, func(i, j int) bool {
|
||||
return totalMessages[i].Seq < totalMessages[j].Seq
|
||||
sort.Slice(filteredMessages, func(i, j int) bool {
|
||||
return filteredMessages[i].Seq < filteredMessages[j].Seq
|
||||
})
|
||||
|
||||
// 处理分页
|
||||
if limit > 0 {
|
||||
if offset >= len(totalMessages) {
|
||||
if offset >= len(filteredMessages) {
|
||||
return []*model.Message{}, nil
|
||||
}
|
||||
end := offset + limit
|
||||
if end > len(totalMessages) {
|
||||
end = len(totalMessages)
|
||||
if end > len(filteredMessages) {
|
||||
end = len(filteredMessages)
|
||||
}
|
||||
return totalMessages[offset:end], nil
|
||||
return filteredMessages[offset:end], nil
|
||||
}
|
||||
|
||||
return totalMessages, nil
|
||||
}
|
||||
|
||||
// getMessagesSingleFile 从单个数据库文件获取消息
|
||||
func (ds *DataSource) getMessagesSingleFile(ctx context.Context, dbInfo MessageDBInfo, startTime, endTime time.Time, talker string, limit, offset int) ([]*model.Message, error) {
|
||||
db, err := ds.dbm.OpenDB(dbInfo.FilePath)
|
||||
if err != nil {
|
||||
return nil, errors.DBConnectFailed(dbInfo.FilePath, nil)
|
||||
}
|
||||
|
||||
// 构建查询条件
|
||||
conditions := []string{"Sequence >= ? AND Sequence <= ?"}
|
||||
args := []interface{}{startTime.Unix() * 1000, endTime.Unix() * 1000}
|
||||
if len(talker) > 0 {
|
||||
// TalkerId 有索引,优先使用
|
||||
talkerID, ok := dbInfo.TalkerMap[talker]
|
||||
if ok {
|
||||
conditions = append(conditions, "TalkerId = ?")
|
||||
args = append(args, talkerID)
|
||||
} else {
|
||||
conditions = append(conditions, "StrTalker = ?")
|
||||
args = append(args, talker)
|
||||
}
|
||||
}
|
||||
query := fmt.Sprintf(`
|
||||
SELECT MsgSvrID, Sequence, CreateTime, StrTalker, IsSender,
|
||||
Type, SubType, StrContent, CompressContent, BytesExtra
|
||||
FROM MSG
|
||||
WHERE %s
|
||||
ORDER BY Sequence ASC
|
||||
`, strings.Join(conditions, " AND "))
|
||||
|
||||
if limit > 0 {
|
||||
query += fmt.Sprintf(" LIMIT %d", limit)
|
||||
|
||||
if offset > 0 {
|
||||
query += fmt.Sprintf(" OFFSET %d", offset)
|
||||
}
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
rows, err := db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, errors.QueryFailed(query, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// 处理查询结果
|
||||
totalMessages := []*model.Message{}
|
||||
for rows.Next() {
|
||||
var msg model.MessageV3
|
||||
var compressContent []byte
|
||||
var bytesExtra []byte
|
||||
err := rows.Scan(
|
||||
&msg.MsgSvrID,
|
||||
&msg.Sequence,
|
||||
&msg.CreateTime,
|
||||
&msg.StrTalker,
|
||||
&msg.IsSender,
|
||||
&msg.Type,
|
||||
&msg.SubType,
|
||||
&msg.StrContent,
|
||||
&compressContent,
|
||||
&bytesExtra,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.ScanRowFailed(err)
|
||||
}
|
||||
msg.CompressContent = compressContent
|
||||
msg.BytesExtra = bytesExtra
|
||||
totalMessages = append(totalMessages, msg.Wrap())
|
||||
}
|
||||
return totalMessages, nil
|
||||
}
|
||||
|
||||
// getMessagesFromDB 从数据库获取消息
|
||||
func (ds *DataSource) getMessagesFromDB(ctx context.Context, db *sql.DB, dbInfo MessageDBInfo, startTime, endTime time.Time, talker string) ([]*model.Message, error) {
|
||||
// 构建查询条件
|
||||
conditions := []string{"Sequence >= ? AND Sequence <= ?"}
|
||||
args := []interface{}{startTime.Unix() * 1000, endTime.Unix() * 1000}
|
||||
|
||||
if len(talker) > 0 {
|
||||
talkerID, ok := dbInfo.TalkerMap[talker]
|
||||
if ok {
|
||||
conditions = append(conditions, "TalkerId = ?")
|
||||
args = append(args, talkerID)
|
||||
} else {
|
||||
conditions = append(conditions, "StrTalker = ?")
|
||||
args = append(args, talker)
|
||||
}
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT MsgSvrID, Sequence, CreateTime, StrTalker, IsSender,
|
||||
Type, SubType, StrContent, CompressContent, BytesExtra
|
||||
FROM MSG
|
||||
WHERE %s
|
||||
ORDER BY Sequence ASC
|
||||
`, strings.Join(conditions, " AND "))
|
||||
|
||||
// 执行查询
|
||||
rows, err := db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, errors.QueryFailed(query, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// 处理查询结果
|
||||
messages := []*model.Message{}
|
||||
for rows.Next() {
|
||||
var msg model.MessageV3
|
||||
var compressContent []byte
|
||||
var bytesExtra []byte
|
||||
|
||||
err := rows.Scan(
|
||||
&msg.MsgSvrID,
|
||||
&msg.Sequence,
|
||||
&msg.CreateTime,
|
||||
&msg.StrTalker,
|
||||
&msg.IsSender,
|
||||
&msg.Type,
|
||||
&msg.SubType,
|
||||
&msg.StrContent,
|
||||
&compressContent,
|
||||
&bytesExtra,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.ScanRowFailed(err)
|
||||
}
|
||||
msg.CompressContent = compressContent
|
||||
msg.BytesExtra = bytesExtra
|
||||
|
||||
messages = append(messages, msg.Wrap())
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
return filteredMessages, nil
|
||||
}
|
||||
|
||||
// GetContacts 实现获取联系人信息的方法
|
||||
|
||||
@@ -18,8 +18,8 @@ func (r *Repository) initChatRoomCache(ctx context.Context) error {
|
||||
}
|
||||
|
||||
chatRoomMap := make(map[string]*model.ChatRoom)
|
||||
remarkToChatRoom := make(map[string]*model.ChatRoom)
|
||||
nickNameToChatRoom := make(map[string]*model.ChatRoom)
|
||||
remarkToChatRoom := make(map[string][]*model.ChatRoom)
|
||||
nickNameToChatRoom := make(map[string][]*model.ChatRoom)
|
||||
chatRoomList := make([]string, 0)
|
||||
chatRoomRemark := make([]string, 0)
|
||||
chatRoomNickName := make([]string, 0)
|
||||
@@ -30,11 +30,21 @@ func (r *Repository) initChatRoomCache(ctx context.Context) error {
|
||||
chatRoomMap[chatRoom.Name] = chatRoom
|
||||
chatRoomList = append(chatRoomList, chatRoom.Name)
|
||||
if chatRoom.Remark != "" {
|
||||
remarkToChatRoom[chatRoom.Remark] = chatRoom
|
||||
remark, ok := remarkToChatRoom[chatRoom.Remark]
|
||||
if !ok {
|
||||
remark = make([]*model.ChatRoom, 0)
|
||||
}
|
||||
remark = append(remark, chatRoom)
|
||||
remarkToChatRoom[chatRoom.Remark] = remark
|
||||
chatRoomRemark = append(chatRoomRemark, chatRoom.Remark)
|
||||
}
|
||||
if chatRoom.NickName != "" {
|
||||
nickNameToChatRoom[chatRoom.NickName] = chatRoom
|
||||
nickName, ok := nickNameToChatRoom[chatRoom.NickName]
|
||||
if !ok {
|
||||
nickName = make([]*model.ChatRoom, 0)
|
||||
}
|
||||
nickName = append(nickName, chatRoom)
|
||||
nickNameToChatRoom[chatRoom.NickName] = nickName
|
||||
chatRoomNickName = append(chatRoomNickName, chatRoom.NickName)
|
||||
}
|
||||
}
|
||||
@@ -49,11 +59,21 @@ func (r *Repository) initChatRoomCache(ctx context.Context) error {
|
||||
chatRoomMap[contact.UserName] = chatRoom
|
||||
chatRoomList = append(chatRoomList, contact.UserName)
|
||||
if contact.Remark != "" {
|
||||
remarkToChatRoom[contact.Remark] = chatRoom
|
||||
remark, ok := remarkToChatRoom[chatRoom.Remark]
|
||||
if !ok {
|
||||
remark = make([]*model.ChatRoom, 0)
|
||||
}
|
||||
remark = append(remark, chatRoom)
|
||||
remarkToChatRoom[chatRoom.Remark] = remark
|
||||
chatRoomRemark = append(chatRoomRemark, contact.Remark)
|
||||
}
|
||||
if contact.NickName != "" {
|
||||
nickNameToChatRoom[contact.NickName] = chatRoom
|
||||
nickName, ok := nickNameToChatRoom[chatRoom.NickName]
|
||||
if !ok {
|
||||
nickName = make([]*model.ChatRoom, 0)
|
||||
}
|
||||
nickName = append(nickName, chatRoom)
|
||||
nickNameToChatRoom[chatRoom.NickName] = nickName
|
||||
chatRoomNickName = append(chatRoomNickName, contact.NickName)
|
||||
}
|
||||
}
|
||||
@@ -63,9 +83,12 @@ func (r *Repository) initChatRoomCache(ctx context.Context) error {
|
||||
sort.Strings(chatRoomNickName)
|
||||
|
||||
r.chatRoomCache = chatRoomMap
|
||||
r.chatRoomList = chatRoomList
|
||||
r.remarkToChatRoom = remarkToChatRoom
|
||||
r.nickNameToChatRoom = nickNameToChatRoom
|
||||
r.chatRoomList = chatRoomList
|
||||
r.chatRoomRemark = chatRoomRemark
|
||||
r.chatRoomNickName = chatRoomNickName
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -75,7 +98,7 @@ func (r *Repository) GetChatRooms(ctx context.Context, key string, limit, offset
|
||||
if key != "" {
|
||||
ret = r.findChatRooms(key)
|
||||
if len(ret) == 0 {
|
||||
return nil, errors.ChatRoomNotFound(key)
|
||||
return []*model.ChatRoom{}, nil
|
||||
}
|
||||
|
||||
if limit > 0 {
|
||||
@@ -129,21 +152,21 @@ func (r *Repository) findChatRoom(key string) *model.ChatRoom {
|
||||
return chatRoom
|
||||
}
|
||||
if chatRoom, ok := r.remarkToChatRoom[key]; ok {
|
||||
return chatRoom
|
||||
return chatRoom[0]
|
||||
}
|
||||
if chatRoom, ok := r.nickNameToChatRoom[key]; ok {
|
||||
return chatRoom
|
||||
return chatRoom[0]
|
||||
}
|
||||
|
||||
// Contain
|
||||
for _, remark := range r.chatRoomRemark {
|
||||
if strings.Contains(remark, key) {
|
||||
return r.remarkToChatRoom[remark]
|
||||
return r.remarkToChatRoom[remark][0]
|
||||
}
|
||||
}
|
||||
for _, nickName := range r.chatRoomNickName {
|
||||
if strings.Contains(nickName, key) {
|
||||
return r.nickNameToChatRoom[nickName]
|
||||
return r.nickNameToChatRoom[nickName][0]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,26 +180,42 @@ func (r *Repository) findChatRooms(key string) []*model.ChatRoom {
|
||||
ret = append(ret, chatRoom)
|
||||
distinct[chatRoom.Name] = true
|
||||
}
|
||||
if chatRoom, ok := r.remarkToChatRoom[key]; ok && !distinct[chatRoom.Name] {
|
||||
ret = append(ret, chatRoom)
|
||||
distinct[chatRoom.Name] = true
|
||||
if chatRooms, ok := r.remarkToChatRoom[key]; ok {
|
||||
for _, chatRoom := range chatRooms {
|
||||
if !distinct[chatRoom.Name] {
|
||||
ret = append(ret, chatRoom)
|
||||
distinct[chatRoom.Name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if chatRoom, ok := r.nickNameToChatRoom[key]; ok && !distinct[chatRoom.Name] {
|
||||
ret = append(ret, chatRoom)
|
||||
distinct[chatRoom.Name] = true
|
||||
if chatRooms, ok := r.nickNameToChatRoom[key]; ok {
|
||||
for _, chatRoom := range chatRooms {
|
||||
if !distinct[chatRoom.Name] {
|
||||
ret = append(ret, chatRoom)
|
||||
distinct[chatRoom.Name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Contain
|
||||
for _, remark := range r.chatRoomRemark {
|
||||
if strings.Contains(remark, key) && !distinct[r.remarkToChatRoom[remark].Name] {
|
||||
ret = append(ret, r.remarkToChatRoom[remark])
|
||||
distinct[r.remarkToChatRoom[remark].Name] = true
|
||||
if strings.Contains(remark, key) {
|
||||
for _, chatRoom := range r.remarkToChatRoom[remark] {
|
||||
if !distinct[chatRoom.Name] {
|
||||
ret = append(ret, chatRoom)
|
||||
distinct[chatRoom.Name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, nickName := range r.chatRoomNickName {
|
||||
if strings.Contains(nickName, key) && !distinct[r.nickNameToChatRoom[nickName].Name] {
|
||||
ret = append(ret, r.nickNameToChatRoom[nickName])
|
||||
distinct[r.nickNameToChatRoom[nickName].Name] = true
|
||||
if strings.Contains(nickName, key) {
|
||||
for _, chatRoom := range r.nickNameToChatRoom[nickName] {
|
||||
if !distinct[chatRoom.Name] {
|
||||
ret = append(ret, chatRoom)
|
||||
distinct[chatRoom.Name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,9 +18,9 @@ func (r *Repository) initContactCache(ctx context.Context) error {
|
||||
}
|
||||
|
||||
contactMap := make(map[string]*model.Contact)
|
||||
aliasMap := make(map[string]*model.Contact)
|
||||
remarkMap := make(map[string]*model.Contact)
|
||||
nickNameMap := make(map[string]*model.Contact)
|
||||
aliasMap := make(map[string][]*model.Contact)
|
||||
remarkMap := make(map[string][]*model.Contact)
|
||||
nickNameMap := make(map[string][]*model.Contact)
|
||||
chatRoomUserMap := make(map[string]*model.Contact)
|
||||
chatRoomInContactMap := make(map[string]*model.Contact)
|
||||
contactList := make([]string, 0)
|
||||
@@ -34,15 +34,30 @@ func (r *Repository) initContactCache(ctx context.Context) error {
|
||||
|
||||
// 建立快速查找索引
|
||||
if contact.Alias != "" {
|
||||
aliasMap[contact.Alias] = contact
|
||||
alias, ok := aliasMap[contact.Alias]
|
||||
if !ok {
|
||||
alias = make([]*model.Contact, 0)
|
||||
}
|
||||
alias = append(alias, contact)
|
||||
aliasMap[contact.Alias] = alias
|
||||
aliasList = append(aliasList, contact.Alias)
|
||||
}
|
||||
if contact.Remark != "" {
|
||||
remarkMap[contact.Remark] = contact
|
||||
remark, ok := remarkMap[contact.Remark]
|
||||
if !ok {
|
||||
remark = make([]*model.Contact, 0)
|
||||
}
|
||||
remark = append(remark, contact)
|
||||
remarkMap[contact.Remark] = remark
|
||||
remarkList = append(remarkList, contact.Remark)
|
||||
}
|
||||
if contact.NickName != "" {
|
||||
nickNameMap[contact.NickName] = contact
|
||||
nickName, ok := nickNameMap[contact.NickName]
|
||||
if !ok {
|
||||
nickName = make([]*model.Contact, 0)
|
||||
}
|
||||
nickName = append(nickName, contact)
|
||||
nickNameMap[contact.NickName] = nickName
|
||||
nickNameList = append(nickNameList, contact.NickName)
|
||||
}
|
||||
|
||||
@@ -88,7 +103,7 @@ func (r *Repository) GetContacts(ctx context.Context, key string, limit, offset
|
||||
if key != "" {
|
||||
ret = r.findContacts(key)
|
||||
if len(ret) == 0 {
|
||||
return nil, errors.ContactNotFound(key)
|
||||
return []*model.Contact{}, nil
|
||||
}
|
||||
if limit > 0 {
|
||||
end := offset + limit
|
||||
@@ -124,29 +139,29 @@ func (r *Repository) findContact(key string) *model.Contact {
|
||||
return contact
|
||||
}
|
||||
if contact, ok := r.aliasToContact[key]; ok {
|
||||
return contact
|
||||
return contact[0]
|
||||
}
|
||||
if contact, ok := r.remarkToContact[key]; ok {
|
||||
return contact
|
||||
return contact[0]
|
||||
}
|
||||
if contact, ok := r.nickNameToContact[key]; ok {
|
||||
return contact
|
||||
return contact[0]
|
||||
}
|
||||
|
||||
// Contain
|
||||
for _, alias := range r.aliasList {
|
||||
if strings.Contains(alias, key) {
|
||||
return r.aliasToContact[alias]
|
||||
return r.aliasToContact[alias][0]
|
||||
}
|
||||
}
|
||||
for _, remark := range r.remarkList {
|
||||
if strings.Contains(remark, key) {
|
||||
return r.remarkToContact[remark]
|
||||
return r.remarkToContact[remark][0]
|
||||
}
|
||||
}
|
||||
for _, nickName := range r.nickNameList {
|
||||
if strings.Contains(nickName, key) {
|
||||
return r.nickNameToContact[nickName]
|
||||
return r.nickNameToContact[nickName][0]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -159,37 +174,62 @@ func (r *Repository) findContacts(key string) []*model.Contact {
|
||||
ret = append(ret, contact)
|
||||
distinct[contact.UserName] = true
|
||||
}
|
||||
if contact, ok := r.aliasToContact[key]; ok && !distinct[contact.UserName] {
|
||||
ret = append(ret, contact)
|
||||
distinct[contact.UserName] = true
|
||||
if contacts, ok := r.aliasToContact[key]; ok {
|
||||
for _, contact := range contacts {
|
||||
if !distinct[contact.UserName] {
|
||||
ret = append(ret, contact)
|
||||
distinct[contact.UserName] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if contact, ok := r.remarkToContact[key]; ok && !distinct[contact.UserName] {
|
||||
ret = append(ret, contact)
|
||||
distinct[contact.UserName] = true
|
||||
if contacts, ok := r.remarkToContact[key]; ok {
|
||||
for _, contact := range contacts {
|
||||
if !distinct[contact.UserName] {
|
||||
ret = append(ret, contact)
|
||||
distinct[contact.UserName] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if contact, ok := r.nickNameToContact[key]; ok && !distinct[contact.UserName] {
|
||||
ret = append(ret, contact)
|
||||
distinct[contact.UserName] = true
|
||||
if contacts, ok := r.nickNameToContact[key]; ok {
|
||||
for _, contact := range contacts {
|
||||
if !distinct[contact.UserName] {
|
||||
ret = append(ret, contact)
|
||||
distinct[contact.UserName] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// Contain
|
||||
for _, alias := range r.aliasList {
|
||||
if strings.Contains(alias, key) && !distinct[r.aliasToContact[alias].UserName] {
|
||||
ret = append(ret, r.aliasToContact[alias])
|
||||
distinct[r.aliasToContact[alias].UserName] = true
|
||||
if strings.Contains(alias, key) {
|
||||
for _, contact := range r.aliasToContact[alias] {
|
||||
if !distinct[contact.UserName] {
|
||||
ret = append(ret, contact)
|
||||
distinct[contact.UserName] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, remark := range r.remarkList {
|
||||
if strings.Contains(remark, key) && !distinct[r.remarkToContact[remark].UserName] {
|
||||
ret = append(ret, r.remarkToContact[remark])
|
||||
distinct[r.remarkToContact[remark].UserName] = true
|
||||
if strings.Contains(remark, key) {
|
||||
for _, contact := range r.remarkToContact[remark] {
|
||||
if !distinct[contact.UserName] {
|
||||
ret = append(ret, contact)
|
||||
distinct[contact.UserName] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, nickName := range r.nickNameList {
|
||||
if strings.Contains(nickName, key) && !distinct[r.nickNameToContact[nickName].UserName] {
|
||||
ret = append(ret, r.nickNameToContact[nickName])
|
||||
distinct[r.nickNameToContact[nickName].UserName] = true
|
||||
if strings.Contains(nickName, key) {
|
||||
for _, contact := range r.nickNameToContact[nickName] {
|
||||
if !distinct[contact.UserName] {
|
||||
ret = append(ret, contact)
|
||||
distinct[contact.UserName] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
|
||||
@@ -2,23 +2,20 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/model"
|
||||
"github.com/sjzar/chatlog/pkg/util"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// GetMessages 实现 Repository 接口的 GetMessages 方法
|
||||
func (r *Repository) GetMessages(ctx context.Context, startTime, endTime time.Time, talker string, limit, offset int) ([]*model.Message, error) {
|
||||
func (r *Repository) GetMessages(ctx context.Context, startTime, endTime time.Time, talker string, sender string, keyword string, limit, offset int) ([]*model.Message, error) {
|
||||
|
||||
if contact, _ := r.GetContact(ctx, talker); contact != nil {
|
||||
talker = contact.UserName
|
||||
} else if chatRoom, _ := r.GetChatRoom(ctx, talker); chatRoom != nil {
|
||||
talker = chatRoom.Name
|
||||
}
|
||||
|
||||
messages, err := r.ds.GetMessages(ctx, startTime, endTime, talker, limit, offset)
|
||||
talker, sender = r.parseTalkerAndSender(ctx, talker, sender)
|
||||
messages, err := r.ds.GetMessages(ctx, startTime, endTime, talker, sender, keyword, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -62,3 +59,53 @@ func (r *Repository) enrichMessage(msg *model.Message) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Repository) parseTalkerAndSender(ctx context.Context, talker, sender string) (string, string) {
|
||||
displayName2User := make(map[string]string)
|
||||
users := make(map[string]bool)
|
||||
|
||||
talkers := util.Str2List(talker, ",")
|
||||
if len(talkers) > 0 {
|
||||
for i := 0; i < len(talkers); i++ {
|
||||
if contact, _ := r.GetContact(ctx, talkers[i]); contact != nil {
|
||||
talkers[i] = contact.UserName
|
||||
} else if chatRoom, _ := r.GetChatRoom(ctx, talker); chatRoom != nil {
|
||||
talkers[i] = chatRoom.Name
|
||||
}
|
||||
}
|
||||
// 获取群聊的用户列表
|
||||
for i := 0; i < len(talkers); i++ {
|
||||
if chatRoom, _ := r.GetChatRoom(ctx, talkers[i]); chatRoom != nil {
|
||||
for user, displayName := range chatRoom.User2DisplayName {
|
||||
displayName2User[displayName] = user
|
||||
}
|
||||
for _, user := range chatRoom.Users {
|
||||
users[user.UserName] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
talker = strings.Join(talkers, ",")
|
||||
}
|
||||
|
||||
senders := util.Str2List(sender, ",")
|
||||
if len(senders) > 0 {
|
||||
for i := 0; i < len(senders); i++ {
|
||||
if user, ok := displayName2User[senders[i]]; ok {
|
||||
senders[i] = user
|
||||
} else {
|
||||
// FIXME 大量群聊用户名称重复,无法直接通过 GetContact 获取 ID,后续再优化
|
||||
for user := range users {
|
||||
if contact := r.getFullContact(user); contact != nil {
|
||||
if contact.DisplayName() == senders[i] {
|
||||
senders[i] = user
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sender = strings.Join(senders, ",")
|
||||
}
|
||||
|
||||
return talker, sender
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@ type Repository struct {
|
||||
|
||||
// Cache for contact
|
||||
contactCache map[string]*model.Contact
|
||||
aliasToContact map[string]*model.Contact
|
||||
remarkToContact map[string]*model.Contact
|
||||
nickNameToContact map[string]*model.Contact
|
||||
aliasToContact map[string][]*model.Contact
|
||||
remarkToContact map[string][]*model.Contact
|
||||
nickNameToContact map[string][]*model.Contact
|
||||
chatRoomInContact map[string]*model.Contact
|
||||
contactList []string
|
||||
aliasList []string
|
||||
@@ -28,8 +28,8 @@ type Repository struct {
|
||||
|
||||
// Cache for chat room
|
||||
chatRoomCache map[string]*model.ChatRoom
|
||||
remarkToChatRoom map[string]*model.ChatRoom
|
||||
nickNameToChatRoom map[string]*model.ChatRoom
|
||||
remarkToChatRoom map[string][]*model.ChatRoom
|
||||
nickNameToChatRoom map[string][]*model.ChatRoom
|
||||
chatRoomList []string
|
||||
chatRoomRemark []string
|
||||
chatRoomNickName []string
|
||||
@@ -43,17 +43,17 @@ func New(ds datasource.DataSource) (*Repository, error) {
|
||||
r := &Repository{
|
||||
ds: ds,
|
||||
contactCache: make(map[string]*model.Contact),
|
||||
aliasToContact: make(map[string]*model.Contact),
|
||||
remarkToContact: make(map[string]*model.Contact),
|
||||
nickNameToContact: make(map[string]*model.Contact),
|
||||
aliasToContact: make(map[string][]*model.Contact),
|
||||
remarkToContact: make(map[string][]*model.Contact),
|
||||
nickNameToContact: make(map[string][]*model.Contact),
|
||||
chatRoomUserToInfo: make(map[string]*model.Contact),
|
||||
contactList: make([]string, 0),
|
||||
aliasList: make([]string, 0),
|
||||
remarkList: make([]string, 0),
|
||||
nickNameList: make([]string, 0),
|
||||
chatRoomCache: make(map[string]*model.ChatRoom),
|
||||
remarkToChatRoom: make(map[string]*model.ChatRoom),
|
||||
nickNameToChatRoom: make(map[string]*model.ChatRoom),
|
||||
remarkToChatRoom: make(map[string][]*model.ChatRoom),
|
||||
nickNameToChatRoom: make(map[string][]*model.ChatRoom),
|
||||
chatRoomList: make([]string, 0),
|
||||
chatRoomRemark: make([]string, 0),
|
||||
chatRoomNickName: make([]string, 0),
|
||||
|
||||
@@ -57,11 +57,11 @@ func (w *DB) Initialize() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *DB) GetMessages(start, end time.Time, talker string, limit, offset int) ([]*model.Message, error) {
|
||||
func (w *DB) GetMessages(start, end time.Time, talker string, sender string, keyword string, limit, offset int) ([]*model.Message, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
// 使用 repository 获取消息
|
||||
messages, err := w.repo.GetMessages(ctx, start, end, talker, limit, offset)
|
||||
messages, err := w.repo.GetMessages(ctx, start, end, talker, sender, keyword, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package util
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
@@ -45,3 +46,26 @@ func IsNumeric(s string) bool {
|
||||
func SplitInt64ToTwoInt32(input int64) (int64, int64) {
|
||||
return input & 0xFFFFFFFF, input >> 32
|
||||
}
|
||||
|
||||
func Str2List(str string, sep string) []string {
|
||||
list := make([]string, 0)
|
||||
|
||||
if str == "" {
|
||||
return list
|
||||
}
|
||||
|
||||
listMap := make(map[string]bool)
|
||||
for _, elem := range strings.Split(str, sep) {
|
||||
elem = strings.TrimSpace(elem)
|
||||
if len(elem) == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := listMap[elem]; ok {
|
||||
continue
|
||||
}
|
||||
listMap[elem] = true
|
||||
list = append(list, elem)
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
@@ -582,8 +582,8 @@ func adjustStartTime(t time.Time, g TimeGranularity) time.Time {
|
||||
func adjustEndTime(t time.Time, g TimeGranularity) time.Time {
|
||||
switch g {
|
||||
case GranularitySecond, GranularityMinute, GranularityHour:
|
||||
// 对于精确到秒/分钟/小时的时间,设置为当天结束
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 999999999, t.Location())
|
||||
// 对于精确到秒/分钟/小时的时间,保持原样
|
||||
return t
|
||||
case GranularityDay:
|
||||
// 精确到天,设置为当天结束
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 999999999, t.Location())
|
||||
@@ -634,3 +634,25 @@ func isValidDate(year, month, day int) bool {
|
||||
|
||||
return day <= daysInMonth
|
||||
}
|
||||
|
||||
func PerfectTimeFormat(start time.Time, end time.Time) string {
|
||||
endTime := end
|
||||
|
||||
// 如果结束时间是某一天的 0 点整,将其减去 1 秒,视为前一天的结束
|
||||
if endTime.Hour() == 0 && endTime.Minute() == 0 && endTime.Second() == 0 && endTime.Nanosecond() == 0 {
|
||||
endTime = endTime.Add(-time.Second) // 减去 1 秒
|
||||
}
|
||||
|
||||
// 判断是否跨年
|
||||
if start.Year() != endTime.Year() {
|
||||
return "2006-01-02 15:04:05" // 完整格式,包含年月日时分秒
|
||||
}
|
||||
|
||||
// 判断是否跨天(但在同一年内)
|
||||
if start.YearDay() != endTime.YearDay() {
|
||||
return "01-02 15:04:05" // 月日时分秒格式
|
||||
}
|
||||
|
||||
// 在同一天内
|
||||
return "15:04:05" // 只显示时分秒
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user