diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..39c7589
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,51 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+env:
+ ENABLE_UPX: 1
+
+jobs:
+ release:
+ name: Release Binary
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Setup Go
+ uses: actions/setup-go@v4
+ with:
+ go-version: '^1.24'
+
+ - name: Cache go module
+ uses: actions/cache@v3
+ with:
+ path: ~/go/pkg/mod
+ key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
+ restore-keys: |
+ ${{ runner.os }}-go-
+
+ - name: Install UPX
+ uses: crazy-max/ghaction-upx@v3
+ with:
+ install-only: true
+
+ - name: Build Package
+ run: |
+ ./script/package.sh
+
+ - name: Release
+ uses: softprops/action-gh-release@v1
+ if: startsWith(github.ref, 'refs/tags/')
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ files: packages/*
+ draft: true
+ prerelease: true
diff --git a/.gitignore b/.gitignore
index 6f72f89..d4b8f7e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,9 @@ go.work.sum
# env file
.env
+
+# syncthing files
+.stfolder
+
+chatlog
+chatlog.exe
\ No newline at end of file
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..87be8d0
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,54 @@
+BINARY_NAME := chatlog
+GO := go
+ifeq ($(VERSION),)
+ VERSION := $(shell git describe --tags --always --dirty="-dev")
+endif
+LDFLAGS := -ldflags '-X "github.com/sjzar/chatlog/pkg/version.Version=$(VERSION)" -w -s'
+
+PLATFORMS := \
+ windows/amd64 \
+ windows/arm64
+
+UPX_PLATFORMS := \
+ windows/386 \
+ windows/amd64
+
+.PHONY: all clean lint tidy test build crossbuild upx
+
+all: clean lint tidy test build
+
+clean:
+ @echo "🧹 Cleaning..."
+ @rm -rf bin/
+
+lint:
+ @echo "🕵️♂️ Running linters..."
+ golangci-lint run ./...
+
+tidy:
+ @echo "🧼 Tidying up dependencies..."
+ $(GO) mod tidy
+
+test:
+ @echo "🧪 Running tests..."
+ $(GO) test ./... -cover
+
+build:
+ @echo "🔨 Building for current platform..."
+ $(GO) build -trimpath $(LDFLAGS) -o bin/$(BINARY_NAME) main.go
+
+crossbuild: clean
+ @echo "🌍 Building for multiple platforms..."
+ for platform in $(PLATFORMS); do \
+ os=$$(echo $$platform | cut -d/ -f1); \
+ arch=$$(echo $$platform | cut -d/ -f2); \
+ float=$$(echo $$platform | cut -d/ -f3); \
+ output_name=bin/chatlog_$${os}_$${arch}; \
+ [ "$$float" != "" ] && output_name=$$output_name_$$float; \
+ echo "🔨 Building for $$os/$$arch..."; \
+ echo "🔨 Building for $$output_name..."; \
+ GOOS=$$os GOARCH=$$arch GOARM=$$float $(GO) build -trimpath $(LDFLAGS) -o $$output_name main.go ; \
+ if [ "$(ENABLE_UPX)" = "1" ] && echo "$(UPX_PLATFORMS)" | grep -q "$$os/$$arch"; then \
+ echo "⚙️ Compressing binary $$output_name..." && upx --best $$output_name; \
+ fi; \
+ done
\ No newline at end of file
diff --git a/README.md b/README.md
index 3ab5b02..d28dc67 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,140 @@
-# chatlog
\ No newline at end of file
+# Chatlog
+
+Chatlog 是一个聊天记录收集、分析的开源工具,旨在帮助用户更好地利用自己的聊天数据。
+目前支持微信聊天记录的解密和查询,提供 Terminal UI 界面和 HTTP API 服务,让您可以方便地访问和分析聊天数据。
+
+## 功能特点
+
+- **数据收集**:从本地数据库文件中获取聊天数据
+- **终端界面**:提供简洁的 Terminal UI,方便直接操作
+- **HTTP API**:提供 API 接口,支持查询聊天记录、联系人和群聊信息
+- **MCP 支持**:实现 Model Context Protocol,可与支持 MCP 的 AI 助手无缝集成
+- **多格式输出**:支持 JSON、CSV、纯文本等多种输出格式
+
+## 安装
+
+### 从源码安装
+
+```bash
+go install github.com/sjzar/chatlog@latest
+```
+
+### 下载预编译版本
+
+访问 [Releases](https://github.com/sjzar/chatlog/releases) 页面下载适合您系统的预编译版本。
+
+## 快速开始
+
+### 终端 UI 模式
+
+1. 启动程序:
+
+```bash
+./chatlog
+```
+
+2. 使用界面操作:
+ - 使用方向键导航菜单
+ - 按 Enter 选择菜单项
+ - 按 Esc 返回上一级菜单
+ - 按 Ctrl+C 退出程序
+
+### 命令行模式
+
+获取微信进程密钥:
+
+```bash
+./chatlog key
+```
+
+解密数据库文件:
+
+```bash
+./chatlog decrypt --data-dir "微信数据目录" --work-dir "输出目录" --key "密钥" --version 3
+```
+
+## HTTP API
+
+启动 HTTP 服务后,可以通过以下 API 访问数据:
+
+### 聊天记录
+
+```
+GET /api/v1/chatlog?time=2023-01-01&talker=wxid_xxx&limit=100&offset=0&format=json
+```
+
+参数说明:
+- `time`: 时间范围,格式为 `YYYY-MM-DD` 或 `YYYY-MM-DD~YYYY-MM-DD`
+- `talker`: 聊天对象的 ID,不知道 ID 的话也可以尝试备注名、昵称、群聊 ID等
+- `limit`: 返回记录数量限制
+- `offset`: 分页偏移量
+- `format`: 输出格式,支持 `json`、`csv` 或纯文本
+
+### 联系人列表
+
+```
+GET /api/v1/contact?format=json
+```
+
+### 群聊列表
+
+```
+GET /api/v1/chatroom?format=json
+```
+
+### 会话列表
+
+```
+GET /api/v1/session?limit=100&format=json
+```
+
+## MCP 集成
+
+Chatlog 实现了 Model Context Protocol (MCP),可以与支持 MCP 的 AI 助手集成。通过 MCP,AI 助手可以:
+
+1. 查询联系人信息
+2. 获取群聊列表和成员
+3. 检索最近的聊天记录
+4. 按时间和联系人搜索聊天记录
+
+## 未来规划
+
+Chatlog 希望成为最好用的聊天记录工具,帮助用户充分挖掘自己聊天数据的价值。我们的路线图包括:
+
+- **多平台支持**:计划支持 MacOS 平台的微信聊天记录解密
+- **全文索引**:实现聊天记录的全文检索,提供更快速的搜索体验
+- **统计与可视化**:提供聊天数据的统计分析和可视化 Dashboard
+- **CS 架构**:将数据收集和统计分析功能分离,支持将服务部署在 NAS 或家庭服务器上
+- **增量更新**:支持聊天记录的增量采集和更新,减少资源消耗
+- **关键词监控**:提供关键词监控和实时提醒功能
+- **更多聊天工具支持**:计划支持更多主流聊天工具的数据采集和分析
+
+## 数据安全声明
+
+Chatlog 高度重视用户数据安全和隐私保护:
+
+- 所有数据处理均在本地完成,不会上传到任何外部服务器
+- 解密后的数据存储在用户指定的工作目录中,用户对数据有完全控制权
+- 建议定期备份重要的聊天记录,并妥善保管解密后的数据
+- 请勿将本工具用于未经授权访问他人聊天记录等非法用途
+
+## 贡献
+
+我们欢迎社区的贡献!无论是代码贡献、问题报告还是功能建议,都将帮助 Chatlog 变得更好:
+
+1. Fork 本仓库
+2. 创建您的特性分支 (`git checkout -b feature/amazing-feature`)
+3. 提交您的更改 (`git commit -m 'Add some amazing feature'`)
+4. 推送到分支 (`git push origin feature/amazing-feature`)
+5. 打开一个 Pull Request
+
+## 许可证
+
+本项目采用 Apache-2.0 许可证 - 详见 [LICENSE](LICENSE) 文件。
+
+## 致谢
+
+- [tview](https://github.com/rivo/tview) - 终端 UI 库
+- [gin](https://github.com/gin-gonic/gin) - HTTP 框架
+- [Model Context Protocol](https://github.com/modelcontextprotocol) - AI 助手集成协议
+- 以及所有贡献者和用户的支持与反馈
\ No newline at end of file
diff --git a/cmd/chatlog/cmd_decrypt.go b/cmd/chatlog/cmd_decrypt.go
new file mode 100644
index 0000000..c7100ac
--- /dev/null
+++ b/cmd/chatlog/cmd_decrypt.go
@@ -0,0 +1,40 @@
+package chatlog
+
+import (
+ "fmt"
+
+ "github.com/sjzar/chatlog/internal/chatlog"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+)
+
+func init() {
+ rootCmd.AddCommand(decryptCmd)
+ decryptCmd.Flags().StringVarP(&dataDir, "data-dir", "d", "", "data dir")
+ decryptCmd.Flags().StringVarP(&workDir, "work-dir", "w", "", "work dir")
+ decryptCmd.Flags().StringVarP(&key, "key", "k", "", "key")
+ decryptCmd.Flags().IntVarP(&decryptVer, "version", "v", 3, "version")
+}
+
+var dataDir string
+var workDir string
+var key string
+var decryptVer int
+
+var decryptCmd = &cobra.Command{
+ Use: "decrypt",
+ Short: "decrypt",
+ Run: func(cmd *cobra.Command, args []string) {
+ m, err := chatlog.New("")
+ if err != nil {
+ log.Error(err)
+ return
+ }
+ if err := m.CommandDecrypt(dataDir, workDir, key, decryptVer); err != nil {
+ log.Error(err)
+ return
+ }
+ fmt.Println("decrypt success")
+ },
+}
diff --git a/cmd/chatlog/cmd_key.go b/cmd/chatlog/cmd_key.go
new file mode 100644
index 0000000..25336fc
--- /dev/null
+++ b/cmd/chatlog/cmd_key.go
@@ -0,0 +1,34 @@
+package chatlog
+
+import (
+ "fmt"
+
+ "github.com/sjzar/chatlog/internal/chatlog"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+)
+
+func init() {
+ rootCmd.AddCommand(keyCmd)
+ keyCmd.Flags().IntVarP(&pid, "pid", "p", 0, "pid")
+}
+
+var pid int
+var keyCmd = &cobra.Command{
+ Use: "key",
+ Short: "key",
+ Run: func(cmd *cobra.Command, args []string) {
+ m, err := chatlog.New("")
+ if err != nil {
+ log.Error(err)
+ return
+ }
+ ret, err := m.CommandKey(pid)
+ if err != nil {
+ log.Error(err)
+ return
+ }
+ fmt.Println(ret)
+ },
+}
diff --git a/cmd/chatlog/cmd_version.go b/cmd/chatlog/cmd_version.go
new file mode 100644
index 0000000..1af2f13
--- /dev/null
+++ b/cmd/chatlog/cmd_version.go
@@ -0,0 +1,27 @@
+package chatlog
+
+import (
+ "fmt"
+
+ "github.com/sjzar/chatlog/pkg/version"
+
+ "github.com/spf13/cobra"
+)
+
+func init() {
+ rootCmd.AddCommand(versionCmd)
+ versionCmd.Flags().BoolVarP(&versionM, "module", "m", false, "module version information")
+}
+
+var versionM bool
+var versionCmd = &cobra.Command{
+ Use: "version [-m]",
+ Short: "Show the version of chatlog",
+ Run: func(cmd *cobra.Command, args []string) {
+ if versionM {
+ fmt.Println(version.GetMore(true))
+ } else {
+ fmt.Printf("chatlog %s\n", version.GetMore(false))
+ }
+ },
+}
diff --git a/cmd/chatlog/log.go b/cmd/chatlog/log.go
new file mode 100644
index 0000000..54b15ab
--- /dev/null
+++ b/cmd/chatlog/log.go
@@ -0,0 +1,50 @@
+package chatlog
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "path"
+ "path/filepath"
+ "runtime"
+
+ "github.com/sjzar/chatlog/pkg/util"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+)
+
+var Debug bool
+
+func initLog(cmd *cobra.Command, args []string) {
+ log.SetFormatter(&log.TextFormatter{
+ FullTimestamp: true,
+ CallerPrettyfier: func(f *runtime.Frame) (string, string) {
+ _, filename := path.Split(f.File)
+ return "", fmt.Sprintf("%s:%d", filename, f.Line)
+ },
+ })
+
+ if Debug {
+ log.SetLevel(log.DebugLevel)
+ log.SetReportCaller(true)
+ }
+}
+
+func initTuiLog(cmd *cobra.Command, args []string) {
+ logOutput := io.Discard
+
+ debug, _ := cmd.Flags().GetBool("debug")
+ if debug {
+ logpath := util.DefaultWorkDir("")
+ util.PrepareDir(logpath)
+ logFD, err := os.OpenFile(filepath.Join(logpath, "chatlog.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, os.ModePerm)
+ if err != nil {
+ panic(err)
+ }
+ logOutput = logFD
+ log.SetReportCaller(true)
+ }
+
+ log.SetOutput(logOutput)
+}
diff --git a/cmd/chatlog/root.go b/cmd/chatlog/root.go
new file mode 100644
index 0000000..c360476
--- /dev/null
+++ b/cmd/chatlog/root.go
@@ -0,0 +1,48 @@
+package chatlog
+
+import (
+ "github.com/sjzar/chatlog/internal/chatlog"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+)
+
+func init() {
+ // windows only
+ cobra.MousetrapHelpText = ""
+
+ rootCmd.PersistentFlags().BoolVar(&Debug, "debug", false, "debug")
+ rootCmd.PersistentPreRun = initLog
+}
+
+func Execute() {
+ if err := rootCmd.Execute(); err != nil {
+ log.Error(err)
+ }
+}
+
+var rootCmd = &cobra.Command{
+ Use: "chatlog",
+ Short: "chatlog",
+ Long: `chatlog`,
+ Example: `chatlog`,
+ Args: cobra.MinimumNArgs(0),
+ CompletionOptions: cobra.CompletionOptions{
+ HiddenDefaultCmd: true,
+ },
+ PreRun: initTuiLog,
+ Run: Root,
+}
+
+func Root(cmd *cobra.Command, args []string) {
+
+ m, err := chatlog.New("")
+ if err != nil {
+ log.Error(err)
+ return
+ }
+
+ if err := m.Run(); err != nil {
+ log.Error(err)
+ }
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..7b4cd80
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,72 @@
+module github.com/sjzar/chatlog
+
+go 1.24.0
+
+require (
+ github.com/gdamore/tcell/v2 v2.8.1
+ github.com/gin-gonic/gin v1.10.0
+ github.com/google/uuid v1.4.0
+ github.com/mattn/go-sqlite3 v1.14.24
+ github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57
+ github.com/shirou/gopsutil/v4 v4.25.2
+ github.com/sirupsen/logrus v1.9.3
+ github.com/spf13/cobra v1.9.1
+ github.com/spf13/viper v1.19.0
+ golang.org/x/crypto v0.36.0
+ golang.org/x/sys v0.31.0
+ google.golang.org/protobuf v1.36.5
+)
+
+require (
+ github.com/bytedance/sonic v1.11.6 // indirect
+ github.com/bytedance/sonic/loader v0.1.1 // indirect
+ github.com/cloudwego/base64x v0.1.4 // indirect
+ github.com/cloudwego/iasm v0.2.0 // indirect
+ github.com/ebitengine/purego v0.8.2 // indirect
+ github.com/fsnotify/fsnotify v1.7.0 // indirect
+ github.com/gabriel-vasile/mimetype v1.4.3 // indirect
+ github.com/gdamore/encoding v1.0.1 // indirect
+ github.com/gin-contrib/sse v0.1.0 // indirect
+ github.com/go-ole/go-ole v1.3.0 // indirect
+ github.com/go-playground/locales v0.14.1 // indirect
+ github.com/go-playground/universal-translator v0.18.1 // indirect
+ github.com/go-playground/validator/v10 v10.20.0 // indirect
+ github.com/goccy/go-json v0.10.2 // indirect
+ github.com/hashicorp/hcl v1.0.0 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/klauspost/cpuid/v2 v2.2.7 // indirect
+ github.com/leodido/go-urn v1.4.0 // indirect
+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
+ github.com/magiconair/properties v1.8.7 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-runewidth v0.0.16 // indirect
+ github.com/mitchellh/mapstructure v1.5.0 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/pelletier/go-toml/v2 v2.2.2 // indirect
+ github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/sagikazarmark/locafero v0.4.0 // indirect
+ github.com/sagikazarmark/slog-shim v0.1.0 // indirect
+ github.com/sourcegraph/conc v0.3.0 // indirect
+ github.com/spf13/afero v1.11.0 // indirect
+ github.com/spf13/cast v1.6.0 // indirect
+ github.com/spf13/pflag v1.0.6 // indirect
+ github.com/subosito/gotenv v1.6.0 // indirect
+ github.com/tklauser/go-sysconf v0.3.12 // indirect
+ github.com/tklauser/numcpus v0.6.1 // indirect
+ github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+ github.com/ugorji/go/codec v1.2.12 // indirect
+ github.com/yusufpapurcu/wmi v1.2.4 // indirect
+ go.uber.org/atomic v1.9.0 // indirect
+ go.uber.org/multierr v1.9.0 // indirect
+ golang.org/x/arch v0.8.0 // indirect
+ golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
+ golang.org/x/net v0.33.0 // indirect
+ golang.org/x/term v0.30.0 // indirect
+ golang.org/x/text v0.23.0 // indirect
+ gopkg.in/ini.v1 v1.67.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..08b8e04
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,245 @@
+github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
+github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
+github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
+github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
+github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
+github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
+github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
+github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
+github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
+github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
+github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
+github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
+github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
+github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
+github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU=
+github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw=
+github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
+github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
+github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
+github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
+github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
+github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
+github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
+github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
+github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
+github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
+github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
+github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
+github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
+github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
+github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
+github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
+github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
+github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
+github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
+github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
+github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
+github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57 h1:LmsF7Fk5jyEDhJk0fYIqdWNuTxSyid2W42A0L2YWjGE=
+github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
+github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
+github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
+github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
+github.com/shirou/gopsutil/v4 v4.25.2 h1:NMscG3l2CqtWFS86kj3vP7soOczqrQYIEhO/pMvvQkk=
+github.com/shirou/gopsutil/v4 v4.25.2/go.mod h1:34gBYJzyqCDT11b6bMHP0XCvWeU3J61XRT7a2EmCRTA=
+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
+github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
+github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
+github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
+github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
+github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
+github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
+github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
+github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
+github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
+github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
+github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
+github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
+github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
+github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
+github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
+github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
+github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
+github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
+github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
+go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
+go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
+go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
+golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
+golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
+golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
+golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
+golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
+golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
+golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
+golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
+golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
+golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
+golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
+golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
+golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
+golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
+golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
+golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
+golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
+golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
+golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
+golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
+golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
+golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
+golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
+google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
+gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
+rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
diff --git a/internal/chatlog/app.go b/internal/chatlog/app.go
new file mode 100644
index 0000000..6ca17e2
--- /dev/null
+++ b/internal/chatlog/app.go
@@ -0,0 +1,409 @@
+package chatlog
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/sjzar/chatlog/internal/chatlog/ctx"
+ "github.com/sjzar/chatlog/internal/ui/footer"
+ "github.com/sjzar/chatlog/internal/ui/help"
+ "github.com/sjzar/chatlog/internal/ui/infobar"
+ "github.com/sjzar/chatlog/internal/ui/menu"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+)
+
+const (
+ RefreshInterval = 1000 * time.Millisecond
+)
+
+type App struct {
+ *tview.Application
+
+ ctx *ctx.Context
+ m *Manager
+ stopRefresh chan struct{}
+
+ // page
+ mainPages *tview.Pages
+ infoBar *infobar.InfoBar
+ tabPages *tview.Pages
+ footer *footer.Footer
+
+ // tab
+ menu *menu.Menu
+ help *help.Help
+ activeTab int
+ tabCount int
+}
+
+func NewApp(ctx *ctx.Context, m *Manager) *App {
+ app := &App{
+ ctx: ctx,
+ m: m,
+ Application: tview.NewApplication(),
+ mainPages: tview.NewPages(),
+ infoBar: infobar.New(),
+ tabPages: tview.NewPages(),
+ footer: footer.New(),
+ menu: menu.New("主菜单"),
+ help: help.New(),
+ }
+
+ app.initMenu()
+
+ return app
+}
+
+func (a *App) Run() error {
+
+ flex := tview.NewFlex().
+ SetDirection(tview.FlexRow).
+ AddItem(a.infoBar, infobar.InfoBarViewHeight, 0, false).
+ AddItem(a.tabPages, 0, 1, true).
+ AddItem(a.footer, 1, 1, false)
+
+ a.mainPages.AddPage("main", flex, true, true)
+
+ a.tabPages.
+ AddPage("0", a.menu, true, true).
+ AddPage("1", a.help, true, false)
+ a.tabCount = 2
+
+ a.SetInputCapture(a.inputCapture)
+
+ go a.refresh()
+
+ if err := a.SetRoot(a.mainPages, true).EnableMouse(false).Run(); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (a *App) Stop() {
+ // 添加一个通道用于停止刷新 goroutine
+ if a.stopRefresh != nil {
+ close(a.stopRefresh)
+ }
+ a.Application.Stop()
+}
+
+func (a *App) switchTab(step int) {
+ index := (a.activeTab + step) % a.tabCount
+ if index < 0 {
+ index = a.tabCount - 1
+ }
+ a.activeTab = index
+ a.tabPages.SwitchToPage(fmt.Sprint(a.activeTab))
+}
+
+func (a *App) refresh() {
+ tick := time.NewTicker(RefreshInterval)
+ defer tick.Stop()
+
+ for {
+ select {
+ case <-a.stopRefresh:
+ return
+ case <-tick.C:
+ a.infoBar.UpdateAccount(a.ctx.Account)
+ a.infoBar.UpdateBasicInfo(a.ctx.PID, a.ctx.Version, a.ctx.ExePath)
+ a.infoBar.UpdateStatus(a.ctx.Status)
+ a.infoBar.UpdateDataKey(a.ctx.DataKey)
+ a.infoBar.UpdateDataUsageDir(a.ctx.DataUsage, a.ctx.DataDir)
+ a.infoBar.UpdateWorkUsageDir(a.ctx.WorkUsage, a.ctx.WorkDir)
+ if a.ctx.HTTPEnabled {
+ a.infoBar.UpdateHTTPServer(fmt.Sprintf("[green][已启动][white] [%s]", a.ctx.HTTPAddr))
+ } else {
+ a.infoBar.UpdateHTTPServer("[未启动]")
+ }
+
+ a.Draw()
+ }
+ }
+}
+
+func (a *App) inputCapture(event *tcell.EventKey) *tcell.EventKey {
+
+ // 如果当前页面不是主页面,ESC 键返回主页面
+ if a.mainPages.HasPage("submenu") && event.Key() == tcell.KeyEscape {
+ a.mainPages.RemovePage("submenu")
+ a.mainPages.SwitchToPage("main")
+ return nil
+ }
+
+ if a.tabPages.HasFocus() {
+ switch event.Key() {
+ case tcell.KeyLeft:
+ a.switchTab(-1)
+ return nil
+ case tcell.KeyRight:
+ a.switchTab(1)
+ return nil
+ }
+ }
+
+ switch event.Key() {
+ case tcell.KeyCtrlC:
+ a.Stop()
+ }
+
+ return event
+}
+
+func (a *App) initMenu() {
+ getDataKey := &menu.Item{
+ Index: 2,
+ Name: "获取数据密钥",
+ Description: "从进程获取数据密钥",
+ Selected: func(i *menu.Item) {
+ if err := a.m.GetDataKey(); err != nil {
+ a.showError(err)
+ return
+ }
+ a.showInfo("获取数据密钥成功")
+ },
+ }
+
+ decryptData := &menu.Item{
+ Index: 3,
+ Name: "解密数据",
+ Description: "解密数据文件",
+ Selected: func(i *menu.Item) {
+ // 创建一个没有按钮的模态框,显示"解密中..."
+ modal := tview.NewModal().
+ SetText("解密中...")
+
+ a.mainPages.AddPage("modal", modal, true, true)
+ a.SetFocus(modal)
+
+ // 在后台执行解密操作
+ go func() {
+ // 执行解密
+ err := a.m.DecryptDBFiles()
+
+ // 在主线程中更新UI
+ a.QueueUpdateDraw(func() {
+ if err != nil {
+ // 解密失败
+ modal.SetText("解密失败: " + err.Error())
+ } else {
+ // 解密成功
+ modal.SetText("解密数据成功")
+ }
+
+ // 添加确认按钮
+ modal.AddButtons([]string{"OK"})
+ modal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
+ a.mainPages.RemovePage("modal")
+ })
+ a.SetFocus(modal)
+ })
+ }()
+ },
+ }
+
+ httpServer := &menu.Item{
+ Index: 4,
+ Name: "启动 HTTP 服务",
+ Description: "启动本地 HTTP & MCP 服务器",
+ Selected: func(i *menu.Item) {
+ modal := tview.NewModal()
+
+ // 根据当前服务状态执行不同操作
+ if !a.ctx.HTTPEnabled {
+ // HTTP 服务未启动,启动服务
+ modal.SetText("正在启动 HTTP 服务...")
+ a.mainPages.AddPage("modal", modal, true, true)
+ a.SetFocus(modal)
+
+ // 在后台启动服务
+ go func() {
+ err := a.m.StartService()
+
+ // 在主线程中更新UI
+ a.QueueUpdateDraw(func() {
+ if err != nil {
+ // 启动失败
+ modal.SetText("启动 HTTP 服务失败: " + err.Error())
+ } else {
+ // 启动成功
+ modal.SetText("已启动 HTTP 服务")
+ // 更改菜单项名称
+ i.Name = "停止 HTTP 服务"
+ i.Description = "停止本地 HTTP & MCP 服务器"
+ }
+
+ // 添加确认按钮
+ modal.AddButtons([]string{"OK"})
+ modal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
+ a.mainPages.RemovePage("modal")
+ })
+ a.SetFocus(modal)
+ })
+ }()
+ } else {
+ // HTTP 服务已启动,停止服务
+ modal.SetText("正在停止 HTTP 服务...")
+ a.mainPages.AddPage("modal", modal, true, true)
+ a.SetFocus(modal)
+
+ // 在后台停止服务
+ go func() {
+ err := a.m.StopService()
+
+ // 在主线程中更新UI
+ a.QueueUpdateDraw(func() {
+ if err != nil {
+ // 停止失败
+ modal.SetText("停止 HTTP 服务失败: " + err.Error())
+ } else {
+ // 停止成功
+ modal.SetText("已停止 HTTP 服务")
+ // 更改菜单项名称
+ i.Name = "启动 HTTP 服务"
+ i.Description = "启动本地 HTTP 服务器"
+ }
+
+ // 添加确认按钮
+ modal.AddButtons([]string{"OK"})
+ modal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
+ a.mainPages.RemovePage("modal")
+ })
+ a.SetFocus(modal)
+ })
+ }()
+ }
+ },
+ }
+
+ setting := &menu.Item{
+ Index: 5,
+ Name: "设置",
+ Description: "设置应用程序选项",
+ Selected: a.settingSelected,
+ }
+
+ a.menu.AddItem(setting)
+ a.menu.AddItem(getDataKey)
+ a.menu.AddItem(decryptData)
+ a.menu.AddItem(httpServer)
+
+ a.menu.AddItem(&menu.Item{
+ Index: 6,
+ Name: "退出",
+ Description: "退出程序",
+ Selected: func(i *menu.Item) {
+ a.Stop()
+ },
+ })
+}
+
+// settingItem 表示一个设置项
+type settingItem struct {
+ name string
+ description string
+ action func()
+}
+
+func (a *App) settingSelected(i *menu.Item) {
+
+ settings := []settingItem{
+ {
+ name: "设置 HTTP 服务端口",
+ description: "配置 HTTP 服务监听的端口",
+ action: a.settingHTTPPort,
+ },
+ {
+ name: "设置工作目录",
+ description: "配置数据解密后的存储目录",
+ action: a.settingWorkDir,
+ },
+ }
+
+ subMenu := menu.NewSubMenu("设置")
+ for idx, setting := range settings {
+ item := &menu.Item{
+ Index: idx + 1,
+ Name: setting.name,
+ Description: setting.description,
+ Selected: func(action func()) func(*menu.Item) {
+ return func(*menu.Item) {
+ action()
+ }
+ }(setting.action),
+ }
+ subMenu.AddItem(item)
+ }
+
+ a.mainPages.AddPage("submenu", subMenu, true, true)
+ a.SetFocus(subMenu)
+}
+
+// settingHTTPPort 设置 HTTP 端口
+func (a *App) settingHTTPPort() {
+ // 实现端口设置逻辑
+ // 这里可以使用 tview.InputField 让用户输入端口
+ form := tview.NewForm().
+ AddInputField("端口", a.ctx.HTTPAddr, 20, nil, func(text string) {
+ a.ctx.SetHTTPAddr(text)
+ }).
+ AddButton("保存", func() {
+ a.mainPages.RemovePage("submenu2")
+ a.showInfo("HTTP 端口已设置为 " + a.ctx.HTTPAddr)
+ }).
+ AddButton("取消", func() {
+ a.mainPages.RemovePage("submenu2")
+ })
+ form.SetBorder(true).SetTitle("设置 HTTP 端口")
+
+ a.mainPages.AddPage("submenu2", form, true, true)
+ a.SetFocus(form)
+}
+
+// settingWorkDir 设置工作目录
+func (a *App) settingWorkDir() {
+ // 实现工作目录设置逻辑
+ form := tview.NewForm().
+ AddInputField("工作目录", a.ctx.WorkDir, 40, nil, func(text string) {
+ a.ctx.SetWorkDir(text)
+ }).
+ AddButton("保存", func() {
+ a.mainPages.RemovePage("submenu2")
+ a.showInfo("工作目录已设置为 " + a.ctx.WorkDir)
+ }).
+ AddButton("取消", func() {
+ a.mainPages.RemovePage("submenu2")
+ })
+ form.SetBorder(true).SetTitle("设置工作目录")
+
+ a.mainPages.AddPage("submenu2", form, true, true)
+ a.SetFocus(form)
+}
+
+// showModal 显示一个模态对话框
+func (a *App) showModal(text string, buttons []string, doneFunc func(buttonIndex int, buttonLabel string)) {
+ modal := tview.NewModal().
+ SetText(text).
+ AddButtons(buttons).
+ SetDoneFunc(doneFunc)
+
+ a.mainPages.AddPage("modal", modal, true, true)
+ a.SetFocus(modal)
+}
+
+// showError 显示错误对话框
+func (a *App) showError(err error) {
+ a.showModal(err.Error(), []string{"OK"}, func(buttonIndex int, buttonLabel string) {
+ a.mainPages.RemovePage("modal")
+ })
+}
+
+// showInfo 显示信息对话框
+func (a *App) showInfo(text string) {
+ a.showModal(text, []string{"OK"}, func(buttonIndex int, buttonLabel string) {
+ a.mainPages.RemovePage("modal")
+ })
+}
diff --git a/internal/chatlog/conf/config.go b/internal/chatlog/conf/config.go
new file mode 100644
index 0000000..79b2ae6
--- /dev/null
+++ b/internal/chatlog/conf/config.go
@@ -0,0 +1,60 @@
+package conf
+
+import "github.com/sjzar/chatlog/pkg/config"
+
+type Config struct {
+ ConfigDir string `mapstructure:"-"`
+ LastAccount string `mapstructure:"last_account" json:"last_account"`
+ History []ProcessConfig `mapstructure:"history" json:"history"`
+}
+
+type ProcessConfig struct {
+ Type string `mapstructure:"type" json:"type"`
+ Version string `mapstructure:"version" json:"version"`
+ MajorVersion int `mapstructure:"major_version" json:"major_version"`
+ Account string `mapstructure:"account" json:"account"`
+ DataKey string `mapstructure:"data_key" json:"data_key"`
+ DataDir string `mapstructure:"data_dir" json:"data_dir"`
+ WorkDir string `mapstructure:"work_dir" json:"work_dir"`
+ HTTPEnabled bool `mapstructure:"http_enabled" json:"http_enabled"`
+ HTTPAddr string `mapstructure:"http_addr" json:"http_addr"`
+ LastTime int64 `mapstructure:"last_time" json:"last_time"`
+ Files []File `mapstructure:"files" json:"files"`
+}
+
+type File struct {
+ Path string `mapstructure:"path" json:"path"`
+ ModifiedTime int64 `mapstructure:"modified_time" json:"modified_time"`
+ Size int64 `mapstructure:"size" json:"size"`
+}
+
+func (c *Config) ParseHistory() map[string]ProcessConfig {
+ m := make(map[string]ProcessConfig)
+ for _, v := range c.History {
+ m[v.Account] = v
+ }
+ return m
+}
+
+func (c *Config) UpdateHistory(account string, conf ProcessConfig) error {
+ if c.History == nil {
+ c.History = make([]ProcessConfig, 0)
+ }
+ if len(c.History) == 0 {
+ c.History = append(c.History, conf)
+ } else {
+ isFind := false
+ for i, v := range c.History {
+ if v.Account == account {
+ isFind = true
+ c.History[i] = conf
+ break
+ }
+ }
+ if !isFind {
+ c.History = append(c.History, conf)
+ }
+ }
+ config.SetConfig("last_account", account)
+ return config.SetConfig("history", c.History)
+}
diff --git a/internal/chatlog/conf/service.go b/internal/chatlog/conf/service.go
new file mode 100644
index 0000000..1edcb36
--- /dev/null
+++ b/internal/chatlog/conf/service.go
@@ -0,0 +1,68 @@
+package conf
+
+import (
+ "log"
+ "os"
+ "sync"
+
+ "github.com/sjzar/chatlog/pkg/config"
+)
+
+const (
+ ConfigName = "chatlog"
+ ConfigType = "json"
+ EnvConfigDir = "CHATLOG_DIR"
+)
+
+// Service 配置服务
+type Service struct {
+ configPath string
+ config *Config
+ mu sync.RWMutex
+}
+
+// NewService 创建配置服务
+func NewService(configPath string) (*Service, error) {
+
+ service := &Service{
+ configPath: configPath,
+ }
+
+ if err := service.Load(); err != nil {
+ return nil, err
+ }
+
+ return service, nil
+}
+
+// Load 加载配置
+func (s *Service) Load() error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ configPath := s.configPath
+ if configPath == "" {
+ configPath = os.Getenv(EnvConfigDir)
+ }
+ if err := config.Init(ConfigName, ConfigType, configPath); err != nil {
+ log.Fatal(err)
+ }
+
+ conf := &Config{}
+ if err := config.Load(conf); err != nil {
+ log.Fatal(err)
+ }
+ conf.ConfigDir = config.ConfigPath
+ s.config = conf
+ return nil
+}
+
+// GetConfig 获取配置副本
+func (s *Service) GetConfig() *Config {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ // 返回配置副本
+ configCopy := *s.config
+ return &configCopy
+}
diff --git a/internal/chatlog/ctx/context.go b/internal/chatlog/ctx/context.go
new file mode 100644
index 0000000..93ae773
--- /dev/null
+++ b/internal/chatlog/ctx/context.go
@@ -0,0 +1,158 @@
+package ctx
+
+import (
+ "sync"
+
+ "github.com/sjzar/chatlog/internal/chatlog/conf"
+ "github.com/sjzar/chatlog/internal/wechat"
+ "github.com/sjzar/chatlog/pkg/util"
+)
+
+// Context is a context for a chatlog.
+// It is used to store information about the chatlog.
+type Context struct {
+ conf *conf.Service
+ mu sync.RWMutex
+
+ History map[string]conf.ProcessConfig
+
+ // 微信账号相关状态
+ Account string
+ Version string
+ MajorVersion int
+ DataKey string
+ DataUsage string
+ DataDir string
+
+ // 工作目录相关状态
+ WorkUsage string
+ WorkDir string
+
+ // HTTP服务相关状态
+ HTTPEnabled bool
+ HTTPAddr string
+
+ // 当前选中的微信实例
+ Current *wechat.Info
+ PID int
+ ExePath string
+ Status string
+
+ // 所有可用的微信实例
+ WeChatInstances []*wechat.Info
+}
+
+func New(conf *conf.Service) *Context {
+ ctx := &Context{
+ conf: conf,
+ }
+
+ ctx.loadConfig()
+
+ return ctx
+}
+
+func (c *Context) loadConfig() {
+ conf := c.conf.GetConfig()
+ c.History = conf.ParseHistory()
+ c.SwitchHistory(conf.LastAccount)
+ c.Refresh()
+}
+
+func (c *Context) SwitchHistory(account string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ history, ok := c.History[account]
+ if ok {
+ c.Account = history.Account
+ c.Version = history.Version
+ c.MajorVersion = history.MajorVersion
+ c.DataKey = history.DataKey
+ c.DataDir = history.DataDir
+ c.WorkDir = history.WorkDir
+ c.HTTPEnabled = history.HTTPEnabled
+ c.HTTPAddr = history.HTTPAddr
+ }
+}
+
+func (c *Context) SwitchCurrent(info *wechat.Info) {
+ c.SwitchHistory(info.AccountName)
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.Current = info
+ c.Refresh()
+
+}
+func (c *Context) Refresh() {
+ if c.Current != nil {
+ c.Account = c.Current.AccountName
+ c.Version = c.Current.Version.FileVersion
+ c.MajorVersion = c.Current.Version.FileMajorVersion
+ c.PID = int(c.Current.PID)
+ c.ExePath = c.Current.ExePath
+ c.Status = c.Current.Status
+ if c.Current.Key != "" && c.Current.Key != c.DataKey {
+ c.DataKey = c.Current.Key
+ }
+ if c.Current.DataDir != "" && c.Current.DataDir != c.DataDir {
+ c.DataDir = c.Current.DataDir
+ }
+ }
+ if c.DataUsage == "" && c.DataDir != "" {
+ go func() {
+ c.DataUsage = util.GetDirSize(c.DataDir)
+ }()
+ }
+ if c.WorkUsage == "" && c.WorkDir != "" {
+ go func() {
+ c.WorkUsage = util.GetDirSize(c.WorkDir)
+ }()
+ }
+}
+
+func (c *Context) SetHTTPEnabled(enabled bool) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.HTTPEnabled = enabled
+ c.UpdateConfig()
+}
+
+func (c *Context) SetHTTPAddr(addr string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.HTTPAddr = addr
+ c.UpdateConfig()
+}
+
+func (c *Context) SetWorkDir(dir string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.WorkDir = dir
+ c.UpdateConfig()
+ c.Refresh()
+}
+
+func (c *Context) SetDataDir(dir string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.DataDir = dir
+ c.UpdateConfig()
+ c.Refresh()
+}
+
+// 更新配置
+func (c *Context) UpdateConfig() {
+ pconf := conf.ProcessConfig{
+ Type: "wechat",
+ Version: c.Version,
+ MajorVersion: c.MajorVersion,
+ Account: c.Account,
+ DataKey: c.DataKey,
+ DataDir: c.DataDir,
+ WorkDir: c.WorkDir,
+ HTTPEnabled: c.HTTPEnabled,
+ HTTPAddr: c.HTTPAddr,
+ }
+ conf := c.conf.GetConfig()
+ conf.UpdateHistory(c.Account, pconf)
+}
diff --git a/internal/chatlog/database/service.go b/internal/chatlog/database/service.go
new file mode 100644
index 0000000..3b75207
--- /dev/null
+++ b/internal/chatlog/database/service.go
@@ -0,0 +1,77 @@
+package database
+
+import (
+ "time"
+
+ "github.com/sjzar/chatlog/internal/chatlog/ctx"
+ "github.com/sjzar/chatlog/internal/wechatdb"
+ "github.com/sjzar/chatlog/pkg/model"
+)
+
+type Service struct {
+ ctx *ctx.Context
+ db *wechatdb.DB
+}
+
+func NewService(ctx *ctx.Context) *Service {
+ return &Service{
+ ctx: ctx,
+ }
+}
+
+func (s *Service) Start() error {
+ db, err := wechatdb.New(s.ctx.WorkDir, s.ctx.MajorVersion)
+ if err != nil {
+ return err
+ }
+ s.db = db
+ return nil
+}
+
+func (s *Service) Stop() error {
+ if s.db != nil {
+ s.db.Close()
+ }
+ s.db = nil
+ return nil
+}
+
+// GetDB returns the underlying database
+func (s *Service) GetDB() *wechatdb.DB {
+ return s.db
+}
+
+// GetMessages retrieves messages based on criteria
+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)
+}
+
+// GetContact retrieves contact information
+func (s *Service) GetContact(userName string) *model.Contact {
+ return s.db.GetContact(userName)
+}
+
+// ListContact retrieves all contacts
+func (s *Service) ListContact() (*wechatdb.ListContactResp, error) {
+ return s.db.ListContact()
+}
+
+// GetChatRoom retrieves chat room information
+func (s *Service) GetChatRoom(name string) *model.ChatRoom {
+ return s.db.GetChatRoom(name)
+}
+
+// ListChatRoom retrieves all chat rooms
+func (s *Service) ListChatRoom() (*wechatdb.ListChatRoomResp, error) {
+ return s.db.ListChatRoom()
+}
+
+// GetSession retrieves session information
+func (s *Service) GetSession(limit int) (*wechatdb.GetSessionResp, error) {
+ return s.db.GetSession(limit)
+}
+
+// Close closes the database connection
+func (s *Service) Close() {
+ // Add cleanup code if needed
+}
diff --git a/internal/chatlog/http/route.go b/internal/chatlog/http/route.go
new file mode 100644
index 0000000..c232be8
--- /dev/null
+++ b/internal/chatlog/http/route.go
@@ -0,0 +1,245 @@
+package http
+
+import (
+ "embed"
+ "fmt"
+ "io/fs"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/sjzar/chatlog/internal/errors"
+ "github.com/sjzar/chatlog/pkg/util"
+
+ "github.com/gin-gonic/gin"
+)
+
+// EFS holds embedded file system data for static assets.
+//
+//go:embed static
+var EFS embed.FS
+
+// initRouter sets up routes and static file servers for the web service.
+// It defines endpoints for API as well as serving static content.
+func (s *Service) initRouter() {
+
+ router := s.GetRouter()
+
+ staticDir, _ := fs.Sub(EFS, "static")
+ router.StaticFS("/static", http.FS(staticDir))
+ router.StaticFileFS("/favicon.ico", "./favicon.ico", http.FS(staticDir))
+ router.StaticFileFS("/", "./index.htm", http.FS(staticDir))
+
+ // MCP Server
+ {
+ router.GET("/sse", s.mcp.HandleSSE)
+ router.POST("/messages", s.mcp.HandleMessages)
+ // mcp inspector is shit
+ // https://github.com/modelcontextprotocol/inspector/blob/aeaf32f/server/src/index.ts#L155
+ router.POST("/message", s.mcp.HandleMessages)
+ }
+
+ // API V1 Router
+ api := router.Group("/api/v1")
+ {
+ api.GET("/chatlog", s.GetChatlog)
+ api.GET("/contact", s.ListContact)
+ api.GET("/chatroom", s.ListChatRoom)
+ api.GET("/session", s.GetSession)
+ }
+
+ router.NoRoute(s.NoRoute)
+}
+
+// NoRoute handles 404 Not Found errors. If the request URL starts with "/api"
+// or "/static", it responds with a JSON error. Otherwise, it redirects to the root path.
+func (s *Service) NoRoute(c *gin.Context) {
+ path := c.Request.URL.Path
+ switch {
+ case strings.HasPrefix(path, "/api"), strings.HasPrefix(path, "/static"):
+ c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
+ default:
+ c.Header("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate, value")
+ c.Redirect(http.StatusFound, "/")
+ }
+}
+
+func (s *Service) GetChatlog(c *gin.Context) {
+
+ var err error
+ start, end, ok := util.TimeRangeOf(c.Query("time"))
+ if !ok {
+ errors.Err(c, errors.ErrInvalidArg("time"))
+ }
+
+ var limit int
+ if _limit := c.Query("limit"); len(_limit) > 0 {
+ limit, err = strconv.Atoi(_limit)
+ if err != nil {
+ errors.Err(c, errors.ErrInvalidArg("limit"))
+ return
+ }
+ }
+
+ var offset int
+ if _offset := c.Query("offset"); len(_offset) > 0 {
+ offset, err = strconv.Atoi(_offset)
+ if err != nil {
+ errors.Err(c, errors.ErrInvalidArg("offset"))
+ return
+ }
+ }
+
+ talker := c.Query("talker")
+
+ if limit < 0 {
+ limit = 0
+ }
+
+ if offset < 0 {
+ offset = 0
+ }
+
+ messages, err := s.db.GetMessages(start, end, talker, limit, offset)
+ if err != nil {
+ errors.Err(c, err)
+ return
+ }
+
+ switch strings.ToLower(c.Query("format")) {
+ case "csv":
+ case "json":
+ // json
+ c.JSON(http.StatusOK, messages)
+ default:
+ // plain text
+ c.Writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ c.Writer.Header().Set("Cache-Control", "no-cache")
+ c.Writer.Header().Set("Connection", "keep-alive")
+ c.Writer.Flush()
+
+ for _, m := range messages {
+ c.Writer.WriteString(m.PlainText(len(talker) == 0))
+ c.Writer.WriteString("\n")
+ c.Writer.Flush()
+ }
+ }
+}
+
+func (s *Service) ListContact(c *gin.Context) {
+ list, err := s.db.ListContact()
+ if err != nil {
+ errors.Err(c, err)
+ return
+ }
+
+ format := strings.ToLower(c.Query("format"))
+ switch format {
+ case "json":
+ // json
+ c.JSON(http.StatusOK, list)
+ default:
+ // csv
+ if format == "csv" {
+ // 浏览器访问时,会下载文件
+ c.Writer.Header().Set("Content-Type", "text/csv; charset=utf-8")
+ } else {
+ c.Writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ }
+ c.Writer.Header().Set("Cache-Control", "no-cache")
+ c.Writer.Header().Set("Connection", "keep-alive")
+ c.Writer.Flush()
+
+ c.Writer.WriteString("UserName,Alias,Remark,NickName\n")
+ for _, contact := range list.Items {
+ c.Writer.WriteString(fmt.Sprintf("%s,%s,%s,%s\n", contact.UserName, contact.Alias, contact.Remark, contact.NickName))
+ }
+ c.Writer.Flush()
+ }
+}
+
+func (s *Service) ListChatRoom(c *gin.Context) {
+
+ if query := c.Query("query"); len(query) > 0 {
+ chatRoom := s.db.GetChatRoom(query)
+ if chatRoom != nil {
+ c.JSON(http.StatusOK, chatRoom)
+ return
+ }
+ }
+
+ list, err := s.db.ListChatRoom()
+ if err != nil {
+ errors.Err(c, err)
+ return
+ }
+ format := strings.ToLower(c.Query("format"))
+ switch format {
+ case "json":
+ // json
+ c.JSON(http.StatusOK, list)
+ default:
+ // csv
+ if format == "csv" {
+ // 浏览器访问时,会下载文件
+ c.Writer.Header().Set("Content-Type", "text/csv; charset=utf-8")
+ } else {
+ c.Writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ }
+ c.Writer.Header().Set("Cache-Control", "no-cache")
+ c.Writer.Header().Set("Connection", "keep-alive")
+ c.Writer.Flush()
+
+ c.Writer.WriteString("Name,Owner,UserCount\n")
+ for _, chatRoom := range list.Items {
+ c.Writer.WriteString(fmt.Sprintf("%s,%s,%d\n", chatRoom.Name, chatRoom.Owner, len(chatRoom.Users)))
+ }
+ c.Writer.Flush()
+ }
+}
+
+func (s *Service) GetSession(c *gin.Context) {
+
+ var err error
+ var limit int
+ if _limit := c.Query("limit"); len(_limit) > 0 {
+ limit, err = strconv.Atoi(_limit)
+ if err != nil {
+ errors.Err(c, errors.ErrInvalidArg("limit"))
+ return
+ }
+ }
+
+ sessions, err := s.db.GetSession(limit)
+ if err != nil {
+ errors.Err(c, err)
+ return
+ }
+ format := strings.ToLower(c.Query("format"))
+ switch format {
+ case "csv":
+ c.Writer.Header().Set("Content-Type", "text/csv; charset=utf-8")
+ c.Writer.Header().Set("Cache-Control", "no-cache")
+ c.Writer.Header().Set("Connection", "keep-alive")
+ c.Writer.Flush()
+
+ c.Writer.WriteString("UserName,NOrder,NickName,Content,NTime\n")
+ for _, session := range sessions.Items {
+ c.Writer.WriteString(fmt.Sprintf("%s,%d,%s,%s,%s\n", session.UserName, session.NOrder, session.NickName, strings.ReplaceAll(session.Content, "\n", "\\n"), session.NTime))
+ }
+ c.Writer.Flush()
+ case "json":
+ // json
+ c.JSON(http.StatusOK, sessions)
+ default:
+ c.Writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ c.Writer.Header().Set("Cache-Control", "no-cache")
+ c.Writer.Header().Set("Connection", "keep-alive")
+ c.Writer.Flush()
+ for _, session := range sessions.Items {
+ c.Writer.WriteString(session.PlainText(120))
+ c.Writer.WriteString("\n")
+ }
+ c.Writer.Flush()
+ }
+}
diff --git a/internal/chatlog/http/service.go b/internal/chatlog/http/service.go
new file mode 100644
index 0000000..5ffe0cd
--- /dev/null
+++ b/internal/chatlog/http/service.go
@@ -0,0 +1,90 @@
+package http
+
+import (
+ "context"
+ "net/http"
+ "time"
+
+ "github.com/sjzar/chatlog/internal/chatlog/ctx"
+ "github.com/sjzar/chatlog/internal/chatlog/database"
+ "github.com/sjzar/chatlog/internal/chatlog/mcp"
+ "github.com/sjzar/chatlog/internal/errors"
+
+ "github.com/gin-gonic/gin"
+ log "github.com/sirupsen/logrus"
+)
+
+type Service struct {
+ ctx *ctx.Context
+ db *database.Service
+ mcp *mcp.Service
+
+ router *gin.Engine
+ server *http.Server
+}
+
+func NewService(ctx *ctx.Context, db *database.Service, mcp *mcp.Service) *Service {
+ gin.SetMode(gin.ReleaseMode)
+ router := gin.New()
+
+ // Handle error from SetTrustedProxies
+ if err := router.SetTrustedProxies(nil); err != nil {
+ log.Error("Failed to set trusted proxies:", err)
+ }
+
+ // Middleware
+ router.Use(
+ gin.Recovery(),
+ gin.LoggerWithWriter(log.StandardLogger().Out),
+ )
+
+ s := &Service{
+ ctx: ctx,
+ db: db,
+ mcp: mcp,
+ router: router,
+ }
+
+ s.initRouter()
+ return s
+}
+
+func (s *Service) Start() error {
+ s.server = &http.Server{
+ Addr: s.ctx.HTTPAddr,
+ Handler: s.router,
+ }
+
+ go func() {
+ // Handle error from Run
+ if err := s.server.ListenAndServe(); err != nil {
+ log.Error("Server Stopped: ", err)
+ }
+ }()
+
+ log.Info("Server started on ", s.ctx.HTTPAddr)
+
+ return nil
+}
+
+func (s *Service) Stop() error {
+
+ if s.server == nil {
+ return nil
+ }
+
+ // 使用超时上下文优雅关闭
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ if err := s.server.Shutdown(ctx); err != nil {
+ return errors.HTTP("HTTP server shutdown error", err)
+ }
+
+ log.Info("HTTP server stopped")
+ return nil
+}
+
+func (s *Service) GetRouter() *gin.Engine {
+ return s.router
+}
diff --git a/internal/chatlog/http/static/index.htm b/internal/chatlog/http/static/index.htm
new file mode 100644
index 0000000..d0a071e
--- /dev/null
+++ b/internal/chatlog/http/static/index.htm
@@ -0,0 +1,156 @@
+
+
+
+
+
+
+
+ Chatlog
+
+
+
+
+
+ ('-. .-. ('-. .-') _
+ ( OO ) / ( OO ).-. ( OO) )
+ .-----. ,--. ,--. / . --. / / '._ ,--. .-'),-----. ,----.
+ ' .--./ | | | | | \-. \ |'--...__) | |.-') ( OO' .-. ' ' .-./-')
+ | |('-. | .| | .-'-' | | '--. .--' | | OO ) / | | | | | |_( O- )
+ /_) |OO ) | | \| |_.' | | | | |`-' | \_) | |\| | | | .--, \
+ || |`-'| | .-. | | .-. | | | (| '---.' \ | | | |(| | '. (_/
+(_' '--'\ | | | | | | | | | | | | `' '-' ' | '--' |
+ `-----' `--' `--' `--' `--' `--' `------' `-----' `------'
+
+
+ _____ _ _ _
+ / __ \| | | | | |
+ | / \/| |__ __ _ | |_ | | ___ __ _
+ | | | '_ \ / _` || __|| | / _ \ / _` |
+ | \__/\| | | || (_| || |_ | || (_) || (_| |
+ \____/|_| |_| \__,_| \__||_| \___/ \__, |
+ __/ |
+ |___/
+
+
+
+ ,-----. ,--. ,--. ,--.
+ ' .--./ | ,---. ,--,--. ,-' '-. | | ,---. ,---.
+ | | | .-. | ' ,-. | '-. .-' | | | .-. | | .-. |
+ ' '--'\ | | | | \ '-' | | | | | ' '-' ' ' '-' '
+ `-----' `--' `--' `--`--' `--' `--' `---' .`- /
+ `---'
+
+
+ ____ _ _ _
+ / ___| | |__ __ _ | |_ | | ___ __ _
+ | | | '_ \ / _` | | __| | | / _ \ / _` |
+ | |___ | | | | | (_| | | |_ | | | (_) | | (_| |
+ \____| |_| |_| \__,_| \__| |_| \___/ \__, |
+ |___/
+
+
+ _____ _____ _____ _____ _____ _______ _____
+ /\ \ /\ \ /\ \ /\ \ /\ \ /::\ \ /\ \
+ /::\ \ /::\____\ /::\ \ /::\ \ /::\____\ /::::\ \ /::\ \
+ /::::\ \ /:::/ / /::::\ \ \:::\ \ /:::/ / /::::::\ \ /::::\ \
+ /::::::\ \ /:::/ / /::::::\ \ \:::\ \ /:::/ / /::::::::\ \ /::::::\ \
+ /:::/\:::\ \ /:::/ / /:::/\:::\ \ \:::\ \ /:::/ / /:::/~~\:::\ \ /:::/\:::\ \
+ /:::/ \:::\ \ /:::/____/ /:::/__\:::\ \ \:::\ \ /:::/ / /:::/ \:::\ \ /:::/ \:::\ \
+ /:::/ \:::\ \ /::::\ \ /::::\ \:::\ \ /::::\ \ /:::/ / /:::/ / \:::\ \ /:::/ \:::\ \
+ /:::/ / \:::\ \ /::::::\ \ _____ /::::::\ \:::\ \ /::::::\ \ /:::/ / /:::/____/ \:::\____\ /:::/ / \:::\ \
+ /:::/ / \:::\ \ /:::/\:::\ \ /\ \ /:::/\:::\ \:::\ \ /:::/\:::\ \ /:::/ / |:::| | |:::| | /:::/ / \:::\ ___\
+ /:::/____/ \:::\____\/:::/ \:::\ /::\____\/:::/ \:::\ \:::\____\ /:::/ \:::\____\/:::/____/ |:::|____| |:::| |/:::/____/ ___\:::| |
+ \:::\ \ \::/ /\::/ \:::\ /:::/ /\::/ \:::\ /:::/ / /:::/ \::/ /\:::\ \ \:::\ \ /:::/ / \:::\ \ /\ /:::|____|
+ \:::\ \ \/____/ \/____/ \:::\/:::/ / \/____/ \:::\/:::/ / /:::/ / \/____/ \:::\ \ \:::\ \ /:::/ / \:::\ /::\ \::/ /
+ \:::\ \ \::::::/ / \::::::/ / /:::/ / \:::\ \ \:::\ /:::/ / \:::\ \:::\ \/____/
+ \:::\ \ \::::/ / \::::/ / /:::/ / \:::\ \ \:::\__/:::/ / \:::\ \:::\____\
+ \:::\ \ /:::/ / /:::/ / \::/ / \:::\ \ \::::::::/ / \:::\ /:::/ /
+ \:::\ \ /:::/ / /:::/ / \/____/ \:::\ \ \::::::/ / \:::\/:::/ /
+ \:::\ \ /:::/ / /:::/ / \:::\ \ \::::/ / \::::::/ /
+ \:::\____\ /:::/ / /:::/ / \:::\____\ \::/____/ \::::/ /
+ \::/ / \::/ / \::/ / \::/ / ~~ \::/____/
+ \/____/ \/____/ \/____/ \/____/
+
+
+
+ ___ _ _ __ ____ __ _____ ___
+ / __)( )_( ) /__\ (_ _)( ) ( _ ) / __)
+ ( (__ ) _ ( /(__)\ )( )(__ )(_)( ( (_-.
+ \___)(_) (_)(__)(__) (__) (____)(_____) \___/
+
+
+ ________ ___ ___ ________ _________ ___ ________ ________
+ |\ ____\ |\ \|\ \ |\ __ \ |\___ ___\ |\ \ |\ __ \ |\ ____\
+ \ \ \___| \ \ \\\ \ \ \ \|\ \ \|___ \ \_| \ \ \ \ \ \|\ \ \ \ \___|
+ \ \ \ \ \ __ \ \ \ __ \ \ \ \ \ \ \ \ \ \\\ \ \ \ \ ___
+ \ \ \____ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \____ \ \ \\\ \ \ \ \|\ \
+ \ \_______\ \ \__\ \__\ \ \__\ \__\ \ \__\ \ \_______\ \ \_______\ \ \_______\
+ \|_______| \|__|\|__| \|__|\|__| \|__| \|_______| \|_______| \|_______|
+
+
+ ╔═╗┬ ┬┌─┐┌┬┐┬ ┌─┐┌─┐
+ ║ ├─┤├─┤ │ │ │ ││ ┬
+ ╚═╝┴ ┴┴ ┴ ┴ ┴─┘└─┘└─┘
+
+
+ ▄▄· ▄ .▄ ▄▄▄· ▄▄▄▄▄▄▄▌ ▄▄ •
+ ▐█ ▌▪██▪▐█▐█ ▀█ •██ ██• ▪ ▐█ ▀ ▪
+ ██ ▄▄██▀▐█▄█▀▀█ ▐█.▪██▪ ▄█▀▄ ▄█ ▀█▄
+ ▐███▌██▌▐▀▐█ ▪▐▌ ▐█▌·▐█▌▐▌▐█▌.▐▌▐█▄▪▐█
+ ·▀▀▀ ▀▀▀ · ▀ ▀ ▀▀▀ .▀▀▀ ▀█▄▀▪·▀▀▀▀
+
+
+ ,. - ., .·¨'`; ,.·´¨;\ ,., ' , . ., ° ,. ' , ·. ,.-·~·., ‘ ,.-·^*ª'` ·,
+ ,·'´ ,. - , ';\ '; ;'\ '; ;::\ ;´ '· ., ;'´ , ., _';\' / ';\ / ·'´,.-·-., `,'‚ .·´ ,·'´:¯'`·, '\‘
+ ,·´ .'´\:::::;' ;:'\ ' ; ;::'\ ,' ;::'; .´ .-, ';\ \:´¨¯:;' `;::'\:'\ ,' ,'::'\ / .'´\:::::::'\ '\ ° ,´ ,'\:::::::::\,.·\'
+ / ,'´::::'\;:-/ ,' ::; ' ; ;::_';,. ,.' ;:::';° / /:\:'; ;:'\' \::::; ,'::_'\;' ,' ;:::';' ,·' ,'::::\:;:-·-:'; ';\‚ / /:::\;·'´¯'`·;\:::\°
+ ,' ;':::::;'´ '; /\::;' ' .' ,. -·~-·, ;:::'; ' ,' ,'::::'\'; ;::'; ,' ,'::;' ‘ '; ,':::;' ;. ';:::;´ ,' ,':'\‚ ; ;:::;' '\;:·´
+ ; ;:::::; '\*'´\::\' ° '; ;'\::::::::; '/::::; ,.-·' '·~^*'´¨, ';::; ; ;:::; ° ; ,':::;' ' '; ;::; ,'´ .'´\::';‚ '; ;::/ ,·´¯'; °
+ '; ';::::'; '\::'\/.' ; ';:;\;::-··; ;::::; ':, ,·:²*´¨¯'`; ;::'; ; ;::;' ‘ ,' ,'::;' '; ':;: ,.·´,.·´::::\;'° '; '·;' ,.·´, ;'\
+ \ '·:;:'_ ,. -·'´.·´\‘ ':,.·´\;' ;' ,' :::/ ' ,' / \::::::::'; ;::'; ; ;::;'‚ ; ';_:,.-·´';\‘ \·, `*´,.·'´::::::;·´ \'·. `'´,.·:´'; ;::\'
+ '\:` · .,. -·:´::::::\' \:::::\ \·.'::::; ,' ,'::::\·²*'´¨¯':,'\:; ',.'\::;'‚ ', _,.-·'´:\:\‘ \\:¯::\:::::::;:·´ '\::\¯::::::::'; ;::'; ‘
+ \:::::::\:::::::;:·'´' \;:·´ \:\::'; \`¨\:::/ \::\' \::\:;'‚ \¨:::::::::::\'; `\:::::\;::·'´ ° `·:\:::;:·´';.·´\::;'
+ `· :;::\;::-·´ `·\;' '\::\;' '\;' ' \;:' ‘ '\;::_;:-·'´‘ ¯ ¯ \::::\;'‚
+ ' `¨' ° '¨ ‘ '\:·´'
+
+
+ ▄████▄ ██░ ██ ▄▄▄ ▄▄▄█████▓ ██▓ ▒█████ ▄████
+ ▒██▀ ▀█ ▓██░ ██▒▒████▄ ▓ ██▒ ▓▒▓██▒ ▒██▒ ██▒ ██▒ ▀█▒
+ ▒▓█ ▄ ▒██▀▀██░▒██ ▀█▄ ▒ ▓██░ ▒░▒██░ ▒██░ ██▒▒██░▄▄▄░
+ ▒▓▓▄ ▄██▒░▓█ ░██ ░██▄▄▄▄██ ░ ▓██▓ ░ ▒██░ ▒██ ██░░▓█ ██▓
+ ▒ ▓███▀ ░░▓█▒░██▓ ▓█ ▓██▒ ▒██▒ ░ ░██████▒░ ████▓▒░░▒▓███▀▒
+ ░ ░▒ ▒ ░ ▒ ░░▒░▒ ▒▒ ▓▒█░ ▒ ░░ ░ ▒░▓ ░░ ▒░▒░▒░ ░▒ ▒
+ ░ ▒ ▒ ░▒░ ░ ▒ ▒▒ ░ ░ ░ ░ ▒ ░ ░ ▒ ▒░ ░ ░
+ ░ ░ ░░ ░ ░ ▒ ░ ░ ░ ░ ░ ░ ▒ ░ ░ ░
+ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░
+ ░
+
+
+ ▄█▄ ▄ █ ██ ▄▄▄▄▀ █ ████▄ ▄▀
+ █▀ ▀▄ █ █ █ █ ▀▀▀ █ █ █ █ ▄▀
+ █ ▀ ██▀▀█ █▄▄█ █ █ █ █ █ ▀▄
+ █▄ ▄▀ █ █ █ █ █ ███▄ ▀████ █ █
+ ▀███▀ █ █ ▀ ▀ ███
+ ▀ █
+ ▀
+
+
+
+
+
diff --git a/internal/chatlog/manager.go b/internal/chatlog/manager.go
new file mode 100644
index 0000000..b55174f
--- /dev/null
+++ b/internal/chatlog/manager.go
@@ -0,0 +1,202 @@
+package chatlog
+
+import (
+ "fmt"
+ "path/filepath"
+
+ "github.com/sjzar/chatlog/internal/chatlog/conf"
+ "github.com/sjzar/chatlog/internal/chatlog/ctx"
+ "github.com/sjzar/chatlog/internal/chatlog/database"
+ "github.com/sjzar/chatlog/internal/chatlog/http"
+ "github.com/sjzar/chatlog/internal/chatlog/mcp"
+ "github.com/sjzar/chatlog/internal/chatlog/wechat"
+ "github.com/sjzar/chatlog/pkg/util"
+)
+
+// Manager 管理聊天日志应用
+type Manager struct {
+ conf *conf.Service
+ ctx *ctx.Context
+
+ // Services
+ db *database.Service
+ http *http.Service
+ mcp *mcp.Service
+ wechat *wechat.Service
+
+ // Terminal UI
+ app *App
+}
+
+func New(configPath string) (*Manager, error) {
+
+ // 创建配置服务
+ conf, err := conf.NewService(configPath)
+ if err != nil {
+ return nil, err
+ }
+
+ // 创建应用上下文
+ ctx := ctx.New(conf)
+
+ wechat := wechat.NewService(ctx)
+
+ db := database.NewService(ctx)
+
+ mcp := mcp.NewService(ctx, db)
+
+ http := http.NewService(ctx, db, mcp)
+
+ return &Manager{
+ conf: conf,
+ ctx: ctx,
+ db: db,
+ mcp: mcp,
+ http: http,
+ wechat: wechat,
+ }, nil
+}
+
+func (m *Manager) Run() error {
+
+ m.ctx.WeChatInstances = m.wechat.GetWeChatInstances()
+ if len(m.ctx.WeChatInstances) >= 1 {
+ m.ctx.SwitchCurrent(m.ctx.WeChatInstances[0])
+ }
+
+ if m.ctx.HTTPEnabled {
+ // 启动HTTP服务
+ if err := m.StartService(); err != nil {
+ return err
+ }
+ }
+ // 启动终端UI
+ m.app = NewApp(m.ctx, m)
+ m.app.Run() // 阻塞
+ return nil
+}
+
+func (m *Manager) StartService() error {
+
+ // 按依赖顺序启动服务
+ if err := m.db.Start(); err != nil {
+ return err
+ }
+
+ if err := m.mcp.Start(); err != nil {
+ m.db.Stop() // 回滚已启动的服务
+ return err
+ }
+
+ if err := m.http.Start(); err != nil {
+ m.mcp.Stop() // 回滚已启动的服务
+ m.db.Stop()
+ return err
+ }
+
+ // 更新状态
+ m.ctx.SetHTTPEnabled(true)
+
+ return nil
+}
+
+func (m *Manager) StopService() error {
+ // 按依赖的反序停止服务
+ var errs []error
+
+ if err := m.http.Stop(); err != nil {
+ errs = append(errs, err)
+ }
+
+ if err := m.mcp.Stop(); err != nil {
+ errs = append(errs, err)
+ }
+
+ if err := m.db.Stop(); err != nil {
+ errs = append(errs, err)
+ }
+
+ // 更新状态
+ m.ctx.SetHTTPEnabled(false)
+
+ // 如果有错误,返回第一个错误
+ if len(errs) > 0 {
+ return errs[0]
+ }
+
+ return nil
+}
+
+func (m *Manager) GetDataKey() error {
+ if m.ctx.Current == nil {
+ return fmt.Errorf("未选择任何账号")
+ }
+ if _, err := m.wechat.GetDataKey(m.ctx.Current); err != nil {
+ return err
+ }
+ m.ctx.Refresh()
+ m.ctx.UpdateConfig()
+ return nil
+}
+
+func (m *Manager) DecryptDBFiles() error {
+ if m.ctx.DataKey == "" {
+ if m.ctx.Current == nil {
+ return fmt.Errorf("未选择任何账号")
+ }
+ if err := m.GetDataKey(); err != nil {
+ return err
+ }
+ }
+ if m.ctx.WorkDir == "" {
+ m.ctx.WorkDir = util.DefaultWorkDir(m.ctx.Account)
+ }
+
+ if err := m.wechat.DecryptDBFiles(m.ctx.DataDir, m.ctx.WorkDir, m.ctx.DataKey, m.ctx.MajorVersion); err != nil {
+ return err
+ }
+ m.ctx.Refresh()
+ m.ctx.UpdateConfig()
+ return nil
+}
+
+func (m *Manager) CommandKey(pid int) (string, error) {
+ instances := m.wechat.GetWeChatInstances()
+ if len(instances) == 0 {
+ return "", fmt.Errorf("wechat process not found")
+ }
+ if len(instances) == 1 {
+ return instances[0].GetKey()
+ }
+ if pid == 0 {
+ str := "Select a process:\n"
+ for _, ins := range instances {
+ str += fmt.Sprintf("PID: %d. %s[Version: %s Data Dir: %s ]\n", ins.PID, ins.AccountName, ins.Version.FileVersion, ins.DataDir)
+ }
+ return str, nil
+ }
+ for _, ins := range instances {
+ if ins.PID == uint32(pid) {
+ return ins.GetKey()
+ }
+ }
+ return "", fmt.Errorf("wechat process not found")
+}
+
+func (m *Manager) CommandDecrypt(dataDir string, workDir string, key string, version int) error {
+ if dataDir == "" {
+ return fmt.Errorf("dataDir is required")
+ }
+ if key == "" {
+ return fmt.Errorf("key is required")
+ }
+ if workDir == "" {
+ workDir = util.DefaultWorkDir(filepath.Base(filepath.Dir(dataDir)))
+ }
+
+ if err := m.wechat.DecryptDBFiles(dataDir, workDir, key, version); err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/internal/chatlog/mcp/const.go b/internal/chatlog/mcp/const.go
new file mode 100644
index 0000000..35ffe63
--- /dev/null
+++ b/internal/chatlog/mcp/const.go
@@ -0,0 +1,96 @@
+package mcp
+
+import (
+ "github.com/sjzar/chatlog/internal/mcp"
+)
+
+// MCPTools 和资源定义
+var (
+ InitializeResponse = mcp.InitializeResponse{
+ ProtocolVersion: mcp.ProtocolVersion,
+ Capabilities: mcp.DefaultCapabilities,
+ ServerInfo: mcp.ServerInfo{
+ Name: "chatlog",
+ Version: "0.0.1",
+ },
+ }
+
+ ToolContact = mcp.Tool{
+ Name: "query_contact",
+ Description: "查询用户的联系人信息。可以通过姓名、备注名或ID进行查询,返回匹配的联系人列表。当用户询问某人的联系方式、想了解联系人信息或需要查找特定联系人时使用此工具。参数为空时,将返回联系人列表",
+ InputSchema: mcp.ToolSchema{
+ Type: "object",
+ Properties: mcp.M{
+ "query": mcp.M{
+ "type": "string",
+ "description": "联系人的搜索关键词,可以是姓名、备注名或ID。",
+ },
+ },
+ },
+ }
+
+ ToolChatRoom = mcp.Tool{
+ Name: "query_chat_room",
+ Description: "查询用户参与的群聊信息。可以通过群名称、群ID或相关关键词进行查询,返回匹配的群聊列表。当用户询问群聊信息、想了解某个群的详情或需要查找特定群聊时使用此工具。",
+ InputSchema: mcp.ToolSchema{
+ Type: "object",
+ Properties: mcp.M{
+ "query": mcp.M{
+ "type": "string",
+ "description": "群聊的搜索关键词,可以是群名称、群ID或相关描述",
+ },
+ },
+ },
+ }
+
+ ToolRecentChat = mcp.Tool{
+ Name: "query_recent_chat",
+ Description: "查询最近会话列表,包括个人聊天和群聊。当用户想了解最近的聊天记录、查看最近联系过的人或群组时使用此工具。不需要参数,直接返回最近的会话列表。",
+ InputSchema: mcp.ToolSchema{
+ Type: "object",
+ Properties: mcp.M{},
+ },
+ }
+
+ ToolChatLog = mcp.Tool{
+ Name: "chatlog",
+ Description: "查询特定时间或时间段内与特定联系人或群组的聊天记录。当用户需要回顾过去的对话内容、查找特定信息或想了解与某人/某群的历史交流时使用此工具。",
+ InputSchema: mcp.ToolSchema{
+ Type: "object",
+ Properties: mcp.M{
+ "time": mcp.M{
+ "type": "string",
+ "description": "查询的时间点或时间段。可以是具体时间,例如 YYYY-MM-DD,也可以是时间段,例如 YYYY-MM-DD~YYYY-MM-DD,时间段之间用\"~\"分隔。",
+ },
+ "talker": mcp.M{
+ "type": "string",
+ "description": "交谈对象,可以是联系人或群聊。支持使用ID、昵称、备注名等进行查询。",
+ },
+ },
+ },
+ }
+
+ ResourceRecentChat = mcp.Resource{
+ Name: "最近会话",
+ URI: "session://recent",
+ Description: "获取最近的聊天会话列表",
+ }
+
+ ResourceTemplateContact = mcp.ResourceTemplate{
+ Name: "联系人信息",
+ URITemplate: "contact://{username}",
+ Description: "获取指定联系人的详细信息",
+ }
+
+ ResourceTemplateChatRoom = mcp.ResourceTemplate{
+ Name: "群聊信息",
+ URITemplate: "chatroom://{roomid}",
+ Description: "获取指定群聊的详细信息",
+ }
+
+ ResourceTemplateChatlog = mcp.ResourceTemplate{
+ Name: "聊天记录",
+ URITemplate: "chatlog://{talker}/{timeframe}?limit,offset",
+ Description: "获取与特定联系人或群聊的聊天记录",
+ }
+)
diff --git a/internal/chatlog/mcp/service.go b/internal/chatlog/mcp/service.go
new file mode 100644
index 0000000..6399286
--- /dev/null
+++ b/internal/chatlog/mcp/service.go
@@ -0,0 +1,357 @@
+package mcp
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/url"
+ "strings"
+
+ "github.com/sjzar/chatlog/internal/chatlog/ctx"
+ "github.com/sjzar/chatlog/internal/chatlog/database"
+ "github.com/sjzar/chatlog/internal/mcp"
+ "github.com/sjzar/chatlog/pkg/util"
+
+ "github.com/gin-gonic/gin"
+)
+
+type Service struct {
+ ctx *ctx.Context
+ db *database.Service
+
+ mcp *mcp.MCP
+}
+
+func NewService(ctx *ctx.Context, db *database.Service) *Service {
+ return &Service{
+ ctx: ctx,
+ db: db,
+ }
+}
+
+// GetMCP 获取底层MCP实例
+func (s *Service) GetMCP() *mcp.MCP {
+ return s.mcp
+}
+
+// Start 启动MCP服务
+func (s *Service) Start() error {
+ s.mcp = mcp.NewMCP()
+ go s.worker()
+ return nil
+}
+
+// Stop 停止MCP服务
+func (s *Service) Stop() error {
+ if s.mcp != nil {
+ s.mcp.Close()
+ }
+ return nil
+}
+
+// worker 处理MCP请求
+func (s *Service) worker() {
+ for {
+ select {
+ case p, ok := <-s.mcp.ProcessChan:
+ if !ok {
+ return
+ }
+ s.processMCP(p.Session, p.Request)
+ }
+ }
+}
+
+func (s *Service) HandleSSE(c *gin.Context) {
+ s.mcp.HandleSSE(c)
+}
+
+func (s *Service) HandleMessages(c *gin.Context) {
+ s.mcp.HandleMessages(c)
+}
+
+// processMCP 处理MCP请求
+func (s *Service) processMCP(session *mcp.Session, req *mcp.Request) {
+ var err error
+ switch req.Method {
+ case mcp.MethodInitialize:
+ err = s.initialize(session, req)
+ case mcp.MethodToolsList:
+ err = s.sendCustomParams(session, req, mcp.M{"tools": []mcp.Tool{
+ ToolContact,
+ ToolChatRoom,
+ ToolRecentChat,
+ ToolChatLog,
+ }})
+ case mcp.MethodToolsCall:
+ err = s.toolsCall(session, req)
+ case mcp.MethodPromptsList:
+ err = s.sendCustomParams(session, req, mcp.M{"prompts": []mcp.Prompt{}})
+ case mcp.MethodResourcesList:
+ err = s.sendCustomParams(session, req, mcp.M{"resources": []mcp.Resource{
+ ResourceRecentChat,
+ }})
+ case mcp.MethodResourcesTemplateList:
+ err = s.sendCustomParams(session, req, mcp.M{"resourceTemplates": []mcp.ResourceTemplate{
+ ResourceTemplateContact,
+ ResourceTemplateChatRoom,
+ ResourceTemplateChatlog,
+ }})
+ case mcp.MethodResourcesRead:
+ err = s.resourcesRead(session, req)
+ case mcp.MethodPing:
+ err = s.sendCustomParams(session, req, struct{}{})
+ }
+
+ if err != nil {
+ session.WriteError(req, err)
+ }
+}
+
+// initialize 处理初始化请求
+func (s *Service) initialize(session *mcp.Session, req *mcp.Request) error {
+ initReq, err := parseParams[mcp.InitializeRequest](req.Params)
+ if err != nil {
+ return fmt.Errorf("解析初始化参数失败: %v", err)
+ }
+ session.SaveClientInfo(initReq.ClientInfo)
+
+ return session.WriteResponse(req, InitializeResponse)
+}
+
+// toolsCall 处理工具调用
+func (s *Service) toolsCall(session *mcp.Session, req *mcp.Request) error {
+ callReq, err := parseParams[mcp.ToolsCallRequest](req.Params)
+ if err != nil {
+ return fmt.Errorf("解析工具调用参数失败: %v", err)
+ }
+
+ buf := &bytes.Buffer{}
+ switch callReq.Name {
+ case "query_contact":
+ query := ""
+ if v, ok := callReq.Arguments["query"]; ok {
+ query = v.(string)
+ }
+ if len(query) == 0 {
+ list, err := s.db.ListContact()
+ if err != nil {
+ return fmt.Errorf("无法获取联系人列表: %v", err)
+ }
+ buf.WriteString("UserName,Alias,Remark,NickName\n")
+ for _, contact := range list.Items {
+ buf.WriteString(fmt.Sprintf("%s,%s,%s,%s\n", contact.UserName, contact.Alias, contact.Remark, contact.NickName))
+ }
+ } else {
+ contact := s.db.GetContact(query)
+ if contact == nil {
+ return fmt.Errorf("无法获取联系人: %s", query)
+ }
+ b, err := json.Marshal(contact)
+ if err != nil {
+ return fmt.Errorf("无法序列化联系人: %v", err)
+ }
+ buf.Write(b)
+ }
+ case "query_chat_room":
+ query := ""
+ if v, ok := callReq.Arguments["query"]; ok {
+ query = v.(string)
+ }
+ if len(query) == 0 {
+ list, err := s.db.ListChatRoom()
+ if err != nil {
+ return fmt.Errorf("无法获取群聊列表: %v", err)
+ }
+ buf.WriteString("Name,Remark,NickName,Owner,UserCount\n")
+ for _, chatRoom := range list.Items {
+ buf.WriteString(fmt.Sprintf("%s,%s,%s,%s,%d\n", chatRoom.Name, chatRoom.Remark, chatRoom.NickName, chatRoom.Owner, len(chatRoom.Users)))
+ }
+ } else {
+ chatRoom := s.db.GetChatRoom(query)
+ if chatRoom == nil {
+ return fmt.Errorf("无法获取群聊: %s", query)
+ }
+ b, err := json.Marshal(chatRoom)
+ if err != nil {
+ return fmt.Errorf("无法序列化群聊: %v", err)
+ }
+ buf.Write(b)
+ }
+ case "query_recent_chat":
+ data, err := s.db.GetSession(0)
+ if err != nil {
+ return fmt.Errorf("无法获取会话列表: %v", err)
+ }
+ for _, session := range data.Items {
+ buf.WriteString(session.PlainText(120))
+ buf.WriteString("\n")
+ }
+ case "chatlog":
+ if callReq.Arguments == nil {
+ return mcp.ErrInvalidParams
+ }
+ _time := ""
+ if v, ok := callReq.Arguments["time"]; ok {
+ _time = v.(string)
+ }
+ start, end, ok := util.TimeRangeOf(_time)
+ if !ok {
+ return fmt.Errorf("无法解析时间范围")
+ }
+ talker := ""
+ if v, ok := callReq.Arguments["talker"]; ok {
+ talker = v.(string)
+ }
+ limit := util.MustAnyToInt(callReq.Arguments["limit"])
+ if limit == 0 {
+ limit = 100
+ }
+ offset := util.MustAnyToInt(callReq.Arguments["offset"])
+ messages, err := s.db.GetMessages(start, end, talker, limit, offset)
+ if err != nil {
+ return fmt.Errorf("无法获取聊天记录: %v", err)
+ }
+ for _, m := range messages {
+ buf.WriteString(m.PlainText(len(talker) == 0))
+ buf.WriteString("\n")
+ }
+ default:
+ return fmt.Errorf("未支持的工具: %s", callReq.Name)
+ }
+
+ resp := mcp.ToolsCallResponse{
+ Content: []mcp.Content{
+ {Type: "text", Text: buf.String()},
+ },
+ IsError: false,
+ }
+ return session.WriteResponse(req, resp)
+}
+
+// resourcesRead 处理资源读取
+func (s *Service) resourcesRead(session *mcp.Session, req *mcp.Request) error {
+ readReq, err := parseParams[mcp.ResourcesReadRequest](req.Params)
+ if err != nil {
+ return fmt.Errorf("解析资源读取参数失败: %v", err)
+ }
+
+ u, err := url.Parse(readReq.URI)
+ if err != nil {
+ return fmt.Errorf("无法解析URI: %v", err)
+ }
+
+ buf := &bytes.Buffer{}
+ switch u.Scheme {
+ case "contact":
+ if len(u.Host) == 0 {
+ list, err := s.db.ListContact()
+ if err != nil {
+ return fmt.Errorf("无法获取联系人列表: %v", err)
+ }
+ buf.WriteString("UserName,Alias,Remark,NickName\n")
+ for _, contact := range list.Items {
+ buf.WriteString(fmt.Sprintf("%s,%s,%s,%s\n", contact.UserName, contact.Alias, contact.Remark, contact.NickName))
+ }
+ } else {
+ contact := s.db.GetContact(u.Host)
+ if contact == nil {
+ return fmt.Errorf("无法获取联系人: %s", u.Host)
+ }
+ b, err := json.Marshal(contact)
+ if err != nil {
+ return fmt.Errorf("无法序列化联系人: %v", err)
+ }
+ buf.Write(b)
+ }
+ case "chatroom":
+ if len(u.Host) == 0 {
+ list, err := s.db.ListChatRoom()
+ if err != nil {
+ return fmt.Errorf("无法获取群聊列表: %v", err)
+ }
+ buf.WriteString("Name,Remark,NickName,Owner,UserCount\n")
+ for _, chatRoom := range list.Items {
+ buf.WriteString(fmt.Sprintf("%s,%s,%s,%s,%d\n", chatRoom.Name, chatRoom.Remark, chatRoom.NickName, chatRoom.Owner, len(chatRoom.Users)))
+ }
+ } else {
+ chatRoom := s.db.GetChatRoom(u.Host)
+ if chatRoom == nil {
+ return fmt.Errorf("无法获取群聊: %s", u.Host)
+ }
+ b, err := json.Marshal(chatRoom)
+ if err != nil {
+ return fmt.Errorf("无法序列化群聊: %v", err)
+ }
+ buf.Write(b)
+ }
+ case "session":
+ data, err := s.db.GetSession(0)
+ if err != nil {
+ return fmt.Errorf("无法获取会话列表: %v", err)
+ }
+ for _, session := range data.Items {
+ buf.WriteString(session.PlainText(120))
+ buf.WriteString("\n")
+ }
+ case "chatlog":
+ start, end, ok := util.TimeRangeOf(strings.TrimPrefix(u.Path, "/"))
+ if !ok {
+ return fmt.Errorf("无法解析时间范围")
+ }
+ limit := util.MustAnyToInt(u.Query().Get("limit"))
+ if limit == 0 {
+ limit = 100
+ }
+ offset := util.MustAnyToInt(u.Query().Get("offset"))
+ messages, err := s.db.GetMessages(start, end, u.Host, limit, offset)
+ if err != nil {
+ return fmt.Errorf("无法获取聊天记录: %v", err)
+ }
+ for _, m := range messages {
+ buf.WriteString(m.PlainText(len(u.Host) == 0))
+ buf.WriteString("\n")
+ }
+ default:
+ return fmt.Errorf("不支持的URI: %s", readReq.URI)
+ }
+
+ resp := mcp.ReadingResource{
+ Contents: []mcp.ReadingResourceContent{
+ {URI: readReq.URI, Text: buf.String()},
+ },
+ }
+ return session.WriteResponse(req, resp)
+}
+
+// sendCustomParams 发送自定义参数
+func (s *Service) sendCustomParams(session *mcp.Session, req *mcp.Request, params interface{}) error {
+ b, err := json.Marshal(mcp.NewResponse(req.ID, params))
+ if err != nil {
+ return fmt.Errorf("无法序列化响应: %v", err)
+ }
+ session.Write(b)
+ return nil
+}
+
+// parseParams 解析参数
+func parseParams[T any](params interface{}) (*T, error) {
+ if params == nil {
+ return nil, errors.New("params is nil")
+ }
+
+ // 将 params 重新编码为 JSON
+ jsonData, err := json.Marshal(params)
+ if err != nil {
+ return nil, fmt.Errorf("无法编码 params: %v", err)
+ }
+
+ // 解码到目标结构体
+ var result T
+ if err := json.Unmarshal(jsonData, &result); err != nil {
+ return nil, fmt.Errorf("无法解码为目标结构体: %v", err)
+ }
+
+ return &result, nil
+}
diff --git a/internal/chatlog/wechat/service.go b/internal/chatlog/wechat/service.go
new file mode 100644
index 0000000..6b737bb
--- /dev/null
+++ b/internal/chatlog/wechat/service.go
@@ -0,0 +1,113 @@
+package wechat
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/sjzar/chatlog/internal/chatlog/ctx"
+ "github.com/sjzar/chatlog/internal/wechat"
+ "github.com/sjzar/chatlog/pkg/util"
+)
+
+type Service struct {
+ ctx *ctx.Context
+}
+
+func NewService(ctx *ctx.Context) *Service {
+ return &Service{
+ ctx: ctx,
+ }
+}
+
+// GetWeChatInstances returns all running WeChat instances
+func (s *Service) GetWeChatInstances() []*wechat.Info {
+ wechat.Load()
+ return wechat.Items
+}
+
+// GetDataKey extracts the encryption key from a WeChat process
+func (s *Service) GetDataKey(info *wechat.Info) (string, error) {
+ if info == nil {
+ return "", fmt.Errorf("no WeChat instance selected")
+ }
+
+ key, err := info.GetKey()
+ if err != nil {
+ return "", err
+ }
+
+ return key, nil
+}
+
+// FindDBFiles finds all .db files in the specified directory
+func (s *Service) FindDBFiles(rootDir string, recursive bool) ([]string, error) {
+ // Check if directory exists
+ info, err := os.Stat(rootDir)
+ if err != nil {
+ return nil, fmt.Errorf("cannot access directory %s: %w", rootDir, err)
+ }
+
+ if !info.IsDir() {
+ return nil, fmt.Errorf("%s is not a directory", rootDir)
+ }
+
+ var dbFiles []string
+
+ // Define walk function
+ walkFunc := func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ // If a file or directory can't be accessed, log the error but continue
+ fmt.Printf("Warning: Cannot access %s: %v\n", path, err)
+ return nil
+ }
+
+ // If it's a directory and not the root directory, and we're not recursively searching, skip it
+ if info.IsDir() && path != rootDir && !recursive {
+ return filepath.SkipDir
+ }
+
+ // Check if file extension is .db
+ if !info.IsDir() && strings.ToLower(filepath.Ext(path)) == ".db" {
+ dbFiles = append(dbFiles, path)
+ }
+
+ return nil
+ }
+
+ // Start traversal
+ err = filepath.Walk(rootDir, walkFunc)
+ if err != nil {
+ return nil, fmt.Errorf("error traversing directory: %w", err)
+ }
+
+ if len(dbFiles) == 0 {
+ return nil, fmt.Errorf("no .db files found")
+ }
+
+ return dbFiles, nil
+}
+
+func (s *Service) DecryptDBFiles(dataDir string, workDir string, key string, version int) error {
+
+ dbfiles, err := s.FindDBFiles(dataDir, true)
+ if err != nil {
+ return err
+ }
+
+ for _, dbfile := range dbfiles {
+ output := filepath.Join(workDir, dbfile[len(dataDir):])
+ if err := util.PrepareDir(filepath.Dir(output)); err != nil {
+ return err
+ }
+ if err := wechat.DecryptDBFileToFile(dbfile, output, key, version); err != nil {
+ if err == wechat.ErrAlreadyDecrypted {
+ continue
+ }
+ return err
+ }
+ }
+
+ return nil
+}
diff --git a/internal/errors/errors.go b/internal/errors/errors.go
new file mode 100644
index 0000000..12f7e65
--- /dev/null
+++ b/internal/errors/errors.go
@@ -0,0 +1,85 @@
+package errors
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+// 定义错误类型常量
+const (
+ ErrTypeDatabase = "database"
+ ErrTypeWeChat = "wechat"
+ ErrTypeHTTP = "http"
+ ErrTypeConfig = "config"
+ ErrTypeInvalidArg = "invalid_argument"
+)
+
+// AppError 表示应用程序错误
+type AppError struct {
+ Type string `json:"type"` // 错误类型
+ Message string `json:"message"` // 错误消息
+ Cause error `json:"-"` // 原始错误
+ Code int `json:"-"` // HTTP Code
+}
+
+func (e *AppError) Error() string {
+ if e.Cause != nil {
+ return fmt.Sprintf("%s: %s: %v", e.Type, e.Message, e.Cause)
+ }
+ return fmt.Sprintf("%s: %s", e.Type, e.Message)
+}
+
+func (e *AppError) String() string {
+ return e.Error()
+}
+
+// New 创建新的应用错误
+func New(errType, message string, cause error, code int) *AppError {
+ return &AppError{
+ Type: errType,
+ Message: message,
+ Cause: cause,
+ Code: code,
+ }
+}
+
+// ErrInvalidArg 无效参数错误
+func ErrInvalidArg(param string) *AppError {
+ return New(ErrTypeInvalidArg, fmt.Sprintf("invalid arg: %s", param), nil, http.StatusBadRequest)
+}
+
+// Database 创建数据库错误
+func Database(message string, cause error) *AppError {
+ return New(ErrTypeDatabase, message, cause, http.StatusInternalServerError)
+}
+
+// WeChat 创建微信相关错误
+func WeChat(message string, cause error) *AppError {
+ return New(ErrTypeWeChat, message, cause, http.StatusInternalServerError)
+}
+
+// HTTP 创建HTTP服务错误
+func HTTP(message string, cause error) *AppError {
+ return New(ErrTypeHTTP, message, cause, http.StatusInternalServerError)
+}
+
+// Config 创建配置错误
+func Config(message string, cause error) *AppError {
+ return New(ErrTypeConfig, message, cause, http.StatusInternalServerError)
+}
+
+// Err 在HTTP响应中返回错误
+func Err(c *gin.Context, err error) {
+ if appErr, ok := err.(*AppError); ok {
+ c.JSON(appErr.Code, appErr)
+ return
+ }
+
+ // 未知错误
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "type": "unknown",
+ "message": err.Error(),
+ })
+}
diff --git a/internal/mcp/error.go b/internal/mcp/error.go
new file mode 100644
index 0000000..0dae03a
--- /dev/null
+++ b/internal/mcp/error.go
@@ -0,0 +1,55 @@
+package mcp
+
+import (
+ "fmt"
+)
+
+// enum ErrorCode {
+// // Standard JSON-RPC error codes
+// ParseError = -32700,
+// InvalidRequest = -32600,
+// MethodNotFound = -32601,
+// InvalidParams = -32602,
+// InternalError = -32603
+// }
+
+// Error
+type Error struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ Data interface{} `json:"data,omitempty"`
+}
+
+var (
+ ErrParseError = &Error{Code: -32700, Message: "Parse error"}
+ ErrInvalidRequest = &Error{Code: -32600, Message: "Invalid Request"}
+ ErrMethodNotFound = &Error{Code: -32601, Message: "Method not found"}
+ ErrInvalidParams = &Error{Code: -32602, Message: "Invalid params"}
+ ErrInternalError = &Error{Code: -32603, Message: "Internal error"}
+
+ ErrInvalidSessionID = &Error{Code: 400, Message: "Invalid session ID"}
+ ErrSessionNotFound = &Error{Code: 404, Message: "Could not find session"}
+ ErrTooManyRequests = &Error{Code: 429, Message: "Too many requests"}
+)
+
+func (e *Error) Error() string {
+ return fmt.Sprintf("%d: %s", e.Code, e.Message)
+}
+
+func (e *Error) JsonRPC() Response {
+ return Response{
+ JsonRPC: JsonRPCVersion,
+ Error: e,
+ }
+}
+
+func NewErrorResponse(id interface{}, code int, err error) *Response {
+ return &Response{
+ JsonRPC: JsonRPCVersion,
+ ID: id,
+ Error: &Error{
+ Code: code,
+ Message: err.Error(),
+ },
+ }
+}
diff --git a/internal/mcp/initialize.go b/internal/mcp/initialize.go
new file mode 100644
index 0000000..8cf2482
--- /dev/null
+++ b/internal/mcp/initialize.go
@@ -0,0 +1,78 @@
+package mcp
+
+const (
+ MethodInitialize = "initialize"
+ MethodPing = "ping"
+ ProtocolVersion = "2024-11-05"
+)
+
+// {
+// "method": "initialize",
+// "params": {
+// "protocolVersion": "2024-11-05",
+// "capabilities": {
+// "sampling": {},
+// "roots": {
+// "listChanged": true
+// }
+// },
+// "clientInfo": {
+// "name": "mcp-inspector",
+// "version": "0.0.1"
+// }
+// },
+// "jsonrpc": "2.0",
+// "id": 0
+// }
+type InitializeRequest struct {
+ ProtocolVersion string `json:"protocolVersion"`
+ Capabilities M `json:"capabilities"`
+ ClientInfo *ClientInfo `json:"clientInfo"`
+}
+
+type ClientInfo struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
+}
+
+// {
+// "jsonrpc": "2.0",
+// "id": 0,
+// "result": {
+// "protocolVersion": "2024-11-05",
+// "capabilities": {
+// "experimental": {},
+// "prompts": {
+// "listChanged": false
+// },
+// "resources": {
+// "subscribe": false,
+// "listChanged": false
+// },
+// "tools": {
+// "listChanged": false
+// }
+// },
+// "serverInfo": {
+// "name": "weather",
+// "version": "1.4.1"
+// }
+// }
+// }
+type InitializeResponse struct {
+ ProtocolVersion string `json:"protocolVersion"`
+ Capabilities M `json:"capabilities"`
+ ServerInfo ServerInfo `json:"serverInfo"`
+}
+
+type ServerInfo struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
+}
+
+var DefaultCapabilities = M{
+ "experimental": M{},
+ "prompts": M{"listChanged": false},
+ "resources": M{"subscribe": false, "listChanged": false},
+ "tools": M{"listChanged": false},
+}
diff --git a/internal/mcp/jsonrpc.go b/internal/mcp/jsonrpc.go
new file mode 100644
index 0000000..9431c67
--- /dev/null
+++ b/internal/mcp/jsonrpc.go
@@ -0,0 +1,62 @@
+package mcp
+
+const (
+ JsonRPCVersion = "2.0"
+)
+
+// Documents: https://modelcontextprotocol.io/docs/concepts/transports
+
+// Request
+//
+// {
+// jsonrpc: "2.0",
+// id: number | string,
+// method: string,
+// params?: object
+// }
+type Request struct {
+ JsonRPC string `json:"jsonrpc"`
+ ID interface{} `json:"id"`
+ Method string `json:"method"`
+ Params interface{} `json:"params,omitempty"`
+}
+
+// Response
+//
+// {
+// jsonrpc: "2.0",
+// id: number | string,
+// result?: object,
+// error?: {
+// code: number,
+// message: string,
+// data?: unknown
+// }
+// }
+type Response struct {
+ JsonRPC string `json:"jsonrpc"`
+ ID interface{} `json:"id"`
+ Result interface{} `json:"result,omitempty"`
+ Error *Error `json:"error,omitempty"`
+}
+
+func NewResponse(id interface{}, result interface{}) *Response {
+ return &Response{
+ JsonRPC: JsonRPCVersion,
+ ID: id,
+ Result: result,
+ }
+}
+
+// Notifications
+//
+// {
+// jsonrpc: "2.0",
+// method: string,
+// params?: object
+// }
+type Notification struct {
+ JsonRPC string `json:"jsonrpc"`
+ Method string `json:"method"`
+ Params interface{} `json:"params,omitempty"`
+}
diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go
new file mode 100644
index 0000000..79306f7
--- /dev/null
+++ b/internal/mcp/mcp.go
@@ -0,0 +1,107 @@
+package mcp
+
+import (
+ "io"
+ "net/http"
+ "sync"
+
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+ log "github.com/sirupsen/logrus"
+)
+
+const (
+ ProcessChanCap = 1000
+)
+
+type MCP struct {
+ sessions map[string]*Session
+ sessionMu sync.Mutex
+
+ ProcessChan chan ProcessCtx
+}
+
+func NewMCP() *MCP {
+ return &MCP{
+ sessions: make(map[string]*Session),
+ ProcessChan: make(chan ProcessCtx, ProcessChanCap),
+ }
+}
+
+func (m *MCP) HandleSSE(c *gin.Context) {
+ id := uuid.New().String()
+ m.sessionMu.Lock()
+ m.sessions[id] = NewSession(c, id)
+ m.sessionMu.Unlock()
+
+ c.Stream(func(w io.Writer) bool {
+ <-c.Request.Context().Done()
+ return false
+ })
+
+ m.sessionMu.Lock()
+ delete(m.sessions, id)
+ m.sessionMu.Unlock()
+}
+
+func (m *MCP) GetSession(id string) *Session {
+ m.sessionMu.Lock()
+ defer m.sessionMu.Unlock()
+ return m.sessions[id]
+}
+
+func (m *MCP) HandleMessages(c *gin.Context) {
+
+ // panic("xxx")
+
+ // 啊这, 一个 sessionid 有 3 种写法 session_id, sessionId, sessionid
+ // 官方 SDK 是 session_id: https://github.com/modelcontextprotocol/python-sdk/blob/c897868/src/mcp/server/sse.py#L98
+ // 写的是 sessionId: https://github.com/modelcontextprotocol/inspector/blob/aeaf32f/server/src/index.ts#L157
+
+ sessionID := c.Query("session_id")
+ if sessionID == "" {
+ sessionID = c.Query("sessionId")
+ }
+ if sessionID == "" {
+ sessionID = c.Param("sessionid")
+ }
+ if sessionID == "" {
+ c.JSON(http.StatusBadRequest, ErrInvalidSessionID.JsonRPC())
+ c.Abort()
+ return
+ }
+
+ session := m.GetSession(sessionID)
+ if session == nil {
+ c.JSON(http.StatusNotFound, ErrSessionNotFound.JsonRPC())
+ c.Abort()
+ return
+ }
+
+ var req Request
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, ErrInvalidRequest.JsonRPC())
+ c.Abort()
+ return
+ }
+
+ log.Printf("收到消息: %v\n", req)
+ select {
+ case m.ProcessChan <- ProcessCtx{Session: session, Request: &req}:
+ default:
+ c.JSON(http.StatusTooManyRequests, ErrTooManyRequests.JsonRPC())
+ c.Abort()
+ return
+ }
+
+ c.String(http.StatusAccepted, "Accepted")
+}
+
+func (m *MCP) Close() {
+ close(m.ProcessChan)
+}
+
+type ProcessCtx struct {
+ Session *Session
+ Request *Request
+}
diff --git a/internal/mcp/prompt.go b/internal/mcp/prompt.go
new file mode 100644
index 0000000..27284bf
--- /dev/null
+++ b/internal/mcp/prompt.go
@@ -0,0 +1,137 @@
+package mcp
+
+// Document: https://modelcontextprotocol.io/docs/concepts/prompts
+
+const (
+ // Client => Server
+ MethodPromptsList = "prompts/list"
+ MethodPromptsGet = "prompts/get"
+)
+
+// Prompt
+//
+// {
+// name: string; // Unique identifier for the prompt
+// description?: string; // Human-readable description
+// arguments?: [ // Optional list of arguments
+// {
+// name: string; // Argument identifier
+// description?: string; // Argument description
+// required?: boolean; // Whether argument is required
+// }
+// ]
+// }
+type Prompt struct {
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ Arguments []PromptArgument `json:"arguments,omitempty"`
+}
+
+type PromptArgument struct {
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ Required bool `json:"required,omitempty"`
+}
+
+// ListPrompts
+//
+// {
+// prompts: [
+// {
+// name: "analyze-code",
+// description: "Analyze code for potential improvements",
+// arguments: [
+// {
+// name: "language",
+// description: "Programming language",
+// required: true
+// }
+// ]
+// }
+// ]
+// }
+type PromptsListResponse struct {
+ Prompts []Prompt `json:"prompts"`
+}
+
+// Use Prompt
+// Request
+//
+// {
+// method: "prompts/get",
+// params: {
+// name: "analyze-code",
+// arguments: {
+// language: "python"
+// }
+// }
+// }
+//
+// Response
+//
+// {
+// description: "Analyze Python code for potential improvements",
+// messages: [
+// {
+// role: "user",
+// content: {
+// type: "text",
+// text: "Please analyze the following Python code for potential improvements:\n\n```python\ndef calculate_sum(numbers):\n total = 0\n for num in numbers:\n total = total + num\n return total\n\nresult = calculate_sum([1, 2, 3, 4, 5])\nprint(result)\n```"
+// }
+// }
+// ]
+// }
+type PromptsGetRequest struct {
+ Name string `json:"name"`
+ Arguments M `json:"arguments"`
+}
+
+type PromptsGetResponse struct {
+ Description string `json:"description"`
+ Messages []PromptMessage `json:"messages"`
+}
+
+type PromptMessage struct {
+ Role string `json:"role"`
+ Content PromptContent `json:"content"`
+}
+
+type PromptContent struct {
+ Type string `json:"type"`
+ Text string `json:"text,omitempty"`
+ Resource interface{} `json:"resource,omitempty"` // Resource or ResourceTemplate
+}
+
+// {
+// "messages": [
+// {
+// "role": "user",
+// "content": {
+// "type": "text",
+// "text": "Analyze these system logs and the code file for any issues:"
+// }
+// },
+// {
+// "role": "user",
+// "content": {
+// "type": "resource",
+// "resource": {
+// "uri": "logs://recent?timeframe=1h",
+// "text": "[2024-03-14 15:32:11] ERROR: Connection timeout in network.py:127\n[2024-03-14 15:32:15] WARN: Retrying connection (attempt 2/3)\n[2024-03-14 15:32:20] ERROR: Max retries exceeded",
+// "mimeType": "text/plain"
+// }
+// }
+// },
+// {
+// "role": "user",
+// "content": {
+// "type": "resource",
+// "resource": {
+// "uri": "file:///path/to/code.py",
+// "text": "def connect_to_service(timeout=30):\n retries = 3\n for attempt in range(retries):\n try:\n return establish_connection(timeout)\n except TimeoutError:\n if attempt == retries - 1:\n raise\n time.sleep(5)\n\ndef establish_connection(timeout):\n # Connection implementation\n pass",
+// "mimeType": "text/x-python"
+// }
+// }
+// }
+// ]
+// }
diff --git a/internal/mcp/resource.go b/internal/mcp/resource.go
new file mode 100644
index 0000000..20a89d9
--- /dev/null
+++ b/internal/mcp/resource.go
@@ -0,0 +1,74 @@
+package mcp
+
+// Document: https://modelcontextprotocol.io/docs/concepts/resources
+
+const (
+ // Client => Server
+ MethodResourcesList = "resources/list"
+ MethodResourcesTemplateList = "resources/templates/list"
+ MethodResourcesRead = "resources/read"
+ MethodResourcesSubscribe = "resources/subscribe"
+ MethodResourcesUnsubscribe = "resources/unsubscribe"
+
+ // Server => Client
+ NotificationResourcesListChanged = "notifications/resources/list_changed"
+ NofiticationResourcesUpdated = "notifications/resources/updated"
+)
+
+// Direct resources
+//
+// {
+// uri: string; // Unique identifier for the resource
+// name: string; // Human-readable name
+// description?: string; // Optional description
+// mimeType?: string; // Optional MIME type
+// }
+type Resource struct {
+ URI string `json:"uri"`
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ MimeType string `json:"mimeType,omitempty"`
+}
+
+// Resource templates
+//
+// {
+// uriTemplate: string; // URI template following RFC 6570
+// name: string; // Human-readable name for this type
+// description?: string; // Optional description
+// mimeType?: string; // Optional MIME type for all matching resources
+// }
+type ResourceTemplate struct {
+ URITemplate string `json:"uriTemplate"`
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ MimeType string `json:"mimeType,omitempty"`
+}
+
+// Reading resources
+// {
+// contents: [
+// {
+// uri: string; // The URI of the resource
+// mimeType?: string; // Optional MIME type
+
+// // One of:
+// text?: string; // For text resources
+// blob?: string; // For binary resources (base64 encoded)
+// }
+// ]
+// }
+type ReadingResource struct {
+ Contents []ReadingResourceContent `json:"contents"`
+}
+
+type ResourcesReadRequest struct {
+ URI string `json:"uri"`
+}
+
+type ReadingResourceContent struct {
+ URI string `json:"uri"`
+ MimeType string `json:"mimeType,omitempty"`
+ Text string `json:"text,omitempty"`
+ Blob string `json:"blob,omitempty"`
+}
diff --git a/internal/mcp/session.go b/internal/mcp/session.go
new file mode 100644
index 0000000..035405b
--- /dev/null
+++ b/internal/mcp/session.go
@@ -0,0 +1,48 @@
+package mcp
+
+import (
+ "encoding/json"
+ "io"
+
+ "github.com/gin-gonic/gin"
+)
+
+type Session struct {
+ id string
+ w io.Writer
+ c *ClientInfo
+}
+
+func NewSession(c *gin.Context, id string) *Session {
+ return &Session{
+ id: id,
+ w: NewSSEWriter(c, id),
+ }
+}
+
+func (s *Session) Write(p []byte) (n int, err error) {
+ return s.w.Write(p)
+}
+
+func (s *Session) WriteError(req *Request, err error) {
+ resp := NewErrorResponse(req.ID, 500, err)
+ b, err := json.Marshal(resp)
+ if err != nil {
+ return
+ }
+ s.Write(b)
+}
+
+func (s *Session) WriteResponse(req *Request, data interface{}) error {
+ resp := NewResponse(req.ID, data)
+ b, err := json.Marshal(resp)
+ if err != nil {
+ return err
+ }
+ s.Write(b)
+ return nil
+}
+
+func (s *Session) SaveClientInfo(c *ClientInfo) {
+ s.c = c
+}
diff --git a/internal/mcp/sse.go b/internal/mcp/sse.go
new file mode 100644
index 0000000..d7f95d6
--- /dev/null
+++ b/internal/mcp/sse.go
@@ -0,0 +1,160 @@
+package mcp
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/gin-gonic/gin"
+)
+
+const (
+ SSEPingIntervalS = 30
+ SSEMessageChanCap = 100
+ SSEContentType = "text/event-stream; charset=utf-8"
+)
+
+type SSEWriter struct {
+ id string
+ c *gin.Context
+}
+
+func NewSSEWriter(c *gin.Context, id string) *SSEWriter {
+ c.Writer.Header().Set("Content-Type", SSEContentType)
+ c.Writer.Header().Set("Cache-Control", "no-cache")
+ c.Writer.Header().Set("Connection", "keep-alive")
+ c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
+ c.Writer.Flush()
+
+ w := &SSEWriter{
+ id: id,
+ c: c,
+ }
+ w.WriteEndpoing()
+ go w.ping()
+ return w
+}
+
+func (w *SSEWriter) Write(p []byte) (n int, err error) {
+ w.WriteMessage(string(p))
+ return len(p), nil
+}
+
+func (w *SSEWriter) WriteMessage(data string) {
+ w.WriteEvent("message", data)
+}
+
+func (w *SSEWriter) WriteEvent(event string, data string) {
+ w.c.Writer.WriteString(fmt.Sprintf("event: %s\n", event))
+ w.c.Writer.WriteString(fmt.Sprintf("data: %s\n\n", data))
+ w.c.Writer.Flush()
+}
+
+func (w *SSEWriter) ping() {
+ for {
+ select {
+ case <-time.After(time.Second * SSEPingIntervalS):
+ w.writePing()
+ case <-w.c.Request.Context().Done():
+ return
+ }
+ }
+}
+
+// WriteEndpoing
+// event: endpoint
+// data: /message?sessionId=285d67ee-1c17-40d9-ab03-173d5ff48419
+func (w *SSEWriter) WriteEndpoing() {
+ w.c.Writer.WriteString(fmt.Sprintf("event: endpoint\n"))
+ w.c.Writer.WriteString(fmt.Sprintf("data: /message?sessionId=%s\n\n", w.id))
+ w.c.Writer.Flush()
+}
+
+// WritePing
+// : ping - 2025-03-16 06:41:51.280928+00:00
+func (w *SSEWriter) writePing() {
+ w.c.Writer.WriteString(fmt.Sprintf(": ping - %s\n\n", time.Now().Format("2006-01-02 15:04:05.999999-07:00")))
+}
+
+// SSE Session
+// 维持一个 SSE 连接的会话
+// 会话中包含了 SSE 连接的 ID,事件通道,停止通道
+// 事件通道用于发送事件,停止通道用于停止会话
+// 需要轮询发送 ping 事件以保持连接
+type SSESession struct {
+ SessionID string
+ Events map[string]chan string
+ Stop chan bool
+
+ c *gin.Context
+}
+
+func NewSSESession(c *gin.Context) *SSESession {
+ return &SSESession{c: c}
+}
+func (s *SSESession) SendEvent(event string, data string) {
+ s.c.SSEvent(event, data)
+}
+
+func (s *SSESession) Close() {
+ close(s.Stop)
+}
+
+// Event
+// request:
+// POST /messages?sesessionId=?
+// '{"method":"prompts/list","params":{},"jsonrpc":"2.0","id":3}'
+//
+// response:
+// GET /sse
+// event: message
+// data: {"jsonrpc":"2.0","id":3,"result":{"prompts":[]}}
+
+// {
+// "jsonrpc": "2.0",
+// "id": 1,
+// "result": {
+// "tools": [
+// {
+// "name": "get_alerts",
+// "description": "Get weather alerts for a US state.\n\n Args:\n state: Two-letter US state code (e.g. CA, NY)\n ",
+// "inputSchema": {
+// "properties": {
+// "state": {
+// "title": "State",
+// "type": "string"
+// }
+// },
+// "required": [
+// "state"
+// ],
+// "title": "get_alertsArguments",
+// "type": "object"
+// }
+// },
+// {
+// "name": "get_forecast",
+// "description": "Get weather forecast for a location.\n\n Args:\n latitude: Latitude of the location\n longitude: Longitude of the location\n ",
+// "inputSchema": {
+// "properties": {
+// "latitude": {
+// "title": "Latitude",
+// "type": "number"
+// },
+// "longitude": {
+// "title": "Longitude",
+// "type": "number"
+// }
+// },
+// "required": [
+// "latitude",
+// "longitude"
+// ],
+// "title": "get_forecastArguments",
+// "type": "object"
+// }
+// }
+// ]
+// }
+// }
+
+// PING
diff --git a/internal/mcp/stdio.go b/internal/mcp/stdio.go
new file mode 100644
index 0000000..87468eb
--- /dev/null
+++ b/internal/mcp/stdio.go
@@ -0,0 +1 @@
+package mcp
diff --git a/internal/mcp/tool.go b/internal/mcp/tool.go
new file mode 100644
index 0000000..5cf3cce
--- /dev/null
+++ b/internal/mcp/tool.go
@@ -0,0 +1,142 @@
+package mcp
+
+// Document: https://modelcontextprotocol.io/docs/concepts/tools
+
+const (
+ // Client => Server
+ MethodToolsList = "tools/list"
+ MethodToolsCall = "tools/call"
+)
+
+type M map[string]interface{}
+
+// Tool
+//
+// {
+// name: string; // Unique identifier for the tool
+// description?: string; // Human-readable description
+// inputSchema: { // JSON Schema for the tool's parameters
+// type: "object",
+// properties: { ... } // Tool-specific parameters
+// }
+// }
+//
+// {
+// name: "analyze_csv",
+// description: "Analyze a CSV file",
+// inputSchema: {
+// type: "object",
+// properties: {
+// filepath: { type: "string" },
+// operations: {
+// type: "array",
+// items: {
+// enum: ["sum", "average", "count"]
+// }
+// }
+// }
+// }
+// }
+//
+// {
+// "jsonrpc": "2.0",
+// "id": 1,
+// "result": {
+// "tools": [
+// {
+// "name": "get_alerts",
+// "description": "Get weather alerts for a US state.\n\n Args:\n state: Two-letter US state code (e.g. CA, NY)\n ",
+// "inputSchema": {
+// "properties": {
+// "state": {
+// "title": "State",
+// "type": "string"
+// }
+// },
+// "required": [
+// "state"
+// ],
+// "title": "get_alertsArguments",
+// "type": "object"
+// }
+// },
+// {
+// "name": "get_forecast",
+// "description": "Get weather forecast for a location.\n\n Args:\n latitude: Latitude of the location\n longitude: Longitude of the location\n ",
+// "inputSchema": {
+// "properties": {
+// "latitude": {
+// "title": "Latitude",
+// "type": "number"
+// },
+// "longitude": {
+// "title": "Longitude",
+// "type": "number"
+// }
+// },
+// "required": [
+// "latitude",
+// "longitude"
+// ],
+// "title": "get_forecastArguments",
+// "type": "object"
+// }
+// }
+// ]
+// }
+// }
+type Tool struct {
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ InputSchema ToolSchema `json:"inputSchema"`
+}
+
+type ToolSchema struct {
+ Type string `json:"type"`
+ Properties M `json:"properties"`
+}
+
+// {
+// "method": "tools/call",
+// "params": {
+// "name": "chatlog",
+// "arguments": {
+// "start": "2006-11-12",
+// "end": "2020-11-20",
+// "limit": "50",
+// "offset": "6"
+// },
+// "_meta": {
+// "progressToken": 1
+// }
+// },
+// "jsonrpc": "2.0",
+// "id": 3
+// }
+type ToolsCallRequest struct {
+ Name string `json:"name"`
+ Arguments M `json:"arguments"`
+}
+
+// {
+// "jsonrpc": "2.0",
+// "id": 2,
+// "result": {
+// "content": [
+// {
+// "type": "text",
+// "text": "\nEvent: Winter Storm Warning\n"
+// }
+// ],
+// "isError": false
+// }
+// }
+type ToolsCallResponse struct {
+ Content []Content `json:"content"`
+ IsError bool `json:"isError"`
+}
+
+type Content struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+}
diff --git a/internal/ui/footer/footer.go b/internal/ui/footer/footer.go
new file mode 100644
index 0000000..d68f936
--- /dev/null
+++ b/internal/ui/footer/footer.go
@@ -0,0 +1,68 @@
+package footer
+
+import (
+ "fmt"
+
+ "github.com/sjzar/chatlog/internal/ui/style"
+ "github.com/sjzar/chatlog/pkg/version"
+
+ "github.com/rivo/tview"
+)
+
+const (
+ Title = "footer"
+)
+
+type Footer struct {
+ *tview.Flex
+ title string
+ copyRight *tview.TextView
+ help *tview.TextView
+}
+
+func New() *Footer {
+ footer := &Footer{
+ Flex: tview.NewFlex(),
+ title: Title,
+ copyRight: tview.NewTextView(),
+ help: tview.NewTextView(),
+ }
+
+ footer.copyRight.
+ SetDynamicColors(true).
+ SetWrap(true).
+ SetTextAlign(tview.AlignLeft)
+ footer.copyRight.
+ SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor)
+ footer.copyRight.SetText(fmt.Sprintf("[%s::b]%s[-:-:-]", style.GetColorHex(style.PageHeaderFgColor), fmt.Sprintf(" @ Sarv's Chatlog (%s)", version.Version)))
+
+ footer.help.
+ SetDynamicColors(true).
+ SetWrap(true).
+ SetTextAlign(tview.AlignRight)
+ footer.help.
+ SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor)
+
+ fmt.Fprintf(footer.help,
+ "[%s::b]↑/↓[%s::b]: 导航 [%s::b]←/→[%s::b]: 切换标签 [%s::b]Enter[%s::b]: 选择 [%s::b]ESC[%s::b]: 返回 [%s::b]Ctrl+C[%s::b]: 退出",
+ style.GetColorHex(style.MenuBgColor), style.GetColorHex(style.PageHeaderFgColor),
+ style.GetColorHex(style.MenuBgColor), style.GetColorHex(style.PageHeaderFgColor),
+ style.GetColorHex(style.MenuBgColor), style.GetColorHex(style.PageHeaderFgColor),
+ style.GetColorHex(style.MenuBgColor), style.GetColorHex(style.PageHeaderFgColor),
+ style.GetColorHex(style.MenuBgColor), style.GetColorHex(style.PageHeaderFgColor),
+ )
+
+ footer.
+ AddItem(footer.copyRight, 0, 1, false).
+ AddItem(footer.help, 0, 1, false)
+
+ return footer
+}
+
+func (f *Footer) SetCopyRight(text string) {
+ f.copyRight.SetText(text)
+}
+
+func (f *Footer) SetHelp(text string) {
+ f.help.SetText(text)
+}
diff --git a/internal/ui/help/help.go b/internal/ui/help/help.go
new file mode 100644
index 0000000..a0def11
--- /dev/null
+++ b/internal/ui/help/help.go
@@ -0,0 +1,87 @@
+package help
+
+import (
+ "fmt"
+
+ "github.com/sjzar/chatlog/internal/ui/style"
+
+ "github.com/rivo/tview"
+)
+
+const (
+ Title = "help"
+ ShowTitle = "帮助"
+ Content = `[yellow]Chatlog 使用指南[white]
+
+[green]基本操作:[white]
+• 使用 [yellow]←→[white] 键在主菜单和帮助页面之间切换
+• 使用 [yellow]↑↓[white] 键在菜单项之间移动
+• 按 [yellow]Enter[white] 选择菜单项
+• 按 [yellow]Esc[white] 返回上一级菜单
+• 按 [yellow]Ctrl+C[white] 退出程序
+
+[green]使用步骤:[white]
+
+[yellow]1. 获取数据密钥[white]
+ 选择"获取数据密钥"菜单项,程序会自动从运行中的微信进程获取密钥。
+ 如果有多个微信进程,会自动选择当前账号的进程。
+ 确保微信正在运行,否则无法获取密钥。
+
+[yellow]2. 解密数据[white]
+ 选择"解密数据"菜单项,程序会使用获取的密钥解密微信数据库文件。
+ 解密后的文件会保存到工作目录中(可在设置中修改)。
+
+[yellow]3. 启动 HTTP 服务[white]
+ 选择"启动 HTTP 服务"菜单项,启动 HTTP 和 MCP 服务。
+ 启动后可以通过浏览器访问 http://localhost:5030 查看聊天记录。
+
+[yellow]4. 设置选项[white]
+ 选择"设置"菜单项,可以配置:
+ • HTTP 服务端口 - 更改 HTTP 服务的监听端口
+ • 工作目录 - 更改解密数据的存储位置
+
+[green]HTTP API 使用:[white]
+• 聊天记录: [yellow]GET http://localhost:5030/api/v1/chatlog?time=2023-01-01&talker=wxid_xxx[white]
+• 联系人列表: [yellow]GET http://localhost:5030/api/v1/contact[white]
+• 群聊列表: [yellow]GET http://localhost:5030/api/v1/chatroom[white]
+• 会话列表: [yellow]GET http://localhost:5030/api/v1/session[white]
+
+[green]MCP 集成:[white]
+Chatlog 支持 Model Context Protocol,可与支持 MCP 的 AI 助手集成。
+通过 MCP,AI 助手可以直接查询您的聊天记录、联系人和群聊信息。
+
+[green]常见问题:[white]
+• 如果获取密钥失败,请确保微信程序正在运行
+• 如果解密失败,请检查密钥是否正确获取
+• 如果 HTTP 服务启动失败,请检查端口是否被占用
+• 数据目录和工作目录会自动保存,下次启动时自动加载
+
+[green]数据安全:[white]
+• 所有数据处理均在本地完成,不会上传到任何外部服务器
+• 请妥善保管解密后的数据,避免隐私泄露
+`
+)
+
+type Help struct {
+ *tview.TextView
+ title string
+}
+
+func New() *Help {
+ help := &Help{
+ TextView: tview.NewTextView(),
+ title: Title,
+ }
+
+ help.SetDynamicColors(true)
+ help.SetRegions(true)
+ help.SetWrap(true)
+ help.SetTextAlign(tview.AlignLeft)
+ help.SetBorder(true)
+ help.SetBorderColor(style.BorderColor)
+ help.SetTitle(ShowTitle)
+
+ fmt.Fprint(help, Content)
+
+ return help
+}
diff --git a/internal/ui/infobar/infobar.go b/internal/ui/infobar/infobar.go
new file mode 100644
index 0000000..2395f76
--- /dev/null
+++ b/internal/ui/infobar/infobar.go
@@ -0,0 +1,182 @@
+package infobar
+
+import (
+ "fmt"
+
+ "github.com/sjzar/chatlog/internal/ui/style"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+)
+
+const (
+ Title = "infobar"
+)
+
+// InfoBarViewHeight info bar height.
+const (
+ InfoBarViewHeight = 6
+ accountRow = 0
+ pidRow = 1
+ statusRow = 2
+ dataUsageRow = 3
+ workUsageRow = 4
+ httpServerRow = 5
+
+ // 列索引
+ labelCol1 = 0 // 第一列标签
+ valueCol1 = 1 // 第一列值
+ labelCol2 = 2 // 第二列标签
+ valueCol2 = 3 // 第二列值
+ totalCols = 4
+)
+
+// InfoBar implements the info bar primitive.
+type InfoBar struct {
+ *tview.Box
+ title string
+ table *tview.Table
+}
+
+// NewInfoBar returns info bar view.
+func New() *InfoBar {
+ table := tview.NewTable()
+ headerColor := style.InfoBarItemFgColor
+
+ // Account 和 Version 行
+ table.SetCell(
+ accountRow,
+ labelCol1,
+ tview.NewTableCell(fmt.Sprintf(" [%s::]%s", headerColor, "Account:")),
+ )
+ table.SetCell(accountRow, valueCol1, tview.NewTableCell(""))
+
+ table.SetCell(
+ accountRow,
+ labelCol2,
+ tview.NewTableCell(fmt.Sprintf(" [%s::]%s", headerColor, "Version:")),
+ )
+ table.SetCell(accountRow, valueCol2, tview.NewTableCell(""))
+
+ // PID 和 ExePath 行
+ table.SetCell(
+ pidRow,
+ labelCol1,
+ tview.NewTableCell(fmt.Sprintf(" [%s::]%s", headerColor, "PID:")),
+ )
+ table.SetCell(pidRow, valueCol1, tview.NewTableCell(""))
+
+ table.SetCell(
+ pidRow,
+ labelCol2,
+ tview.NewTableCell(fmt.Sprintf(" [%s::]%s", headerColor, "ExePath:")),
+ )
+ table.SetCell(pidRow, valueCol2, tview.NewTableCell(""))
+
+ // Status 和 Key 行
+ table.SetCell(
+ statusRow,
+ labelCol1,
+ tview.NewTableCell(fmt.Sprintf(" [%s::]%s", headerColor, "Status:")),
+ )
+ table.SetCell(statusRow, valueCol1, tview.NewTableCell(""))
+
+ table.SetCell(
+ statusRow,
+ labelCol2,
+ tview.NewTableCell(fmt.Sprintf(" [%s::]%s", headerColor, "Data Key:")),
+ )
+ table.SetCell(statusRow, valueCol2, tview.NewTableCell(""))
+
+ // Data Usage 和 Data Dir 行
+ table.SetCell(
+ dataUsageRow,
+ labelCol1,
+ tview.NewTableCell(fmt.Sprintf(" [%s::]%s", headerColor, "Data Usage:")),
+ )
+ table.SetCell(dataUsageRow, valueCol1, tview.NewTableCell(""))
+
+ table.SetCell(
+ dataUsageRow,
+ labelCol2,
+ tview.NewTableCell(fmt.Sprintf(" [%s::]%s", headerColor, "Data Dir:")),
+ )
+ table.SetCell(dataUsageRow, valueCol2, tview.NewTableCell(""))
+
+ // Work Usage 和 Work Dir 行
+ table.SetCell(
+ workUsageRow,
+ labelCol1,
+ tview.NewTableCell(fmt.Sprintf(" [%s::]%s", headerColor, "Work Usage:")),
+ )
+ table.SetCell(workUsageRow, valueCol1, tview.NewTableCell(""))
+
+ table.SetCell(
+ workUsageRow,
+ labelCol2,
+ tview.NewTableCell(fmt.Sprintf(" [%s::]%s", headerColor, "Work Dir:")),
+ )
+ table.SetCell(workUsageRow, valueCol2, tview.NewTableCell(""))
+
+ // HTTP Server 行
+ table.SetCell(
+ httpServerRow,
+ labelCol1,
+ tview.NewTableCell(fmt.Sprintf(" [%s::]%s", headerColor, "HTTP Server:")),
+ )
+ table.SetCell(httpServerRow, valueCol1, tview.NewTableCell(""))
+
+ // infobar
+ infoBar := &InfoBar{
+ Box: tview.NewBox(),
+ title: Title,
+ table: table,
+ }
+
+ return infoBar
+}
+
+func (info *InfoBar) UpdateAccount(account string) {
+ info.table.GetCell(accountRow, valueCol1).SetText(account)
+}
+
+func (info *InfoBar) UpdateBasicInfo(pid int, version string, exePath string) {
+ info.table.GetCell(pidRow, valueCol1).SetText(fmt.Sprintf("%d", pid))
+ info.table.GetCell(pidRow, valueCol2).SetText(exePath)
+ info.table.GetCell(accountRow, valueCol2).SetText(version)
+}
+
+func (info *InfoBar) UpdateStatus(status string) {
+ info.table.GetCell(statusRow, valueCol1).SetText(status)
+}
+
+func (info *InfoBar) UpdateDataKey(key string) {
+ info.table.GetCell(statusRow, valueCol2).SetText(key)
+}
+
+func (info *InfoBar) UpdateDataUsageDir(dataUsage string, dataDir string) {
+ info.table.GetCell(dataUsageRow, valueCol1).SetText(dataUsage)
+ info.table.GetCell(dataUsageRow, valueCol2).SetText(dataDir)
+}
+
+func (info *InfoBar) UpdateWorkUsageDir(workUsage string, workDir string) {
+ info.table.GetCell(workUsageRow, valueCol1).SetText(workUsage)
+ info.table.GetCell(workUsageRow, valueCol2).SetText(workDir)
+}
+
+// UpdateHTTPServer updates HTTP Server value.
+func (info *InfoBar) UpdateHTTPServer(server string) {
+ info.table.GetCell(httpServerRow, valueCol1).SetText(server)
+}
+
+// Draw draws this primitive onto the screen.
+func (info *InfoBar) Draw(screen tcell.Screen) {
+ info.Box.DrawForSubclass(screen, info)
+ info.Box.SetBorder(false)
+
+ x, y, width, height := info.GetInnerRect()
+
+ info.table.SetRect(x, y, width, height)
+ info.table.SetBorder(false)
+ info.table.Draw(screen)
+}
diff --git a/internal/ui/menu/menu.go b/internal/ui/menu/menu.go
new file mode 100644
index 0000000..766de19
--- /dev/null
+++ b/internal/ui/menu/menu.go
@@ -0,0 +1,162 @@
+package menu
+
+import (
+ "fmt"
+ "sort"
+
+ "github.com/sjzar/chatlog/internal/ui/style"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+)
+
+type Item struct {
+ Index int
+ Key string
+ Name string
+ Description string
+ Hidden bool
+ Selected func(i *Item)
+}
+
+type Menu struct {
+ *tview.Box
+ title string
+ table *tview.Table
+ items []*Item
+}
+
+func New(title string) *Menu {
+ menu := &Menu{
+ Box: tview.NewBox(),
+ title: title,
+ items: make([]*Item, 0),
+ table: tview.NewTable(),
+ }
+
+ menu.table.SetBorders(false)
+ menu.table.SetSelectable(true, false)
+ menu.table.SetTitle(fmt.Sprintf("[::b]%s", menu.title))
+ menu.table.SetBorderColor(style.BorderColor)
+ menu.table.SetBackgroundColor(style.BgColor)
+ menu.table.SetTitleColor(style.FgColor)
+ menu.table.SetFixed(1, 0)
+ menu.table.Select(1, 0).SetSelectedFunc(func(row, column int) {
+ if row == 0 {
+ return // 忽略表头
+ }
+
+ item, ok := menu.table.GetCell(row, 0).GetReference().(*Item)
+ if ok {
+ if item.Selected != nil {
+ item.Selected(item)
+ }
+ }
+ })
+
+ menu.setTableHeader()
+
+ return menu
+}
+
+func (m *Menu) setTableHeader() {
+ m.table.SetCell(0, 0, tview.NewTableCell(fmt.Sprintf("[black::b]%s", "命令")).
+ SetExpansion(1).
+ SetBackgroundColor(style.PageHeaderBgColor).
+ SetTextColor(style.PageHeaderFgColor).
+ SetAlign(tview.AlignLeft).
+ SetSelectable(false))
+
+ m.table.SetCell(0, 1, tview.NewTableCell(fmt.Sprintf("[black::b]%s", "说明")).
+ SetExpansion(2).
+ SetBackgroundColor(style.PageHeaderBgColor).
+ SetTextColor(style.PageHeaderFgColor).
+ SetAlign(tview.AlignLeft).
+ SetSelectable(false))
+}
+
+func (m *Menu) AddItem(item *Item) {
+ m.items = append(m.items, item)
+ sort.Sort(SortItems(m.items))
+ m.refresh()
+}
+
+func (m *Menu) SetItems(items []*Item) {
+ m.items = items
+ m.refresh()
+}
+
+func (m *Menu) GetItems() []*Item {
+ return m.items
+}
+
+func (m *Menu) refresh() {
+ m.table.Clear()
+ m.setTableHeader()
+
+ row := 1
+ for _, item := range m.items {
+ if item.Hidden {
+ continue
+ }
+ m.table.SetCell(row, 0, tview.NewTableCell(item.Name).
+ SetTextColor(style.FgColor).
+ SetBackgroundColor(style.BgColor).
+ SetReference(item).
+ SetAlign(tview.AlignLeft))
+ m.table.SetCell(row, 1, tview.NewTableCell(item.Description).
+ SetTextColor(style.FgColor).
+ SetBackgroundColor(style.BgColor).
+ SetReference(item).
+ SetAlign(tview.AlignLeft))
+ row++
+ }
+
+}
+
+func (m *Menu) Draw(screen tcell.Screen) {
+ m.refresh()
+
+ m.Box.DrawForSubclass(screen, m)
+ m.Box.SetBorder(false)
+
+ menuViewX, menuViewY, menuViewW, menuViewH := m.GetInnerRect()
+
+ m.table.SetRect(menuViewX, menuViewY, menuViewW, menuViewH)
+ m.table.SetBorder(true).SetBorderColor(style.BorderColor)
+
+ m.table.Draw(screen)
+}
+
+func (m *Menu) Focus(delegate func(p tview.Primitive)) {
+ delegate(m.table)
+}
+
+// HasFocus returns whether or not this primitive has focus
+func (m *Menu) HasFocus() bool {
+ // Check if the active menu has focus
+ return m.table.HasFocus()
+}
+
+func (m *Menu) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
+ return m.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
+ // 将事件传递给表格
+ if handler := m.table.InputHandler(); handler != nil {
+ handler(event, setFocus)
+ }
+ })
+}
+
+type SortItems []*Item
+
+func (l SortItems) Len() int {
+ return len(l)
+}
+
+func (l SortItems) Less(i, j int) bool {
+ return l[i].Index < l[j].Index
+}
+
+func (l SortItems) Swap(i, j int) {
+ l[i], l[j] = l[j], l[i]
+}
diff --git a/internal/ui/menu/submenu.go b/internal/ui/menu/submenu.go
new file mode 100644
index 0000000..1a664c9
--- /dev/null
+++ b/internal/ui/menu/submenu.go
@@ -0,0 +1,232 @@
+package menu
+
+import (
+ "fmt"
+ "sort"
+
+ "github.com/sjzar/chatlog/internal/ui/style"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+)
+
+const (
+ // DialogPadding dialog inner paddign.
+ DialogPadding = 3
+
+ // DialogFormHeight dialog "Enter"/"Cancel" form height.
+ DialogHelpHeight = 1
+
+ // DialogMinWidth dialog min width.
+ DialogMinWidth = 40
+
+ // TableHeightOffset table height offset for border.
+ TableHeightOffset = 3
+
+ cmdWidthOffset = 6
+)
+
+type SubMenu struct {
+ *tview.Box
+ title string
+ layout *tview.Flex
+ table *tview.Table
+ width int
+ height int
+ items []*Item
+ cancelHandler func()
+}
+
+func NewSubMenu(title string) *SubMenu {
+ subMenu := &SubMenu{
+ Box: tview.NewBox(),
+ title: title,
+ items: make([]*Item, 0),
+ layout: tview.NewFlex(),
+ table: tview.NewTable(),
+ }
+
+ subMenu.table.SetBorders(false)
+ subMenu.table.SetSelectable(true, false)
+ subMenu.table.SetBorderColor(style.DialogBorderColor)
+ subMenu.table.SetBackgroundColor(style.DialogBgColor)
+ subMenu.table.SetTitleColor(style.DialogFgColor)
+ subMenu.table.SetFixed(1, 1)
+
+ subMenu.table.Select(1, 0).SetSelectedFunc(func(row, column int) {
+ if row == 0 {
+ return // 忽略表头
+ }
+
+ item := subMenu.items[row-1]
+ if item.Selected != nil {
+ item.Selected(item)
+ }
+ })
+
+ subMenu.setTableHeader()
+
+ // 帮助信息
+ helpText := tview.NewTextView()
+ helpText.SetDynamicColors(true)
+ helpText.SetTextAlign(tview.AlignCenter)
+ helpText.SetTextColor(style.DialogFgColor)
+ helpText.SetBackgroundColor(style.DialogBgColor)
+ fmt.Fprintf(helpText,
+ "[%s::b]↑/↓[%s::b]: 导航 [%s::b]Enter[%s::b]: 选择 [%s::b]ESC[%s::b]: 返回",
+ style.GetColorHex(style.MenuBgColor), style.GetColorHex(style.PageHeaderFgColor),
+ style.GetColorHex(style.MenuBgColor), style.GetColorHex(style.PageHeaderFgColor),
+ style.GetColorHex(style.MenuBgColor), style.GetColorHex(style.PageHeaderFgColor),
+ )
+
+ // 布局
+ tableLayout := tview.NewFlex().SetDirection(tview.FlexColumn)
+ tableLayout.AddItem(EmptyBoxSpace(style.DialogBgColor), 1, 0, true)
+ tableLayout.AddItem(subMenu.table, 0, 1, true)
+ tableLayout.AddItem(EmptyBoxSpace(style.DialogBgColor), 1, 0, true)
+
+ subMenu.layout.SetDirection(tview.FlexRow)
+ subMenu.layout.SetTitle(fmt.Sprintf("[::b]%s", subMenu.title))
+ subMenu.layout.SetTitleColor(style.DialogFgColor)
+ subMenu.layout.SetTitleAlign(tview.AlignCenter)
+ subMenu.layout.AddItem(tableLayout, 0, 1, true)
+ subMenu.layout.AddItem(helpText, DialogHelpHeight, 0, true)
+ subMenu.layout.SetBorder(true)
+ subMenu.layout.SetBorderColor(style.DialogBorderColor)
+ subMenu.layout.SetBackgroundColor(style.DialogBgColor)
+
+ return subMenu
+}
+
+func (m *SubMenu) setTableHeader() {
+ m.table.SetCell(0, 0, tview.NewTableCell(fmt.Sprintf("[%s::b]%s", style.GetColorHex(style.TableHeaderFgColor), "命令")).
+ SetExpansion(1).
+ SetBackgroundColor(style.TableHeaderBgColor).
+ SetTextColor(style.TableHeaderFgColor).
+ SetAlign(tview.AlignLeft).
+ SetSelectable(false))
+
+ m.table.SetCell(0, 1, tview.NewTableCell(fmt.Sprintf("[%s::b]%s", style.GetColorHex(style.TableHeaderFgColor), "说明")).
+ SetExpansion(1).
+ SetBackgroundColor(style.TableHeaderBgColor).
+ SetTextColor(style.TableHeaderFgColor).
+ SetAlign(tview.AlignLeft).
+ SetSelectable(false))
+}
+
+func (m *SubMenu) AddItem(item *Item) {
+ m.items = append(m.items, item)
+ sort.Sort(SortItems(m.items))
+ m.refresh()
+}
+
+func (m *SubMenu) SetItems(items []*Item) {
+ m.items = items
+ m.refresh()
+}
+
+func (m *SubMenu) SetCancelFunc(handler func()) *SubMenu {
+ m.cancelHandler = handler
+ return m
+}
+
+func (m *SubMenu) refresh() {
+ m.table.Clear()
+ m.setTableHeader()
+
+ col1Width := 0
+ col2Width := 0
+
+ row := 1
+ for _, item := range m.items {
+ if item.Hidden {
+ continue
+ }
+ m.table.SetCell(row, 0, tview.NewTableCell(item.Name).
+ SetTextColor(style.DialogFgColor).
+ SetBackgroundColor(style.DialogBgColor).
+ SetReference(item).
+ SetAlign(tview.AlignLeft))
+ m.table.SetCell(row, 1, tview.NewTableCell(item.Description).
+ SetTextColor(style.DialogFgColor).
+ SetBackgroundColor(style.DialogBgColor).
+ SetReference(item).
+ SetAlign(tview.AlignLeft))
+ if len(item.Name) > col1Width {
+ col1Width = len(item.Name)
+ }
+ if len(item.Description) > col2Width {
+ col2Width = len(item.Description)
+ }
+ row++
+ }
+
+ m.width = col1Width + col2Width + 2 + cmdWidthOffset
+ m.height = len(m.items) + TableHeightOffset + DialogHelpHeight + 1
+
+}
+
+func (m *SubMenu) Draw(screen tcell.Screen) {
+ m.refresh()
+
+ m.Box.DrawForSubclass(screen, m)
+ m.layout.Draw(screen)
+}
+
+func (m *SubMenu) SetRect(x, y, width, height int) {
+ ws := (width - m.width) / 2
+ hs := ((height - m.height) / 2)
+ dy := y + hs
+ bWidth := m.width
+
+ if m.width > width {
+ ws = 0
+ bWidth = width - 1
+ }
+
+ bHeight := m.height
+
+ if m.height >= height {
+ dy = y + 1
+ bHeight = height - 1
+ }
+
+ m.Box.SetRect(x+ws, dy, bWidth, bHeight)
+
+ x, y, width, height = m.Box.GetInnerRect()
+
+ m.layout.SetRect(x, y, width, height)
+}
+
+func (m *SubMenu) Focus(delegate func(p tview.Primitive)) {
+ delegate(m.table)
+}
+
+// HasFocus returns whether or not this primitive has focus
+func (m *SubMenu) HasFocus() bool {
+ // Check if the active menu has focus
+ return m.table.HasFocus()
+}
+
+func (m *SubMenu) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
+ return m.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
+
+ if event.Key() == tcell.KeyEscape && m.cancelHandler != nil {
+ m.cancelHandler()
+ return
+ }
+
+ // 将事件传递给表格
+ if handler := m.table.InputHandler(); handler != nil {
+ handler(event, setFocus)
+ }
+ })
+}
+
+func EmptyBoxSpace(bgColor tcell.Color) *tview.Box {
+ box := tview.NewBox()
+ box.SetBackgroundColor(bgColor)
+ box.SetBorder(false)
+
+ return box
+}
diff --git a/internal/ui/style/style.go b/internal/ui/style/style.go
new file mode 100644
index 0000000..9509329
--- /dev/null
+++ b/internal/ui/style/style.go
@@ -0,0 +1,78 @@
+//go:build !windows
+// +build !windows
+
+package style
+
+import (
+ "fmt"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+)
+
+const (
+ // HeavyGreenCheckMark unicode.
+ HeavyGreenCheckMark = "\u2705"
+ // HeavyRedCrossMark unicode.
+ HeavyRedCrossMark = "\u274C"
+ // ProgressBar cell.
+ ProgressBarCell = "▉"
+)
+
+var (
+ // infobar.
+ InfoBarItemFgColor = tcell.ColorSilver
+ // main views.
+ FgColor = tcell.ColorFloralWhite
+ BgColor = tview.Styles.PrimitiveBackgroundColor
+ BorderColor = tcell.NewRGBColor(135, 175, 146) //nolint:mnd
+ HelpHeaderFgColor = tcell.NewRGBColor(135, 175, 146) //nolint:mnd
+ MenuBgColor = tcell.ColorMediumSeaGreen
+ PageHeaderBgColor = tcell.ColorMediumSeaGreen
+ PageHeaderFgColor = tcell.ColorFloralWhite
+ RunningStatusFgColor = tcell.NewRGBColor(95, 215, 0) //nolint:mnd
+ PausedStatusFgColor = tcell.NewRGBColor(255, 175, 0) //nolint:mnd
+ // dialogs.
+ DialogBgColor = tcell.NewRGBColor(38, 38, 38) //nolint:mnd
+ DialogBorderColor = tcell.ColorMediumSeaGreen
+ DialogFgColor = tcell.ColorFloralWhite
+ DialogSubBoxBorderColor = tcell.ColorDimGray
+ ErrorDialogBgColor = tcell.NewRGBColor(215, 0, 0) //nolint:mnd
+ ErrorDialogButtonBgColor = tcell.ColorDarkRed
+ // terminal.
+ TerminalFgColor = tcell.ColorFloralWhite
+ TerminalBgColor = tcell.NewRGBColor(5, 5, 5) //nolint:mnd
+ TerminalBorderColor = tcell.ColorDimGray
+ // table header.
+ TableHeaderBgColor = tcell.ColorMediumSeaGreen
+ TableHeaderFgColor = tcell.ColorFloralWhite
+ // progress bar.
+ PrgBgColor = tcell.ColorDimGray
+ PrgBarColor = tcell.ColorDarkOrange
+ PrgBarEmptyColor = tcell.ColorWhite
+ PrgBarOKColor = tcell.ColorGreen
+ PrgBarWarnColor = tcell.ColorOrange
+ PrgBarCritColor = tcell.ColorRed
+ // dropdown.
+ DropDownUnselected = tcell.StyleDefault.Background(tcell.ColorWhiteSmoke).Foreground(tcell.ColorBlack)
+ DropDownSelected = tcell.StyleDefault.Background(tcell.ColorLightSlateGray).Foreground(tcell.ColorWhite)
+ // other primitives.
+ InputFieldBgColor = tcell.ColorGray
+ ButtonBgColor = tcell.ColorMediumSeaGreen
+)
+
+// GetColorName returns convert tcell color to its name.
+func GetColorName(color tcell.Color) string {
+ for name, c := range tcell.ColorNames {
+ if c == color {
+ return name
+ }
+ }
+
+ return ""
+}
+
+// GetColorHex returns convert tcell color to its hex useful for textview primitives.
+func GetColorHex(color tcell.Color) string {
+ return fmt.Sprintf("#%x", color.Hex())
+}
diff --git a/internal/ui/style/style_windows.go b/internal/ui/style/style_windows.go
new file mode 100644
index 0000000..e98f09a
--- /dev/null
+++ b/internal/ui/style/style_windows.go
@@ -0,0 +1,81 @@
+//go:build windows
+
+package style
+
+import (
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+)
+
+const (
+ // HeavyGreenCheckMark unicode.
+ HeavyGreenCheckMark = "[green::]\u25CF[-::]"
+ // HeavyRedCrossMark unicode.
+ HeavyRedCrossMark = "[red::]\u25CF[-::]"
+ // ProgressBar cell.
+ ProgressBarCell = "\u2593"
+)
+
+var (
+ // infobar.
+ InfoBarItemFgColor = tcell.ColorGray
+ // main views.
+ FgColor = tview.Styles.PrimaryTextColor
+ BgColor = tview.Styles.PrimitiveBackgroundColor
+ BorderColor = tcell.ColorSpringGreen
+ MenuBgColor = tcell.ColorSpringGreen
+ HelpHeaderFgColor = tcell.ColorSpringGreen
+ PageHeaderBgColor = tcell.ColorSpringGreen
+ PageHeaderFgColor = tview.Styles.PrimaryTextColor
+ RunningStatusFgColor = tcell.ColorLime
+ PausedStatusFgColor = tcell.ColorYellow
+
+ // dialogs.
+ DialogBgColor = tview.Styles.PrimitiveBackgroundColor
+ DialogFgColor = tview.Styles.PrimaryTextColor
+ DialogBorderColor = tcell.ColorSpringGreen
+ DialogSubBoxBorderColor = tcell.ColorGray
+ ErrorDialogBgColor = tcell.ColorRed
+ ErrorDialogButtonBgColor = tcell.ColorSpringGreen
+ // terminal.
+ TerminalBgColor = tview.Styles.PrimitiveBackgroundColor
+ TerminalFgColor = tview.Styles.PrimaryTextColor
+ TerminalBorderColor = tview.Styles.PrimitiveBackgroundColor
+ // table header.
+ TableHeaderBgColor = tcell.ColorSpringGreen
+ TableHeaderFgColor = tview.Styles.PrimaryTextColor
+ // progress bar.
+ PrgBgColor = tview.Styles.PrimaryTextColor
+ PrgBarColor = tcell.ColorFuchsia
+ PrgBarEmptyColor = tcell.ColorWhite
+ PrgBarOKColor = tcell.ColorLime
+ PrgBarWarnColor = tcell.ColorYellow
+ PrgBarCritColor = tcell.ColorRed
+ // dropdown.
+ DropDownUnselected = tcell.StyleDefault.Background(tcell.ColorGray).Foreground(tcell.ColorWhite)
+ DropDownSelected = tcell.StyleDefault.Background(tcell.ColorPurple).Foreground(tview.Styles.PrimaryTextColor)
+ // other primitives.
+ InputFieldBgColor = tcell.ColorGray
+ ButtonBgColor = tcell.ColorSpringGreen
+)
+
+// GetColorName returns convert tcell color to its name.
+func GetColorName(color tcell.Color) string {
+ for name, c := range tcell.ColorNames {
+ if c == color {
+ return name
+ }
+ }
+ return ""
+}
+
+// GetColorHex shall returns convert tcell color to its hex useful for textview primitives,
+// however, for windows nodes it will return color name.
+func GetColorHex(color tcell.Color) string {
+ for name, c := range tcell.ColorNames {
+ if c == color {
+ return name
+ }
+ }
+ return ""
+}
diff --git a/internal/wechat/decrypt.go b/internal/wechat/decrypt.go
new file mode 100644
index 0000000..0184e20
--- /dev/null
+++ b/internal/wechat/decrypt.go
@@ -0,0 +1,415 @@
+package wechat
+
+import (
+ "bytes"
+ "context"
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/hmac"
+ "crypto/sha1"
+ "crypto/sha512"
+ "encoding/binary"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "hash"
+ "io"
+ "os"
+
+ "golang.org/x/crypto/pbkdf2"
+)
+
+// Constants for WeChat database decryption
+const (
+ // Common constants
+ PageSize = 4096
+ KeySize = 32
+ SaltSize = 16
+ AESBlockSize = 16
+ SQLiteHeader = "SQLite format 3\x00"
+
+ // Version specific constants
+ V3IterCount = 64000
+ V4IterCount = 256000
+
+ IVSize = 16 // Same for both versions
+ HmacSHA1Size = 20 // Used in V3
+ HmacSHA512Size = 64 // Used in V4
+)
+
+// Error definitions
+var (
+ ErrHashVerificationFailed = errors.New("hash verification failed")
+ ErrInvalidVersion = errors.New("invalid version, must be 3 or 4")
+ ErrInvalidKey = errors.New("invalid key format")
+ ErrIncorrectKey = errors.New("incorrect decryption key")
+ ErrReadFile = errors.New("failed to read database file")
+ ErrOpenFile = errors.New("failed to open database file")
+ ErrIncompleteRead = errors.New("incomplete header read")
+ ErrCreateCipher = errors.New("failed to create cipher")
+ ErrDecodeKey = errors.New("failed to decode hex key")
+ ErrWriteOutput = errors.New("failed to write output")
+ ErrSeekFile = errors.New("failed to seek in file")
+ ErrOperationCanceled = errors.New("operation was canceled")
+ ErrAlreadyDecrypted = errors.New("file is already decrypted")
+)
+
+// Decryptor handles the decryption of WeChat database files
+type Decryptor struct {
+ // Database file path
+ dbPath string
+
+ // Database properties
+ version int
+ salt []byte
+ page1 []byte
+ reserve int
+
+ // Calculated fields
+ hashFunc func() hash.Hash
+ hmacSize int
+ currentPage int64
+ totalPages int64
+}
+
+// NewDecryptor creates a new Decryptor for the specified database file and version
+func NewDecryptor(dbPath string, version int) (*Decryptor, error) {
+ // Validate version
+ if version != 3 && version != 4 {
+ return nil, ErrInvalidVersion
+ }
+
+ // Open database file
+ fp, err := os.Open(dbPath)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrOpenFile, err)
+ }
+ defer fp.Close()
+
+ // Get file size
+ fileInfo, err := fp.Stat()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get file info: %v", err)
+ }
+
+ // Calculate total pages
+ fileSize := fileInfo.Size()
+ totalPages := fileSize / PageSize
+ if fileSize%PageSize > 0 {
+ totalPages++
+ }
+
+ // Read first page
+ buffer := make([]byte, PageSize)
+ n, err := io.ReadFull(fp, buffer)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrReadFile, err)
+ }
+ if n != PageSize {
+ return nil, fmt.Errorf("%w: expected %d bytes, got %d", ErrIncompleteRead, PageSize, n)
+ }
+
+ // Check if file is already decrypted
+ if bytes.Equal(buffer[:len(SQLiteHeader)-1], []byte(SQLiteHeader[:len(SQLiteHeader)-1])) {
+ return nil, ErrAlreadyDecrypted
+ }
+
+ // Initialize hash function and HMAC size based on version
+ var hashFunc func() hash.Hash
+ var hmacSize int
+
+ if version == 4 {
+ hashFunc = sha512.New
+ hmacSize = HmacSHA512Size
+ } else {
+ hashFunc = sha1.New
+ hmacSize = HmacSHA1Size
+ }
+
+ // Calculate reserve size and MAC offset
+ reserve := IVSize + hmacSize
+ if reserve%AESBlockSize != 0 {
+ reserve = ((reserve / AESBlockSize) + 1) * AESBlockSize
+ }
+
+ return &Decryptor{
+ dbPath: dbPath,
+ version: version,
+ salt: buffer[:SaltSize],
+ page1: buffer,
+ reserve: reserve,
+ hashFunc: hashFunc,
+ hmacSize: hmacSize,
+ totalPages: totalPages,
+ }, nil
+}
+
+// GetTotalPages returns the total number of pages in the database
+func (d *Decryptor) GetTotalPages() int64 {
+ return d.totalPages
+}
+
+// Validate checks if the provided key is valid for this database
+func (d *Decryptor) Validate(key []byte) bool {
+ if len(key) != KeySize {
+ return false
+ }
+ _, macKey := d.calcPBKDF2Key(key)
+ return d.validate(macKey)
+}
+
+func (d *Decryptor) calcPBKDF2Key(key []byte) ([]byte, []byte) {
+ // Generate encryption key from password
+ var encKey []byte
+ if d.version == 4 {
+ encKey = pbkdf2.Key(key, d.salt, V4IterCount, KeySize, sha512.New)
+ } else {
+ encKey = pbkdf2.Key(key, d.salt, V3IterCount, KeySize, sha1.New)
+ }
+
+ // Generate MAC key
+ macSalt := xorBytes(d.salt, 0x3a)
+ macKey := pbkdf2.Key(encKey, macSalt, 2, KeySize, d.hashFunc)
+ return encKey, macKey
+}
+
+func (d *Decryptor) validate(macKey []byte) bool {
+ // Calculate HMAC
+ hashMac := hmac.New(d.hashFunc, macKey)
+
+ dataEnd := PageSize - d.reserve + IVSize
+ hashMac.Write(d.page1[SaltSize:dataEnd])
+
+ // Page number is fixed as 1
+ pageNoBytes := make([]byte, 4)
+ binary.LittleEndian.PutUint32(pageNoBytes, 1)
+ hashMac.Write(pageNoBytes)
+
+ calculatedMAC := hashMac.Sum(nil)
+ storedMAC := d.page1[dataEnd : dataEnd+d.hmacSize]
+
+ return hmac.Equal(calculatedMAC, storedMAC)
+}
+
+// Decrypt decrypts the database using the provided key and writes the result to the writer
+func (d *Decryptor) Decrypt(ctx context.Context, hexKey string, w io.Writer) error {
+ // Decode key
+ key, err := hex.DecodeString(hexKey)
+ if err != nil {
+ return fmt.Errorf("%w: %v", ErrDecodeKey, err)
+ }
+
+ encKey, macKey := d.calcPBKDF2Key(key)
+
+ // Validate key first
+ if !d.validate(macKey) {
+ return ErrIncorrectKey
+ }
+
+ // Open input file
+ dbFile, err := os.Open(d.dbPath)
+ if err != nil {
+ return fmt.Errorf("%w: %v", ErrOpenFile, err)
+ }
+ defer dbFile.Close()
+
+ // Write SQLite header to output
+ _, err = w.Write([]byte(SQLiteHeader))
+ if err != nil {
+ return fmt.Errorf("%w: %v", ErrWriteOutput, err)
+ }
+
+ // Process each page
+ pageBuf := make([]byte, PageSize)
+ d.currentPage = 0
+
+ for curPage := int64(0); curPage < d.totalPages; curPage++ {
+ // Check for cancellation before processing each page
+ select {
+ case <-ctx.Done():
+ return ErrOperationCanceled
+ default:
+ // Continue processing
+ }
+
+ // For the first page, we need to skip the salt
+ if curPage == 0 {
+ // Read the first page
+ _, err = io.ReadFull(dbFile, pageBuf)
+ if err != nil {
+ return fmt.Errorf("%w: %v", ErrReadFile, err)
+ }
+ } else {
+ // Read a full page
+ n, err := io.ReadFull(dbFile, pageBuf)
+ if err != nil {
+ if err == io.EOF || err == io.ErrUnexpectedEOF {
+ // Handle last partial page
+ if n > 0 {
+ // Process partial page
+ // For simplicity, we'll just break here
+ break
+ }
+ }
+ return fmt.Errorf("%w: %v", ErrReadFile, err)
+ }
+ }
+
+ // check if page contains only zeros (v3 & v4 both have this behavior)
+ allZeros := true
+ for _, b := range pageBuf {
+ if b != 0 {
+ allZeros = false
+ break
+ }
+ }
+
+ if allZeros {
+ // Write the zeros page to output
+ _, err = w.Write(pageBuf)
+ if err != nil {
+ return fmt.Errorf("%w: %v", ErrWriteOutput, err)
+ }
+
+ // Update progress
+ d.currentPage = curPage + 1
+ continue
+
+ // // Set current page to total pages to indicate completion
+ // d.currentPage = d.totalPages
+ // return nil
+ }
+
+ // Decrypt the page
+ decryptedPage, err := d.decryptPage(encKey, macKey, pageBuf, curPage)
+ if err != nil {
+ return err
+ }
+
+ // Write decrypted page to output
+ _, err = w.Write(decryptedPage)
+ if err != nil {
+ return fmt.Errorf("%w: %v", ErrWriteOutput, err)
+ }
+
+ // Update progress
+ d.currentPage = curPage + 1
+ }
+
+ return nil
+}
+
+// decryptPage decrypts a single page of the database
+func (d *Decryptor) decryptPage(key, macKey []byte, pageBuf []byte, pageNum int64) ([]byte, error) {
+ offset := 0
+ if pageNum == 0 {
+ offset = SaltSize
+ }
+
+ // Verify HMAC
+ mac := hmac.New(d.hashFunc, macKey)
+ mac.Write(pageBuf[offset : PageSize-d.reserve+IVSize])
+
+ // Convert page number and update HMAC
+ pageNumBytes := make([]byte, 4)
+ binary.LittleEndian.PutUint32(pageNumBytes, uint32(pageNum+1))
+ mac.Write(pageNumBytes)
+
+ hashMac := mac.Sum(nil)
+
+ hashMacStartOffset := PageSize - d.reserve + IVSize
+ hashMacEndOffset := hashMacStartOffset + len(hashMac)
+
+ if !bytes.Equal(hashMac, pageBuf[hashMacStartOffset:hashMacEndOffset]) {
+ return nil, ErrHashVerificationFailed
+ }
+
+ // Decrypt content using AES-256-CBC
+ iv := pageBuf[PageSize-d.reserve : PageSize-d.reserve+IVSize]
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrCreateCipher, err)
+ }
+
+ mode := cipher.NewCBCDecrypter(block, iv)
+
+ // Create a copy of encrypted data for decryption
+ encrypted := make([]byte, PageSize-d.reserve-offset)
+ copy(encrypted, pageBuf[offset:PageSize-d.reserve])
+
+ // Decrypt in place
+ mode.CryptBlocks(encrypted, encrypted)
+
+ // Combine decrypted data with reserve part
+ decryptedPage := append(encrypted, pageBuf[PageSize-d.reserve:PageSize]...)
+
+ return decryptedPage, nil
+}
+
+// xorBytes performs XOR operation on each byte of the array with the specified byte
+func xorBytes(a []byte, b byte) []byte {
+ result := make([]byte, len(a))
+ for i := range a {
+ result[i] = a[i] ^ b
+ }
+ return result
+}
+
+// Utility functions for backward compatibility
+
+// DecryptDBFile decrypts a WeChat database file and returns the decrypted content
+func DecryptDBFile(dbPath string, hexKey string, version int) ([]byte, error) {
+ // Create a buffer to store the decrypted content
+ var buf bytes.Buffer
+
+ // Create a decryptor
+ d, err := NewDecryptor(dbPath, version)
+ if err != nil {
+ return nil, err
+ }
+
+ // Decrypt the database
+ err = d.Decrypt(context.Background(), hexKey, &buf)
+ if err != nil {
+ return nil, err
+ }
+
+ return buf.Bytes(), nil
+}
+
+// DecryptDBFileToFile decrypts a WeChat database file and saves the result to the specified output file
+func DecryptDBFileToFile(dbPath, outputPath, hexKey string, version int) error {
+ // Create output file
+ outputFile, err := os.Create(outputPath)
+ if err != nil {
+ return fmt.Errorf("failed to create output file: %v", err)
+ }
+ defer outputFile.Close()
+
+ // Create a decryptor
+ d, err := NewDecryptor(dbPath, version)
+ if err != nil {
+ return err
+ }
+
+ // Decrypt the database
+ return d.Decrypt(context.Background(), hexKey, outputFile)
+}
+
+// ValidateDBKey validates if the provided key is correct for the database
+func ValidateDBKey(dbPath string, hexKey string, version int) bool {
+ // Create a decryptor
+ d, err := NewDecryptor(dbPath, version)
+ if err != nil {
+ return false
+ }
+
+ // Decode key
+ key, err := hex.DecodeString(hexKey)
+ if err != nil {
+ return false
+ }
+
+ // Validate the key
+ return d.Validate(key)
+}
diff --git a/internal/wechat/info.go b/internal/wechat/info.go
new file mode 100644
index 0000000..be5859f
--- /dev/null
+++ b/internal/wechat/info.go
@@ -0,0 +1,50 @@
+package wechat
+
+import (
+ "github.com/sjzar/chatlog/pkg/dllver"
+
+ "github.com/shirou/gopsutil/v4/process"
+ log "github.com/sirupsen/logrus"
+)
+
+const (
+ StatusInit = ""
+ StatusOffline = "offline"
+ StatusOnline = "online"
+)
+
+type Info struct {
+ PID uint32
+ ExePath string
+ Version *dllver.Info
+ Status string
+ DataDir string
+ AccountName string
+ Key string
+}
+
+func NewInfo(p *process.Process) (*Info, error) {
+ info := &Info{
+ PID: uint32(p.Pid),
+ Status: StatusOffline,
+ }
+
+ var err error
+ info.ExePath, err = p.Exe()
+ if err != nil {
+ log.Error(err)
+ return nil, err
+ }
+
+ info.Version, err = dllver.New(info.ExePath)
+ if err != nil {
+ log.Error(err)
+ return nil, err
+ }
+
+ if err := info.initialize(p); err != nil {
+ return nil, err
+ }
+
+ return info, nil
+}
diff --git a/internal/wechat/info_others.go b/internal/wechat/info_others.go
new file mode 100644
index 0000000..b8c3e3d
--- /dev/null
+++ b/internal/wechat/info_others.go
@@ -0,0 +1,16 @@
+//go:build !windows
+
+package wechat
+
+import "github.com/shirou/gopsutil/v4/process"
+
+// Giao~
+// 还没来得及写,Mac 版本打算通过 vmmap 检查内存区域,再用 lldb 读取内存来检查 Key,需要关 SIP 或自签名应用,稍晚再填坑
+
+func (i *Info) initialize(p *process.Process) error {
+ return nil
+}
+
+func (i *Info) GetKey() (string, error) {
+ return "mock-key", nil
+}
diff --git a/internal/wechat/info_windows.go b/internal/wechat/info_windows.go
new file mode 100644
index 0000000..6820c44
--- /dev/null
+++ b/internal/wechat/info_windows.go
@@ -0,0 +1,507 @@
+package wechat
+
+import (
+ "bytes"
+ "context"
+ "encoding/binary"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "sync"
+ "unsafe"
+
+ "github.com/sjzar/chatlog/pkg/util"
+
+ "github.com/shirou/gopsutil/v4/process"
+ log "github.com/sirupsen/logrus"
+ "golang.org/x/sys/windows"
+)
+
+const (
+ V3ModuleName = "WeChatWin.dll"
+ V3DBFile = "Msg\\Misc.db"
+ V4DBFile = "db_storage\\message\\message_0.db"
+
+ MaxWorkers = 16
+
+ // Windows memory protection constants
+ MEM_PRIVATE = 0x20000
+)
+
+// Common error definitions
+var (
+ ErrWeChatOffline = errors.New("wechat is not logged in")
+ ErrOpenProcess = errors.New("failed to open process")
+ ErrReaddecryptor = errors.New("failed to read database header")
+ ErrCheckProcessBits = errors.New("failed to check process architecture")
+ ErrFindWeChatDLL = errors.New("WeChatWin.dll module not found")
+ ErrNoValidKey = errors.New("no valid key found")
+ ErrInvalidFilePath = errors.New("invalid file path format")
+)
+
+// GetKey is the entry point for retrieving the WeChat database key
+func (i *Info) GetKey() (string, error) {
+ if i.Status == StatusOffline {
+ return "", ErrWeChatOffline
+ }
+
+ // Choose key retrieval method based on WeChat version
+ if i.Version.FileMajorVersion == 4 {
+ return i.getKeyV4()
+ }
+ return i.getKeyV3()
+}
+
+// initialize initializes WeChat information
+func (i *Info) initialize(p *process.Process) error {
+ files, err := p.OpenFiles()
+ if err != nil {
+ log.Error("Failed to get open file list: ", err)
+ return err
+ }
+
+ dbPath := V3DBFile
+ if i.Version.FileMajorVersion == 4 {
+ dbPath = V4DBFile
+ }
+
+ for _, f := range files {
+ if strings.HasSuffix(f.Path, dbPath) {
+ filePath := f.Path[4:] // Remove "\\?\" prefix
+ parts := strings.Split(filePath, string(filepath.Separator))
+ if len(parts) < 4 {
+ log.Debug("Invalid file path format: " + filePath)
+ continue
+ }
+
+ i.Status = StatusOnline
+ if i.Version.FileMajorVersion == 4 {
+ i.DataDir = strings.Join(parts[:len(parts)-3], string(filepath.Separator))
+ i.AccountName = parts[len(parts)-4]
+ } else {
+ i.DataDir = strings.Join(parts[:len(parts)-2], string(filepath.Separator))
+ i.AccountName = parts[len(parts)-3]
+ }
+ }
+ }
+
+ return nil
+}
+
+// getKeyV3 retrieves the database key for WeChat V3 version
+func (i *Info) getKeyV3() (string, error) {
+ // Read database header for key validation
+ dbPath := filepath.Join(i.DataDir, V3DBFile)
+ decryptor, err := NewDecryptor(dbPath, i.Version.FileMajorVersion)
+ if err != nil {
+ return "", fmt.Errorf("%w: %v", ErrReaddecryptor, err)
+ }
+ log.Debug("V3 database path: ", dbPath)
+
+ // Open WeChat process
+ handle, err := windows.OpenProcess(windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ, false, i.PID)
+ if err != nil {
+ return "", fmt.Errorf("%w: %v", ErrOpenProcess, err)
+ }
+ defer windows.CloseHandle(handle)
+
+ // Check process architecture
+ is64Bit, err := util.Is64Bit(handle)
+ if err != nil {
+ return "", fmt.Errorf("%w: %v", ErrCheckProcessBits, err)
+ }
+
+ // Create context to control all goroutines
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ // Create channels for memory data and results
+ memoryChannel := make(chan []byte, 100)
+ resultChannel := make(chan string, 1)
+
+ // Determine number of worker goroutines
+ workerCount := runtime.NumCPU()
+ if workerCount < 2 {
+ workerCount = 2
+ }
+ if workerCount > MaxWorkers {
+ workerCount = MaxWorkers
+ }
+ log.Debug("Starting ", workerCount, " workers for V3 key search")
+
+ // Start consumer goroutines
+ var workerWaitGroup sync.WaitGroup
+ workerWaitGroup.Add(workerCount)
+ for index := 0; index < workerCount; index++ {
+ go func() {
+ defer workerWaitGroup.Done()
+ workerV3(ctx, handle, decryptor, is64Bit, memoryChannel, resultChannel)
+ }()
+ }
+
+ // Start producer goroutine
+ var producerWaitGroup sync.WaitGroup
+ producerWaitGroup.Add(1)
+ go func() {
+ defer producerWaitGroup.Done()
+ defer close(memoryChannel) // Close channel when producer is done
+ err := i.findMemoryV3(ctx, handle, memoryChannel)
+ if err != nil {
+ log.Error(err)
+ }
+ }()
+
+ // Wait for producer and consumers to complete
+ go func() {
+ producerWaitGroup.Wait()
+ workerWaitGroup.Wait()
+ close(resultChannel)
+ }()
+
+ // Wait for result
+ result, ok := <-resultChannel
+ if ok && result != "" {
+ i.Key = result
+ return result, nil
+ }
+
+ return "", ErrNoValidKey
+}
+
+// getKeyV4 retrieves the database key for WeChat V4 version
+func (i *Info) getKeyV4() (string, error) {
+ // Read database header for key validation
+ dbPath := filepath.Join(i.DataDir, V4DBFile)
+ decryptor, err := NewDecryptor(dbPath, i.Version.FileMajorVersion)
+ if err != nil {
+ return "", fmt.Errorf("%w: %v", ErrReaddecryptor, err)
+ }
+ log.Debug("V4 database path: ", dbPath)
+
+ // Open process handle
+ handle, err := windows.OpenProcess(windows.PROCESS_VM_READ|windows.PROCESS_QUERY_INFORMATION, false, i.PID)
+ if err != nil {
+ return "", fmt.Errorf("%w: %v", ErrOpenProcess, err)
+ }
+ defer windows.CloseHandle(handle)
+
+ // Create context to control all goroutines
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ // Create channels for memory data and results
+ memoryChannel := make(chan []byte, 100)
+ resultChannel := make(chan string, 1)
+
+ // Determine number of worker goroutines
+ workerCount := runtime.NumCPU()
+ if workerCount < 2 {
+ workerCount = 2
+ }
+ if workerCount > MaxWorkers {
+ workerCount = MaxWorkers
+ }
+ log.Debug("Starting ", workerCount, " workers for V4 key search")
+
+ // Start consumer goroutines
+ var workerWaitGroup sync.WaitGroup
+ workerWaitGroup.Add(workerCount)
+ for index := 0; index < workerCount; index++ {
+ go func() {
+ defer workerWaitGroup.Done()
+ workerV4(ctx, handle, decryptor, memoryChannel, resultChannel)
+ }()
+ }
+
+ // Start producer goroutine
+ var producerWaitGroup sync.WaitGroup
+ producerWaitGroup.Add(1)
+ go func() {
+ defer producerWaitGroup.Done()
+ defer close(memoryChannel) // Close channel when producer is done
+ err := i.findMemoryV4(ctx, handle, memoryChannel)
+ if err != nil {
+ log.Error(err)
+ }
+ }()
+
+ // Wait for producer and consumers to complete
+ go func() {
+ producerWaitGroup.Wait()
+ workerWaitGroup.Wait()
+ close(resultChannel)
+ }()
+
+ // Wait for result
+ result, ok := <-resultChannel
+ if ok && result != "" {
+ i.Key = result
+ return result, nil
+ }
+
+ return "", ErrNoValidKey
+}
+
+// findMemoryV3 searches for writable memory regions in WeChatWin.dll for V3 version
+func (i *Info) findMemoryV3(ctx context.Context, handle windows.Handle, memoryChannel chan<- []byte) error {
+ // Find WeChatWin.dll module
+ module, isFound := FindModule(i.PID, V3ModuleName)
+ if !isFound {
+ return ErrFindWeChatDLL
+ }
+ log.Debug("Found WeChatWin.dll module at base address: 0x", fmt.Sprintf("%X", module.ModBaseAddr))
+
+ // Read writable memory regions
+ baseAddr := uintptr(module.ModBaseAddr)
+ endAddr := baseAddr + uintptr(module.ModBaseSize)
+ currentAddr := baseAddr
+
+ for currentAddr < endAddr {
+ var mbi windows.MemoryBasicInformation
+ err := windows.VirtualQueryEx(handle, currentAddr, &mbi, unsafe.Sizeof(mbi))
+ if err != nil {
+ break
+ }
+
+ // Skip small memory regions
+ if mbi.RegionSize < 100*1024 {
+ currentAddr += uintptr(mbi.RegionSize)
+ continue
+ }
+
+ // Check if memory region is writable
+ isWritable := (mbi.Protect & (windows.PAGE_READWRITE | windows.PAGE_WRITECOPY | windows.PAGE_EXECUTE_READWRITE | windows.PAGE_EXECUTE_WRITECOPY)) > 0
+ if isWritable && uint32(mbi.State) == windows.MEM_COMMIT {
+ // Calculate region size, ensure it doesn't exceed DLL bounds
+ regionSize := uintptr(mbi.RegionSize)
+ if currentAddr+regionSize > endAddr {
+ regionSize = endAddr - currentAddr
+ }
+
+ // Read writable memory region
+ memory := make([]byte, regionSize)
+ if err = windows.ReadProcessMemory(handle, currentAddr, &memory[0], regionSize, nil); err == nil {
+ select {
+ case memoryChannel <- memory:
+ log.Debug("Sent memory region for analysis, size: ", regionSize, " bytes")
+ case <-ctx.Done():
+ return nil
+ }
+ }
+ }
+
+ // Move to next memory region
+ currentAddr = uintptr(mbi.BaseAddress) + uintptr(mbi.RegionSize)
+ }
+
+ return nil
+}
+
+// workerV3 processes memory regions to find V3 version key
+func workerV3(ctx context.Context, handle windows.Handle, decryptor *Decryptor, is64Bit bool, memoryChannel <-chan []byte, resultChannel chan<- string) {
+
+ // Define search pattern
+ keyPattern := []byte{0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
+ ptrSize := 8
+ littleEndianFunc := binary.LittleEndian.Uint64
+
+ // Adjust for 32-bit process
+ if !is64Bit {
+ keyPattern = keyPattern[:4]
+ ptrSize = 4
+ littleEndianFunc = func(b []byte) uint64 { return uint64(binary.LittleEndian.Uint32(b)) }
+ }
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case memory, ok := <-memoryChannel:
+ if !ok {
+ return
+ }
+
+ index := len(memory)
+ for {
+ select {
+ case <-ctx.Done():
+ return // Exit if result found
+ default:
+ }
+
+ // Find pattern from end to beginning
+ index = bytes.LastIndex(memory[:index], keyPattern)
+ if index == -1 || index-ptrSize < 0 {
+ break
+ }
+
+ // Extract and validate pointer value
+ ptrValue := littleEndianFunc(memory[index-ptrSize : index])
+ if ptrValue > 0x10000 && ptrValue < 0x7FFFFFFFFFFF {
+ if key := validateKey(handle, decryptor, ptrValue); key != "" {
+ select {
+ case resultChannel <- key:
+ log.Debug("Valid key found for V3 database")
+ default:
+ }
+ return
+ }
+ }
+ index -= 1 // Continue searching from previous position
+ }
+ }
+ }
+}
+
+// findMemoryV4 searches for writable memory regions for V4 version
+func (i *Info) findMemoryV4(ctx context.Context, handle windows.Handle, memoryChannel chan<- []byte) error {
+ // Define search range
+ minAddr := uintptr(0x10000) // Process space usually starts from 0x10000
+ maxAddr := uintptr(0x7FFFFFFF) // 32-bit process space limit
+
+ if runtime.GOARCH == "amd64" {
+ maxAddr = uintptr(0x7FFFFFFFFFFF) // 64-bit process space limit
+ }
+ log.Debug("Scanning memory regions from 0x", fmt.Sprintf("%X", minAddr), " to 0x", fmt.Sprintf("%X", maxAddr))
+
+ currentAddr := minAddr
+
+ for currentAddr < maxAddr {
+ var memInfo windows.MemoryBasicInformation
+ err := windows.VirtualQueryEx(handle, currentAddr, &memInfo, unsafe.Sizeof(memInfo))
+ if err != nil {
+ break
+ }
+
+ // Skip small memory regions
+ if memInfo.RegionSize < 1024*1024 {
+ currentAddr += uintptr(memInfo.RegionSize)
+ continue
+ }
+
+ // Check if memory region is readable and private
+ if memInfo.State == windows.MEM_COMMIT && (memInfo.Protect&windows.PAGE_READWRITE) != 0 && memInfo.Type == MEM_PRIVATE {
+ // Calculate region size, ensure it doesn't exceed limit
+ regionSize := uintptr(memInfo.RegionSize)
+ if currentAddr+regionSize > maxAddr {
+ regionSize = maxAddr - currentAddr
+ }
+
+ // Read memory region
+ memory := make([]byte, regionSize)
+ if err = windows.ReadProcessMemory(handle, currentAddr, &memory[0], regionSize, nil); err == nil {
+ select {
+ case memoryChannel <- memory:
+ log.Debug("Sent memory region for analysis, size: ", regionSize, " bytes")
+ case <-ctx.Done():
+ return nil
+ }
+ }
+ }
+
+ // Move to next memory region
+ currentAddr = uintptr(memInfo.BaseAddress) + uintptr(memInfo.RegionSize)
+ }
+
+ return nil
+}
+
+// workerV4 processes memory regions to find V4 version key
+func workerV4(ctx context.Context, handle windows.Handle, decryptor *Decryptor, memoryChannel <-chan []byte, resultChannel chan<- string) {
+
+ // Define search pattern for V4
+ keyPattern := []byte{
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x2F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ }
+ ptrSize := 8
+ littleEndianFunc := binary.LittleEndian.Uint64
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case memory, ok := <-memoryChannel:
+ if !ok {
+ return
+ }
+
+ index := len(memory)
+ for {
+ select {
+ case <-ctx.Done():
+ return // Exit if result found
+ default:
+ }
+
+ // Find pattern from end to beginning
+ index = bytes.LastIndex(memory[:index], keyPattern)
+ if index == -1 || index-ptrSize < 0 {
+ break
+ }
+
+ // Extract and validate pointer value
+ ptrValue := littleEndianFunc(memory[index-ptrSize : index])
+ if ptrValue > 0x10000 && ptrValue < 0x7FFFFFFFFFFF {
+ if key := validateKey(handle, decryptor, ptrValue); key != "" {
+ select {
+ case resultChannel <- key:
+ log.Debug("Valid key found for V4 database")
+ default:
+ }
+ return
+ }
+ }
+ index -= 1 // Continue searching from previous position
+ }
+ }
+ }
+}
+
+// validateKey validates a single key candidate
+func validateKey(handle windows.Handle, decryptor *Decryptor, addr uint64) string {
+ keyData := make([]byte, 0x20) // 32-byte key
+ if err := windows.ReadProcessMemory(handle, uintptr(addr), &keyData[0], uintptr(len(keyData)), nil); err != nil {
+ return ""
+ }
+
+ // Validate key against database header
+ if decryptor.Validate(keyData) {
+ return hex.EncodeToString(keyData)
+ }
+
+ return ""
+}
+
+// FindModule searches for a specified module in the process
+// Used to find WeChatWin.dll module for V3 version
+func FindModule(pid uint32, name string) (module windows.ModuleEntry32, isFound bool) {
+ // Create module snapshot
+ snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPMODULE|windows.TH32CS_SNAPMODULE32, pid)
+ if err != nil {
+ log.Debug("Failed to create module snapshot: ", err)
+ return module, false
+ }
+ defer windows.CloseHandle(snapshot)
+
+ // Initialize module entry structure
+ module.Size = uint32(windows.SizeofModuleEntry32)
+
+ // Get the first module
+ if err := windows.Module32First(snapshot, &module); err != nil {
+ log.Debug("Failed to get first module: ", err)
+ return module, false
+ }
+
+ // Iterate through all modules to find WeChatWin.dll
+ for ; err == nil; err = windows.Module32Next(snapshot, &module) {
+ if windows.UTF16ToString(module.Module[:]) == name {
+ return module, true
+ }
+ }
+ return module, false
+}
diff --git a/internal/wechat/manager.go b/internal/wechat/manager.go
new file mode 100644
index 0000000..d812345
--- /dev/null
+++ b/internal/wechat/manager.go
@@ -0,0 +1,57 @@
+package wechat
+
+import (
+ "strings"
+
+ "github.com/shirou/gopsutil/v4/process"
+ log "github.com/sirupsen/logrus"
+)
+
+const (
+ V3ProcessName = "WeChat"
+ V4ProcessName = "Weixin"
+)
+
+var (
+ Items []*Info
+ ItemMap map[string]*Info
+)
+
+func Load() {
+ Items = make([]*Info, 0, 2)
+ ItemMap = make(map[string]*Info)
+
+ processes, err := process.Processes()
+ if err != nil {
+ log.Println("获取进程列表失败:", err)
+ return
+ }
+
+ for _, p := range processes {
+ name, err := p.Name()
+ name = strings.TrimSuffix(name, ".exe")
+ if err != nil || name != V3ProcessName && name != V4ProcessName {
+ continue
+ }
+
+ // v4 存在同名进程,需要继续判断 cmdline
+ if name == V4ProcessName {
+ cmdline, err := p.Cmdline()
+ if err != nil {
+ log.Error(err)
+ continue
+ }
+ if strings.Contains(cmdline, "--") {
+ continue
+ }
+ }
+
+ info, err := NewInfo(p)
+ if err != nil {
+ continue
+ }
+
+ Items = append(Items, info)
+ ItemMap[info.AccountName] = info
+ }
+}
diff --git a/internal/wechatdb/contact.go b/internal/wechatdb/contact.go
new file mode 100644
index 0000000..19d1d4d
--- /dev/null
+++ b/internal/wechatdb/contact.go
@@ -0,0 +1,269 @@
+package wechatdb
+
+import (
+ "database/sql"
+ "fmt"
+
+ "github.com/sjzar/chatlog/pkg/model"
+ "github.com/sjzar/chatlog/pkg/util"
+
+ log "github.com/sirupsen/logrus"
+)
+
+var (
+ ContactFileV3 = "^MicroMsg.db$"
+ ContactFileV4 = "contact.db$"
+)
+
+type Contact struct {
+ version int
+ dbFile string
+ db *sql.DB
+
+ Contact map[string]*model.Contact // 好友和群聊信息,Key UserName
+ ChatRoom map[string]*model.ChatRoom // 群聊信息,Key UserName
+ Sessions []*model.Session // 历史会话,按时间倒序
+
+ // Quick Search
+ ChatRoomUsers map[string]*model.Contact // 群聊成员信息,Key UserName
+ Alias2Contack map[string]*model.Contact // 别名到联系人的映射
+ Remark2Contack map[string]*model.Contact // 备注名到联系人的映射
+ NickName2Contack map[string]*model.Contact // 昵称到联系人的映射
+}
+
+func NewContact(path string, version int) (*Contact, error) {
+ c := &Contact{
+ version: version,
+ }
+
+ files, err := util.FindFilesWithPatterns(path, ContactFileV3, true)
+ if err != nil {
+ return nil, fmt.Errorf("查找数据库文件失败: %v", err)
+ }
+
+ if len(files) == 0 {
+ return nil, fmt.Errorf("未找到任何数据库文件: %s", path)
+ }
+
+ c.dbFile = files[0]
+
+ c.db, err = sql.Open("sqlite3", c.dbFile)
+ if err != nil {
+ log.Printf("警告: 连接数据库 %s 失败: %v", c.dbFile, err)
+ return nil, fmt.Errorf("连接数据库失败: %v", err)
+ }
+
+ c.loadContact()
+ c.loadChatRoom()
+ c.loadSession()
+ c.fillChatRoomInfo()
+
+ return c, nil
+}
+
+func (c *Contact) loadContact() {
+ contactMap := make(map[string]*model.Contact)
+ chatRoomUserMap := make(map[string]*model.Contact)
+ aliasMap := make(map[string]*model.Contact)
+ remarkMap := make(map[string]*model.Contact)
+ nickNameMap := make(map[string]*model.Contact)
+ rows, err := c.db.Query("SELECT UserName, Alias, Remark, NickName, Reserved1 FROM Contact")
+ if err != nil {
+ log.Errorf("查询联系人失败: %v", err)
+ return
+ }
+
+ for rows.Next() {
+ var contactv3 model.ContactV3
+
+ if err := rows.Scan(
+ &contactv3.UserName,
+ &contactv3.Alias,
+ &contactv3.Remark,
+ &contactv3.NickName,
+ &contactv3.Reserved1,
+ ); err != nil {
+ log.Printf("警告: 扫描联系人行失败: %v", err)
+ continue
+ }
+ contact := contactv3.Wrap()
+
+ if contact.IsFriend {
+ contactMap[contact.UserName] = contact
+ if contact.Alias != "" {
+ aliasMap[contact.Alias] = contact
+ }
+ if contact.Remark != "" {
+ remarkMap[contact.Remark] = contact
+ }
+ if contact.NickName != "" {
+ nickNameMap[contact.NickName] = contact
+ }
+ } else {
+ chatRoomUserMap[contact.UserName] = contact
+ }
+
+ }
+ rows.Close()
+
+ c.Contact = contactMap
+ c.ChatRoomUsers = chatRoomUserMap
+ c.Alias2Contack = aliasMap
+ c.Remark2Contack = remarkMap
+ c.NickName2Contack = nickNameMap
+}
+
+func (c *Contact) loadChatRoom() {
+
+ chatRoomMap := make(map[string]*model.ChatRoom)
+ rows, err := c.db.Query("SELECT ChatRoomName, Reserved2, RoomData FROM ChatRoom")
+ if err != nil {
+ log.Errorf("查询群聊失败: %v", err)
+ return
+ }
+ for rows.Next() {
+ var chatRoom model.ChatRoomV3
+ if err := rows.Scan(
+ &chatRoom.ChatRoomName,
+ &chatRoom.Reserved2,
+ &chatRoom.RoomData,
+ ); err != nil {
+ log.Printf("警告: 扫描群聊行失败: %v", err)
+ continue
+ }
+ chatRoomMap[chatRoom.ChatRoomName] = chatRoom.Wrap()
+ }
+ rows.Close()
+ c.ChatRoom = chatRoomMap
+}
+
+func (c *Contact) loadSession() {
+
+ sessions := make([]*model.Session, 0)
+ rows, err := c.db.Query("SELECT strUsrName, nOrder, strNickName, strContent, nTime FROM Session ORDER BY nOrder DESC")
+ if err != nil {
+ log.Errorf("查询群聊失败: %v", err)
+ return
+ }
+ for rows.Next() {
+ var sessionV3 model.SessionV3
+ if err := rows.Scan(
+ &sessionV3.StrUsrName,
+ &sessionV3.NOrder,
+ &sessionV3.StrNickName,
+ &sessionV3.StrContent,
+ &sessionV3.NTime,
+ ); err != nil {
+ log.Printf("警告: 扫描历史会话失败: %v", err)
+ continue
+ }
+ session := sessionV3.Wrap()
+ sessions = append(sessions, session)
+
+ }
+ rows.Close()
+ c.Sessions = sessions
+}
+
+func (c *Contact) ListContact() ([]*model.Contact, error) {
+ contacts := make([]*model.Contact, 0, len(c.Contact))
+ for _, contact := range c.Contact {
+ contacts = append(contacts, contact)
+ }
+ return contacts, nil
+}
+
+func (c *Contact) ListChatRoom() ([]*model.ChatRoom, error) {
+ chatRooms := make([]*model.ChatRoom, 0, len(c.ChatRoom))
+ for _, chatRoom := range c.ChatRoom {
+ chatRooms = append(chatRooms, chatRoom)
+ }
+ return chatRooms, nil
+}
+
+func (c *Contact) GetContact(key string) *model.Contact {
+ if contact, ok := c.Contact[key]; ok {
+ return contact
+ }
+ if contact, ok := c.Alias2Contack[key]; ok {
+ return contact
+ }
+ if contact, ok := c.Remark2Contack[key]; ok {
+ return contact
+ }
+ if contact, ok := c.NickName2Contack[key]; ok {
+ return contact
+ }
+ return nil
+}
+
+func (c *Contact) GetChatRoom(name string) *model.ChatRoom {
+ if chatRoom, ok := c.ChatRoom[name]; ok {
+ return chatRoom
+ }
+
+ if contact := c.GetContact(name); contact != nil {
+ if chatRoom, ok := c.ChatRoom[contact.UserName]; ok {
+ return chatRoom
+ } else {
+ // 被删除的群聊,在 ChatRoom 记录中没有了,但是能找到 Contact,做下 Mock
+ return &model.ChatRoom{
+ Name: contact.UserName,
+ Remark: contact.Remark,
+ NickName: contact.NickName,
+ Users: make([]model.ChatRoomUser, 0),
+ User2DisplayName: make(map[string]string),
+ }
+ }
+ }
+
+ return nil
+}
+
+func (c *Contact) GetSession(limit int) []*model.Session {
+ if limit <= 0 {
+ limit = len(c.Sessions)
+ }
+
+ if len(c.Sessions) < limit {
+ limit = len(c.Sessions)
+ }
+ return c.Sessions[:limit]
+}
+
+func (c *Contact) getFullContact(userName string) *model.Contact {
+ if contact := c.GetContact(userName); contact != nil {
+ return contact
+ }
+ if contact, ok := c.ChatRoomUsers[userName]; ok {
+ return contact
+ }
+ return nil
+}
+
+func (c *Contact) fillChatRoomInfo() {
+ for i := range c.ChatRoom {
+ if contact := c.GetContact(c.ChatRoom[i].Name); contact != nil {
+ c.ChatRoom[i].Remark = contact.Remark
+ c.ChatRoom[i].NickName = contact.NickName
+ }
+ }
+}
+
+func (c *Contact) MessageFillInfo(msg *model.Message) {
+ talker := msg.Talker
+ if msg.IsChatRoom {
+ talker = msg.ChatRoomSender
+ if chatRoom := c.GetChatRoom(msg.Talker); chatRoom != nil {
+ msg.CharRoomName = chatRoom.DisplayName()
+ if displayName, ok := chatRoom.User2DisplayName[talker]; ok {
+ msg.DisplayName = displayName
+ }
+ }
+ }
+ if msg.DisplayName == "" && msg.IsSender != 1 {
+ if contact := c.getFullContact(talker); contact != nil {
+ msg.DisplayName = contact.DisplayName()
+ }
+ }
+}
diff --git a/internal/wechatdb/message.go b/internal/wechatdb/message.go
new file mode 100644
index 0000000..d2ba934
--- /dev/null
+++ b/internal/wechatdb/message.go
@@ -0,0 +1,321 @@
+package wechatdb
+
+import (
+ "database/sql"
+ "fmt"
+ "log"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/sjzar/chatlog/pkg/model"
+ "github.com/sjzar/chatlog/pkg/util"
+
+ _ "github.com/mattn/go-sqlite3"
+)
+
+const (
+ MessageFileV3 = "^MSG([0-9]?[0-9])?\\.db$"
+ MessageFileV4 = "^messages_([0-9]?[0-9])+\\.db$"
+)
+
+type Message struct {
+ version int
+ files []MsgDBInfo
+ dbs map[string]*sql.DB
+}
+
+type MsgDBInfo struct {
+ FilePath string
+ StartTime time.Time
+ EndTime time.Time
+ TalkerMap map[string]int
+}
+
+func NewMessage(path string, version int) (*Message, error) {
+ m := &Message{
+ version: version,
+ files: make([]MsgDBInfo, 0),
+ dbs: make(map[string]*sql.DB),
+ }
+
+ // 查找所有 MSG[0-13].db 文件
+ files, err := util.FindFilesWithPatterns(path, MessageFileV3, true)
+ if err != nil {
+ return nil, fmt.Errorf("查找数据库文件失败: %v", err)
+ }
+
+ if len(files) == 0 {
+ return nil, fmt.Errorf("未找到任何数据库文件: %s", path)
+ }
+
+ // 处理每个数据库文件
+ for _, filePath := range files {
+ // 连接数据库
+ db, err := sql.Open("sqlite3", filePath)
+ if err != nil {
+ log.Printf("警告: 连接数据库 %s 失败: %v", filePath, err)
+ continue
+ }
+
+ // 获取 DBInfo 表中的开始时间
+ // 首先检查表结构
+ var startTime time.Time
+
+ // 尝试从 DBInfo 表中查找 Start Time 对应的记录
+ rows, err := db.Query("SELECT tableIndex, tableVersion, tableDesc FROM DBInfo")
+ if err != nil {
+ log.Printf("警告: 查询数据库 %s 的 DBInfo 表失败: %v", filePath, err)
+ db.Close()
+ continue
+ }
+
+ for rows.Next() {
+ var tableIndex int
+ var tableVersion int64
+ var tableDesc string
+
+ if err := rows.Scan(&tableIndex, &tableVersion, &tableDesc); err != nil {
+ log.Printf("警告: 扫描 DBInfo 行失败: %v", err)
+ continue
+ }
+
+ // 查找描述为 "Start Time" 的记录
+ if strings.Contains(tableDesc, "Start Time") {
+ startTime = time.Unix(tableVersion/1000, (tableVersion%1000)*1000000)
+ break
+ }
+ }
+ rows.Close()
+
+ // 组织 TalkerMap
+ talkerMap := make(map[string]int)
+ rows, err = db.Query("SELECT UsrName FROM Name2ID")
+ if err != nil {
+ log.Printf("警告: 查询数据库 %s 的 Name2ID 表失败: %v", filePath, err)
+ db.Close()
+ continue
+ }
+
+ i := 1
+ for rows.Next() {
+ var userName string
+ if err := rows.Scan(&userName); err != nil {
+ log.Printf("警告: 扫描 Name2ID 行失败: %v", err)
+ continue
+ }
+ talkerMap[userName] = i
+ i++
+ }
+
+ // 保存数据库信息
+ m.files = append(m.files, MsgDBInfo{
+ FilePath: filePath,
+ StartTime: startTime,
+ TalkerMap: talkerMap,
+ })
+
+ // 保存数据库连接
+ m.dbs[filePath] = db
+ }
+
+ // 按照 StartTime 排序数据库文件
+ sort.Slice(m.files, func(i, j int) bool {
+ return m.files[i].StartTime.Before(m.files[j].StartTime)
+ })
+
+ for i := range m.files {
+ if i == len(m.files)-1 {
+ m.files[i].EndTime = time.Now()
+ } else {
+ m.files[i].EndTime = m.files[i+1].StartTime
+ }
+ }
+
+ return m, nil
+}
+
+// GetMessages 根据时间段和 talker 查询聊天记录
+func (m *Message) GetMessages(startTime, endTime time.Time, talker string, limit, offset int) ([]*model.Message, error) {
+ // 找到时间范围内的数据库文件
+ dbInfos := m.getDBInfosForTimeRange(startTime, endTime)
+ if len(dbInfos) == 0 {
+ return nil, fmt.Errorf("未找到时间范围 %v 到 %v 内的数据库文件", startTime, endTime)
+ }
+
+ if len(dbInfos) == 1 {
+ // LIMIT 和 OFFSET 逻辑在单文件情况下可以直接在 SQL 里处理
+ return m.getMessagesSingleFile(dbInfos[0], startTime, endTime, talker, limit, offset)
+ }
+
+ // 从每个相关数据库中查询消息
+ totalMessages := []*model.Message{}
+
+ for _, dbInfo := range dbInfos {
+ db, ok := m.dbs[dbInfo.FilePath]
+ if !ok {
+ log.Printf("警告: 数据库 %s 未打开", dbInfo.FilePath)
+ continue
+ }
+
+ // 构建查询条件
+ // 使用 Sequence 查询,有索引
+ 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 Sequence, CreateTime, TalkerId, StrTalker, IsSender,
+ Type, SubType, StrContent, CompressContent, BytesExtra
+ FROM MSG
+ WHERE %s
+ ORDER BY Sequence ASC
+ `, strings.Join(conditions, " AND "))
+
+ // 执行查询
+ rows, err := db.Query(query, args...)
+ if err != nil {
+ log.Printf("警告: 查询数据库 %s 失败: %v", dbInfo.FilePath, err)
+ continue
+ }
+
+ // 处理查询结果
+ for rows.Next() {
+ var msg model.MessageV3
+ var compressContent []byte
+ var bytesExtra []byte
+
+ err := rows.Scan(
+ &msg.Sequence,
+ &msg.CreateTime,
+ &msg.TalkerID,
+ &msg.StrTalker,
+ &msg.IsSender,
+ &msg.Type,
+ &msg.SubType,
+ &msg.StrContent,
+ &compressContent,
+ &bytesExtra,
+ )
+ if err != nil {
+ log.Printf("警告: 扫描消息行失败: %v", err)
+ continue
+ }
+ msg.CompressContent = compressContent
+ msg.BytesExtra = bytesExtra
+
+ totalMessages = append(totalMessages, msg.Wrap())
+ }
+ rows.Close()
+
+ if limit+offset > 0 && len(totalMessages) >= limit+offset {
+ break
+ }
+ }
+
+ // 对所有消息按时间排序
+ sort.Slice(totalMessages, func(i, j int) bool {
+ return totalMessages[i].Sequence < totalMessages[j].Sequence
+ })
+
+ // FIXME limit 和 offset 逻辑,在多文件边界条件下不好处理,直接查询全量数据后在进程里处理
+ if limit > 0 {
+ if offset >= len(totalMessages) {
+ return []*model.Message{}, nil
+ }
+ end := offset + limit
+ if end > len(totalMessages) || limit == 0 {
+ end = len(totalMessages)
+ }
+ return totalMessages[offset:end], nil
+ }
+
+ return totalMessages, nil
+
+}
+
+func (m *Message) getMessagesSingleFile(dbInfo MsgDBInfo, startTime, endTime time.Time, talker string, limit, offset int) ([]*model.Message, error) {
+ // 构建查询条件
+ // 使用 Sequence 查询,有索引
+ 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 Sequence, CreateTime, TalkerId, 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 := m.dbs[dbInfo.FilePath].Query(query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("查询数据库 %s 失败: %v", dbInfo.FilePath, err)
+ }
+ defer rows.Close()
+ // 处理查询结果
+ totalMessages := []*model.Message{}
+ for rows.Next() {
+ var msg model.MessageV3
+ var compressContent []byte
+ var bytesExtra []byte
+ err := rows.Scan(
+ &msg.Sequence,
+ &msg.CreateTime,
+ &msg.TalkerID,
+ &msg.StrTalker,
+ &msg.IsSender,
+ &msg.Type,
+ &msg.SubType,
+ &msg.StrContent,
+ &compressContent,
+ &bytesExtra,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("扫描消息行失败: %v", err)
+ }
+ msg.CompressContent = compressContent
+ msg.BytesExtra = bytesExtra
+ totalMessages = append(totalMessages, msg.Wrap())
+ }
+ return totalMessages, nil
+}
+
+func (m *Message) getDBInfosForTimeRange(startTime, endTime time.Time) []MsgDBInfo {
+ var dbs []MsgDBInfo
+ for _, info := range m.files {
+ if info.StartTime.Before(endTime) && info.EndTime.After(startTime) {
+ dbs = append(dbs, info)
+ }
+ }
+ return dbs
+}
diff --git a/internal/wechatdb/wechatdb.go b/internal/wechatdb/wechatdb.go
new file mode 100644
index 0000000..dfdf176
--- /dev/null
+++ b/internal/wechatdb/wechatdb.go
@@ -0,0 +1,117 @@
+package wechatdb
+
+import (
+ "time"
+
+ "github.com/sjzar/chatlog/pkg/model"
+
+ _ "github.com/mattn/go-sqlite3"
+)
+
+type DB struct {
+ BasePath string
+ Version int
+
+ contact *Contact
+ message *Message
+}
+
+func New(path string, version int) (*DB, error) {
+ w := &DB{
+ BasePath: path,
+ Version: version,
+ }
+
+ // 初始化,加载数据库文件信息
+ if err := w.Initialize(); err != nil {
+ return nil, err
+ }
+
+ return w, nil
+}
+
+func (w *DB) Close() error {
+ return nil
+}
+
+func (w *DB) Initialize() error {
+
+ var err error
+ w.message, err = NewMessage(w.BasePath, w.Version)
+ if err != nil {
+ return err
+ }
+
+ w.contact, err = NewContact(w.BasePath, w.Version)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (w *DB) GetMessages(start, end time.Time, talker string, limit, offset int) ([]*model.Message, error) {
+
+ if talker != "" {
+ if contact := w.contact.GetContact(talker); contact != nil {
+ talker = contact.UserName
+ }
+ }
+
+ messages, err := w.message.GetMessages(start, end, talker, limit, offset)
+ if err != nil {
+ return nil, err
+ }
+ for i := range messages {
+ w.contact.MessageFillInfo(messages[i])
+ }
+
+ return messages, nil
+}
+
+type ListContactResp struct {
+ Items []*model.Contact `json:"items"`
+}
+
+func (w *DB) ListContact() (*ListContactResp, error) {
+ list, err := w.contact.ListContact()
+ if err != nil {
+ return nil, err
+ }
+ return &ListContactResp{
+ Items: list,
+ }, nil
+}
+
+func (w *DB) GetContact(userName string) *model.Contact {
+ return w.contact.GetContact(userName)
+}
+
+type ListChatRoomResp struct {
+ Items []*model.ChatRoom `json:"items"`
+}
+
+func (w *DB) ListChatRoom() (*ListChatRoomResp, error) {
+ list, err := w.contact.ListChatRoom()
+ if err != nil {
+ return nil, err
+ }
+ return &ListChatRoomResp{
+ Items: list,
+ }, nil
+}
+
+func (w *DB) GetChatRoom(userName string) *model.ChatRoom {
+ return w.contact.GetChatRoom(userName)
+}
+
+type GetSessionResp struct {
+ Items []*model.Session `json:"items"`
+}
+
+func (w *DB) GetSession(limit int) (*GetSessionResp, error) {
+ sessions := w.contact.GetSession(limit)
+ return &GetSessionResp{
+ Items: sessions,
+ }, nil
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..0b6838e
--- /dev/null
+++ b/main.go
@@ -0,0 +1,12 @@
+package main
+
+import (
+ "log"
+
+ "github.com/sjzar/chatlog/cmd/chatlog"
+)
+
+func main() {
+ log.SetFlags(log.LstdFlags | log.Lshortfile)
+ chatlog.Execute()
+}
diff --git a/pkg/config/config.go b/pkg/config/config.go
new file mode 100644
index 0000000..571849f
--- /dev/null
+++ b/pkg/config/config.go
@@ -0,0 +1,148 @@
+/*
+ * Copyright (c) 2023 shenjunzheng@gmail.com
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package config
+
+import (
+ "errors"
+ "os"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/spf13/viper"
+)
+
+const (
+ DefaultConfigType = "json"
+)
+
+var (
+ // ConfigName holds the name of the configuration file.
+ ConfigName = ""
+
+ // ConfigType specifies the type/format of the configuration file.
+ ConfigType = ""
+
+ // ConfigPath denotes the path to the configuration file.
+ ConfigPath = ""
+
+ // ERROR
+ ErrInvalidDirectory = errors.New("invalid directory path")
+ ErrMissingConfigName = errors.New("config name not specified")
+)
+
+// Init initializes the configuration settings.
+// It sets up the name, type, and path for the configuration file.
+func Init(name, _type, path string) error {
+ if len(name) == 0 {
+ return ErrMissingConfigName
+ }
+
+ if len(_type) == 0 {
+ _type = DefaultConfigType
+ }
+
+ var err error
+ if len(path) == 0 {
+ path, err = os.UserHomeDir()
+ if err != nil {
+ path = os.TempDir()
+ }
+ path += string(os.PathSeparator) + "." + name
+ }
+ if err := PrepareDir(path); err != nil {
+ return err
+ }
+
+ ConfigName = name
+ ConfigType = _type
+ ConfigPath = path
+ return nil
+}
+
+// Load loads the configuration from the previously initialized file.
+// It unmarshals the configuration into the provided conf interface.
+func Load(conf interface{}) error {
+ viper.SetConfigName(ConfigName)
+ viper.SetConfigType(ConfigType)
+ viper.AddConfigPath(ConfigPath)
+ if err := viper.ReadInConfig(); err != nil {
+ if err := viper.SafeWriteConfig(); err != nil {
+ return err
+ }
+ }
+ if err := viper.Unmarshal(conf); err != nil {
+ return err
+ }
+ SetDefault(conf)
+ return nil
+}
+
+// LoadFile loads the configuration from a specified file.
+// It unmarshals the configuration into the provided conf interface.
+func LoadFile(file string, conf interface{}) error {
+ viper.SetConfigFile(file)
+ if err := viper.ReadInConfig(); err != nil {
+ return err
+ }
+ if err := viper.Unmarshal(conf); err != nil {
+ return err
+ }
+ SetDefault(conf)
+ return nil
+}
+
+// SetConfig sets a configuration key to a specified value.
+// It also writes the updated configuration back to the file.
+func SetConfig(key string, value interface{}) error {
+ viper.Set(key, value)
+ if err := viper.WriteConfig(); err != nil {
+ return err
+ }
+ return nil
+}
+
+// ResetConfig resets the configuration to empty.
+func ResetConfig() error {
+ viper.Reset()
+ viper.SetConfigName(ConfigName)
+ viper.SetConfigType(ConfigType)
+ viper.AddConfigPath(ConfigPath)
+ return viper.WriteConfig()
+}
+
+// GetConfig retrieves all configuration settings as a map.
+func GetConfig() map[string]interface{} {
+ return viper.AllSettings()
+}
+
+// PrepareDir ensures that the specified directory path exists.
+// If the directory does not exist, it attempts to create it.
+func PrepareDir(path string) error {
+ stat, err := os.Stat(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ if err := os.MkdirAll(path, 0755); err != nil {
+ return err
+ }
+ } else {
+ return err
+ }
+ } else if !stat.IsDir() {
+ log.Debugf("%s is not a directory", path)
+ return ErrInvalidDirectory
+ }
+ return nil
+}
diff --git a/pkg/config/default.go b/pkg/config/default.go
new file mode 100644
index 0000000..f36ad24
--- /dev/null
+++ b/pkg/config/default.go
@@ -0,0 +1,251 @@
+/*
+ * Copyright (c) 2023 shenjunzheng@gmail.com
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package config
+
+import (
+ "encoding/json"
+ "reflect"
+ "strconv"
+)
+
+// DefaultTag is the default tag used to identify default values for struct fields.
+var DefaultTag string
+
+func init() {
+ DefaultTag = "default"
+}
+
+// SetDefaultTag updates the tag used to identify default values.
+func SetDefaultTag(tag string) {
+ DefaultTag = tag
+}
+
+// SetDefault sets the default values for a given interface{}.
+// The interface{} must be a pointer to a struct, bcs the default values are SET on the struct fields.
+func SetDefault(v interface{}) {
+ if v == nil {
+ return
+ }
+
+ val := reflect.ValueOf(v)
+ if val.Kind() == reflect.Ptr {
+ val = val.Elem()
+ }
+
+ setDefault(val, "")
+}
+
+// setDefault recursively sets default values based on the struct's tags.
+func setDefault(val reflect.Value, tag string) {
+
+ if !val.CanSet() {
+ return
+ }
+
+ switch val.Kind() {
+ case reflect.Struct:
+ handleStruct(val, tag)
+ case reflect.Ptr:
+ handlePtr(val, tag)
+ case reflect.Interface:
+ handleInterface(val, tag)
+ case reflect.Map:
+ handleMap(val, tag)
+ case reflect.Slice, reflect.Array:
+ handleSliceArray(val, tag)
+ default:
+ handleSimpleType(val, tag)
+ }
+}
+
+// handleSimpleType handles the assignment of default values for simple data types.
+func handleSimpleType(val reflect.Value, tag string) {
+
+ if len(tag) == 0 || !val.IsZero() || !val.CanSet() {
+ return
+ }
+
+ // Assign appropriate default values based on the data type.
+ switch val.Kind() {
+ case reflect.String:
+ val.SetString(tag)
+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+ if intValue, err := strconv.ParseInt(tag, 10, 64); err == nil {
+ val.SetInt(intValue)
+ }
+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
+ if uintVal, err := strconv.ParseUint(tag, 10, 64); err == nil {
+ val.SetUint(uintVal)
+ }
+ case reflect.Float32, reflect.Float64:
+ if floatValue, err := strconv.ParseFloat(tag, 64); err == nil {
+ val.SetFloat(floatValue)
+ }
+ case reflect.Bool:
+ if boolVal, err := strconv.ParseBool(tag); err == nil {
+ val.SetBool(boolVal)
+ }
+ }
+}
+
+// handleStruct processes struct type fields and sets default values based on their tags.
+func handleStruct(val reflect.Value, tag string) {
+
+ if val.Kind() != reflect.Struct {
+ return
+ }
+
+ if !val.IsZero() || len(tag) == 0 {
+ typ := val.Type()
+ for i := 0; i < val.NumField(); i++ {
+ field := val.Field(i)
+ fieldType := typ.Field(i)
+ setDefault(field, fieldType.Tag.Get(DefaultTag))
+ }
+ return
+ }
+
+ if !val.CanSet() {
+ return
+ }
+
+ // If tag is provided, unmarshal the default value and set it.
+ newStruct := reflect.New(val.Type()).Interface()
+ if err := json.Unmarshal([]byte(tag), newStruct); err == nil {
+ newStructVal := reflect.ValueOf(newStruct).Elem()
+ for i := 0; i < newStructVal.NumField(); i++ {
+ field := newStructVal.Field(i)
+ fieldType := newStructVal.Type().Field(i)
+ setDefault(field, fieldType.Tag.Get(DefaultTag))
+ }
+ val.Set(newStructVal)
+ }
+}
+
+// handleSliceArray sets default values for slice or array types.
+func handleSliceArray(val reflect.Value, tag string) {
+
+ if val.Kind() != reflect.Slice && val.Kind() != reflect.Array {
+ return
+ }
+
+ if !val.IsZero() || len(tag) == 0 {
+ for i := 0; i < val.Len(); i++ {
+ setDefault(val.Index(i), "")
+ }
+ return
+ }
+
+ if !val.CanSet() {
+ return
+ }
+
+ // array 提前初始化子结构体,解决 default 长度小于 array 长度的问题
+ if val.Kind() == reflect.Array {
+ for i := 0; i < val.Len(); i++ {
+ setDefault(val.Index(i), "")
+ }
+ }
+
+ // If tag is provided, unmarshal the default value and set it.
+ newSlice := reflect.New(reflect.SliceOf(val.Type().Elem())).Interface()
+ if err := json.Unmarshal([]byte(tag), newSlice); err == nil {
+ sliceValue := reflect.ValueOf(newSlice).Elem()
+ for j := 0; j < sliceValue.Len(); j++ {
+ v := sliceValue.Index(j)
+ setDefault(v, "")
+ if val.Kind() == reflect.Array {
+ if j >= val.Len() {
+ return
+ }
+ val.Index(j).Set(v)
+ } else {
+ val.Set(reflect.Append(val, v))
+ }
+ }
+ }
+}
+
+// handleMap sets default values for map types.
+func handleMap(val reflect.Value, tag string) {
+
+ if val.Kind() != reflect.Map {
+ return
+ }
+
+ if !val.IsZero() || len(tag) == 0 {
+ for _, key := range val.MapKeys() {
+ setDefault(val.MapIndex(key), "")
+ }
+ return
+ }
+
+ if !val.CanSet() {
+ return
+ }
+
+ // If tag is provided, unmarshal the default value and set it.
+ newMap := reflect.New(val.Type()).Interface()
+ if err := json.Unmarshal([]byte(tag), newMap); err == nil {
+ newMapVal := reflect.ValueOf(newMap).Elem()
+ for _, k := range newMapVal.MapKeys() {
+ v := newMapVal.MapIndex(k)
+ setDefault(v, "")
+ }
+ val.Set(newMapVal)
+ }
+}
+
+// handlePtr handles pointer types and sets default values based on their tags.
+func handlePtr(val reflect.Value, tag string) {
+
+ if val.Kind() != reflect.Ptr {
+ return
+ }
+
+ if !val.IsZero() || len(tag) == 0 || !val.CanSet() {
+ return
+ }
+
+ // If tag is provided, unmarshal the default value and set it.
+ newPtr := reflect.New(val.Type()).Interface()
+ if err := json.Unmarshal([]byte(tag), newPtr); err == nil {
+ newPtrVal := reflect.ValueOf(newPtr).Elem()
+ setDefault(newPtrVal, "")
+ val.Set(newPtrVal)
+ }
+}
+
+// handleInterface processes interface types and sets default values based on their tags.
+// support pointer interface only, bcs the default values are SET on the struct fields.
+func handleInterface(val reflect.Value, tag string) {
+
+ if val.Kind() != reflect.Interface {
+ return
+ }
+
+ if val.IsNil() {
+ return
+ }
+
+ if val.Elem().Kind() != reflect.Ptr {
+ return
+ }
+
+ // Recursively set the default for the inner value of the interface.
+ setDefault(reflect.ValueOf(val.Elem().Interface()).Elem(), tag)
+}
diff --git a/pkg/dllver/version.go b/pkg/dllver/version.go
new file mode 100644
index 0000000..c28cf57
--- /dev/null
+++ b/pkg/dllver/version.go
@@ -0,0 +1,25 @@
+package dllver
+
+type Info struct {
+ FilePath string `json:"file_path"`
+ CompanyName string `json:"company_name"`
+ FileDescription string `json:"file_description"`
+ FileVersion string `json:"file_version"`
+ FileMajorVersion int `json:"file_major_version"`
+ LegalCopyright string `json:"legal_copyright"`
+ ProductName string `json:"product_name"`
+ ProductVersion string `json:"product_version"`
+}
+
+func New(filePath string) (*Info, error) {
+ i := &Info{
+ FilePath: filePath,
+ }
+
+ err := i.initialize()
+ if err != nil {
+ return nil, err
+ }
+
+ return i, nil
+}
diff --git a/pkg/dllver/version_others.go b/pkg/dllver/version_others.go
new file mode 100644
index 0000000..86f69f8
--- /dev/null
+++ b/pkg/dllver/version_others.go
@@ -0,0 +1,7 @@
+//go:build !windows
+
+package dllver
+
+func (i *Info) initialize() error {
+ return nil
+}
diff --git a/pkg/dllver/version_windows.go b/pkg/dllver/version_windows.go
new file mode 100644
index 0000000..8bcbc62
--- /dev/null
+++ b/pkg/dllver/version_windows.go
@@ -0,0 +1,142 @@
+package dllver
+
+import (
+ "fmt"
+ "syscall"
+ "unsafe"
+)
+
+var (
+ modversion = syscall.NewLazyDLL("version.dll")
+ procGetFileVersionInfoSize = modversion.NewProc("GetFileVersionInfoSizeW")
+ procGetFileVersionInfo = modversion.NewProc("GetFileVersionInfoW")
+ procVerQueryValue = modversion.NewProc("VerQueryValueW")
+)
+
+// VS_FIXEDFILEINFO 结构体
+type VS_FIXEDFILEINFO struct {
+ Signature uint32
+ StrucVersion uint32
+ FileVersionMS uint32
+ FileVersionLS uint32
+ ProductVersionMS uint32
+ ProductVersionLS uint32
+ FileFlagsMask uint32
+ FileFlags uint32
+ FileOS uint32
+ FileType uint32
+ FileSubtype uint32
+ FileDateMS uint32
+ FileDateLS uint32
+}
+
+// initialize 初始化版本信息
+func (i *Info) initialize() error {
+ // 转换路径为 UTF16
+ pathPtr, err := syscall.UTF16PtrFromString(i.FilePath)
+ if err != nil {
+ return err
+ }
+
+ // 获取版本信息大小
+ var handle uintptr
+ size, _, err := procGetFileVersionInfoSize.Call(
+ uintptr(unsafe.Pointer(pathPtr)),
+ uintptr(unsafe.Pointer(&handle)),
+ )
+ if size == 0 {
+ return fmt.Errorf("GetFileVersionInfoSize failed: %v", err)
+ }
+
+ // 分配内存
+ verInfo := make([]byte, size)
+ ret, _, err := procGetFileVersionInfo.Call(
+ uintptr(unsafe.Pointer(pathPtr)),
+ 0,
+ size,
+ uintptr(unsafe.Pointer(&verInfo[0])),
+ )
+ if ret == 0 {
+ return fmt.Errorf("GetFileVersionInfo failed: %v", err)
+ }
+
+ // 获取固定的文件信息
+ var fixedFileInfo *VS_FIXEDFILEINFO
+ var uLen uint32
+ rootPtr, _ := syscall.UTF16PtrFromString("\\")
+ ret, _, err = procVerQueryValue.Call(
+ uintptr(unsafe.Pointer(&verInfo[0])),
+ uintptr(unsafe.Pointer(rootPtr)),
+ uintptr(unsafe.Pointer(&fixedFileInfo)),
+ uintptr(unsafe.Pointer(&uLen)),
+ )
+ if ret == 0 {
+ return fmt.Errorf("VerQueryValue failed: %v", err)
+ }
+
+ // 解析文件版本
+ i.FileVersion = fmt.Sprintf("%d.%d.%d.%d",
+ (fixedFileInfo.FileVersionMS>>16)&0xffff,
+ (fixedFileInfo.FileVersionMS>>0)&0xffff,
+ (fixedFileInfo.FileVersionLS>>16)&0xffff,
+ (fixedFileInfo.FileVersionLS>>0)&0xffff,
+ )
+ i.FileMajorVersion = int((fixedFileInfo.FileVersionMS >> 16) & 0xffff)
+
+ i.ProductVersion = fmt.Sprintf("%d.%d.%d.%d",
+ (fixedFileInfo.ProductVersionMS>>16)&0xffff,
+ (fixedFileInfo.ProductVersionMS>>0)&0xffff,
+ (fixedFileInfo.ProductVersionLS>>16)&0xffff,
+ (fixedFileInfo.ProductVersionLS>>0)&0xffff,
+ )
+
+ // 获取翻译信息
+ type langAndCodePage struct {
+ language uint16
+ codePage uint16
+ }
+
+ var lpTranslate *langAndCodePage
+ var cbTranslate uint32
+ transPtr, _ := syscall.UTF16PtrFromString("\\VarFileInfo\\Translation")
+ ret, _, _ = procVerQueryValue.Call(
+ uintptr(unsafe.Pointer(&verInfo[0])),
+ uintptr(unsafe.Pointer(transPtr)),
+ uintptr(unsafe.Pointer(&lpTranslate)),
+ uintptr(unsafe.Pointer(&cbTranslate)),
+ )
+
+ if ret != 0 && cbTranslate > 0 {
+ // 获取所有需要的字符串信息
+ stringInfos := map[string]*string{
+ "CompanyName": &i.CompanyName,
+ "FileDescription": &i.FileDescription,
+ "FileVersion": &i.FileVersion,
+ "LegalCopyright": &i.LegalCopyright,
+ "ProductName": &i.ProductName,
+ "ProductVersion": &i.ProductVersion,
+ }
+
+ for name, ptr := range stringInfos {
+ subBlock := fmt.Sprintf("\\StringFileInfo\\%04x%04x\\%s",
+ lpTranslate.language, lpTranslate.codePage, name)
+
+ subBlockPtr, _ := syscall.UTF16PtrFromString(subBlock)
+ var buffer *uint16
+ var bufLen uint32
+
+ ret, _, _ = procVerQueryValue.Call(
+ uintptr(unsafe.Pointer(&verInfo[0])),
+ uintptr(unsafe.Pointer(subBlockPtr)),
+ uintptr(unsafe.Pointer(&buffer)),
+ uintptr(unsafe.Pointer(&bufLen)),
+ )
+
+ if ret != 0 && bufLen > 0 {
+ *ptr = syscall.UTF16ToString((*[1 << 20]uint16)(unsafe.Pointer(buffer))[:bufLen:bufLen])
+ }
+ }
+ }
+
+ return nil
+}
diff --git a/pkg/model/chatroom.go b/pkg/model/chatroom.go
new file mode 100644
index 0000000..d76bbc7
--- /dev/null
+++ b/pkg/model/chatroom.go
@@ -0,0 +1,142 @@
+package model
+
+import (
+ "github.com/sjzar/chatlog/pkg/model/wxproto"
+
+ "google.golang.org/protobuf/proto"
+)
+
+type ChatRoom struct {
+ Name string `json:"name"`
+ Owner string `json:"owner"`
+ Users []ChatRoomUser `json:"users"`
+
+ // Extra From Contact
+ Remark string `json:"remark"`
+ NickName string `json:"nickName"`
+
+ User2DisplayName map[string]string `json:"-"`
+}
+
+type ChatRoomUser struct {
+ UserName string `json:"userName"`
+ DisplayName string `json:"displayName"`
+}
+
+// CREATE TABLE ChatRoom(
+// ChatRoomName TEXT PRIMARY KEY,
+// UserNameList TEXT,
+// DisplayNameList TEXT,
+// ChatRoomFlag int Default 0,
+// Owner INTEGER DEFAULT 0,
+// IsShowName INTEGER DEFAULT 0,
+// SelfDisplayName TEXT,
+// Reserved1 INTEGER DEFAULT 0,
+// Reserved2 TEXT,
+// Reserved3 INTEGER DEFAULT 0,
+// Reserved4 TEXT,
+// Reserved5 INTEGER DEFAULT 0,
+// Reserved6 TEXT,
+// RoomData BLOB,
+// Reserved7 INTEGER DEFAULT 0,
+// Reserved8 TEXT
+// )
+type ChatRoomV3 struct {
+ ChatRoomName string `json:"ChatRoomName"`
+ Reserved2 string `json:"Reserved2"` // Creator
+ RoomData []byte `json:"RoomData"`
+
+ // // 非关键信息,暂时忽略
+ // UserNameList string `json:"UserNameList"`
+ // DisplayNameList string `json:"DisplayNameList"`
+ // ChatRoomFlag int `json:"ChatRoomFlag"`
+ // Owner int `json:"Owner"`
+ // IsShowName int `json:"IsShowName"`
+ // SelfDisplayName string `json:"SelfDisplayName"`
+ // Reserved1 int `json:"Reserved1"`
+ // Reserved3 int `json:"Reserved3"`
+ // Reserved4 string `json:"Reserved4"`
+ // Reserved5 int `json:"Reserved5"`
+ // Reserved6 string `json:"Reserved6"`
+ // Reserved7 int `json:"Reserved7"`
+ // Reserved8 string `json:"Reserved8"`
+}
+
+func (c *ChatRoomV3) Wrap() *ChatRoom {
+
+ var users []ChatRoomUser
+ if len(c.RoomData) != 0 {
+ users = ParseRoomData(c.RoomData)
+ }
+
+ user2DisplayName := make(map[string]string, len(users))
+ for _, user := range users {
+ if user.DisplayName != "" {
+ user2DisplayName[user.UserName] = user.DisplayName
+ }
+ }
+
+ return &ChatRoom{
+ Name: c.ChatRoomName,
+ Owner: c.Reserved2,
+ Users: users,
+ User2DisplayName: user2DisplayName,
+ }
+}
+
+// CREATE TABLE chat_room(
+// id INTEGER PRIMARY KEY,
+// username TEXT,
+// owner TEXT,
+// ext_buffer BLOB
+// )
+type ChatRoomV4 struct {
+ ID int `json:"id"`
+ UserName string `json:"username"`
+ Owner string `json:"owner"`
+ ExtBuffer []byte `json:"ext_buffer"`
+}
+
+func (c *ChatRoomV4) Wrap() *ChatRoom {
+
+ var users []ChatRoomUser
+ if len(c.ExtBuffer) != 0 {
+ users = ParseRoomData(c.ExtBuffer)
+ }
+
+ return &ChatRoom{
+ Name: c.UserName,
+ Owner: c.Owner,
+ Users: users,
+ }
+}
+
+func ParseRoomData(b []byte) (users []ChatRoomUser) {
+ var pbMsg wxproto.RoomData
+ if err := proto.Unmarshal(b, &pbMsg); err != nil {
+ return
+ }
+ if pbMsg.Users == nil {
+ return
+ }
+
+ users = make([]ChatRoomUser, 0, len(pbMsg.Users))
+ for _, user := range pbMsg.Users {
+ u := ChatRoomUser{UserName: user.UserName}
+ if user.DisplayName != nil {
+ u.DisplayName = *user.DisplayName
+ }
+ users = append(users, u)
+ }
+ return users
+}
+
+func (c *ChatRoom) DisplayName() string {
+ switch {
+ case c.Remark != "":
+ return c.Remark
+ case c.NickName != "":
+ return c.NickName
+ }
+ return ""
+}
diff --git a/pkg/model/contact.go b/pkg/model/contact.go
new file mode 100644
index 0000000..638e80a
--- /dev/null
+++ b/pkg/model/contact.go
@@ -0,0 +1,158 @@
+package model
+
+type Contact struct {
+ UserName string `json:"userName"`
+ Alias string `json:"alias"`
+ Remark string `json:"remark"`
+ NickName string `json:"nickName"`
+ IsFriend bool `json:"isFriend"`
+}
+
+// CREATE TABLE Contact(
+// UserName TEXT PRIMARY KEY ,
+// Alias TEXT,
+// EncryptUserName TEXT,
+// DelFlag INTEGER DEFAULT 0,
+// Type INTEGER DEFAULT 0,
+// VerifyFlag INTEGER DEFAULT 0,
+// Reserved1 INTEGER DEFAULT 0,
+// Reserved2 INTEGER DEFAULT 0,
+// Reserved3 TEXT,
+// Reserved4 TEXT,
+// Remark TEXT,
+// NickName TEXT,
+// LabelIDList TEXT,
+// DomainList TEXT,
+// ChatRoomType int,
+// PYInitial TEXT,
+// QuanPin TEXT,
+// RemarkPYInitial TEXT,
+// RemarkQuanPin TEXT,
+// BigHeadImgUrl TEXT,
+// SmallHeadImgUrl TEXT,
+// HeadImgMd5 TEXT,
+// ChatRoomNotify INTEGER DEFAULT 0,
+// Reserved5 INTEGER DEFAULT 0,
+// Reserved6 TEXT,
+// Reserved7 TEXT,
+// ExtraBuf BLOB,
+// Reserved8 INTEGER DEFAULT 0,
+// Reserved9 INTEGER DEFAULT 0,
+// Reserved10 TEXT,
+// Reserved11 TEXT
+// )
+type ContactV3 struct {
+ UserName string `json:"UserName"`
+ Alias string `json:"Alias"`
+ Remark string `json:"Remark"`
+ NickName string `json:"NickName"`
+ Reserved1 int `json:"Reserved1"` // 1 自己好友或自己加入的群聊; 0 群聊成员(非好友)
+
+ // EncryptUserName string `json:"EncryptUserName"`
+ // DelFlag int `json:"DelFlag"`
+ // Type int `json:"Type"`
+ // VerifyFlag int `json:"VerifyFlag"`
+ // Reserved2 int `json:"Reserved2"`
+ // Reserved3 string `json:"Reserved3"`
+ // Reserved4 string `json:"Reserved4"`
+ // LabelIDList string `json:"LabelIDList"`
+ // DomainList string `json:"DomainList"`
+ // ChatRoomType int `json:"ChatRoomType"`
+ // PYInitial string `json:"PYInitial"`
+ // QuanPin string `json:"QuanPin"`
+ // RemarkPYInitial string `json:"RemarkPYInitial"`
+ // RemarkQuanPin string `json:"RemarkQuanPin"`
+ // BigHeadImgUrl string `json:"BigHeadImgUrl"`
+ // SmallHeadImgUrl string `json:"SmallHeadImgUrl"`
+ // HeadImgMd5 string `json:"HeadImgMd5"`
+ // ChatRoomNotify int `json:"ChatRoomNotify"`
+ // Reserved5 int `json:"Reserved5"`
+ // Reserved6 string `json:"Reserved6"`
+ // Reserved7 string `json:"Reserved7"`
+ // ExtraBuf []byte `json:"ExtraBuf"`
+ // Reserved8 int `json:"Reserved8"`
+ // Reserved9 int `json:"Reserved9"`
+ // Reserved10 string `json:"Reserved10"`
+ // Reserved11 string `json:"Reserved11"`
+}
+
+func (c *ContactV3) Wrap() *Contact {
+ return &Contact{
+ UserName: c.UserName,
+ Alias: c.Alias,
+ Remark: c.Remark,
+ NickName: c.NickName,
+ IsFriend: c.Reserved1 == 1,
+ }
+}
+
+// CREATE TABLE contact(
+// id INTEGER PRIMARY KEY,
+// username TEXT,
+// local_type INTEGER,
+// alias TEXT,
+// encrypt_username TEXT,
+// flag INTEGER,
+// delete_flag INTEGER,
+// verify_flag INTEGER,
+// remark TEXT,
+// remark_quan_pin TEXT,
+// remark_pin_yin_initial TEXT,
+// nick_name TEXT,
+// pin_yin_initial TEXT,
+// quan_pin TEXT,
+// big_head_url TEXT,
+// small_head_url TEXT,
+// head_img_md5 TEXT,
+// chat_room_notify INTEGER,
+// is_in_chat_room INTEGER,
+// description TEXT,
+// extra_buffer BLOB,
+// chat_room_type INTEGER
+// )
+type ContactV4 struct {
+ UserName string `json:"username"`
+ Alias string `json:"alias"`
+ Remark string `json:"remark"`
+ NickName string `json:"nick_name"`
+ LocalType int `json:"local_type"` // 2 群聊; 3 群聊成员(非好友); 5,6 企业微信;
+
+ // ID int `json:"id"`
+
+ // EncryptUserName string `json:"encrypt_username"`
+ // Flag int `json:"flag"`
+ // DeleteFlag int `json:"delete_flag"`
+ // VerifyFlag int `json:"verify_flag"`
+ // RemarkQuanPin string `json:"remark_quan_pin"`
+ // RemarkPinYinInitial string `json:"remark_pin_yin_initial"`
+ // PinYinInitial string `json:"pin_yin_initial"`
+ // QuanPin string `json:"quan_pin"`
+ // BigHeadUrl string `json:"big_head_url"`
+ // SmallHeadUrl string `json:"small_head_url"`
+ // HeadImgMd5 string `json:"head_img_md5"`
+ // ChatRoomNotify int `json:"chat_room_notify"`
+ // IsInChatRoom int `json:"is_in_chat_room"`
+ // Description string `json:"description"`
+ // ExtraBuffer []byte `json:"extra_buffer"`
+ // ChatRoomType int `json:"chat_room_type"`
+}
+
+func (c *ContactV4) Wrap() *Contact {
+ return &Contact{
+ UserName: c.UserName,
+ Alias: c.Alias,
+ Remark: c.Remark,
+ NickName: c.NickName,
+ IsFriend: c.LocalType != 3,
+ }
+}
+
+func (c *Contact) DisplayName() string {
+ switch {
+ case c.Remark != "":
+ return c.Remark
+ case c.NickName != "":
+ return c.NickName
+ }
+ return ""
+}
diff --git a/pkg/model/message.go b/pkg/model/message.go
new file mode 100644
index 0000000..2de6662
--- /dev/null
+++ b/pkg/model/message.go
@@ -0,0 +1,301 @@
+package model
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/sjzar/chatlog/pkg/model/wxproto"
+ "github.com/sjzar/chatlog/pkg/util"
+
+ "google.golang.org/protobuf/proto"
+)
+
+const (
+ // Source
+ WeChatV3 = "wechatv3"
+ WeChatV4 = "wechatv4"
+)
+
+type Message struct {
+ Sequence int64 `json:"sequence"` // 消息序号,10位时间戳 + 3位序号
+ CreateTime time.Time `json:"createTime"` // 消息创建时间,10位时间戳
+ TalkerID int `json:"talkerID"` // 聊天对象,Name2ID 表序号,索引值
+ Talker string `json:"talker"` // 聊天对象,微信 ID or 群 ID
+ IsSender int `json:"isSender"` // 是否为发送消息,0 接收消息,1 发送消息
+ Type int `json:"type"` // 消息类型
+ SubType int `json:"subType"` // 消息子类型
+ Content string `json:"content"` // 消息内容,文字聊天内容 或 XML
+ CompressContent []byte `json:"compressContent"` // 非文字聊天内容,如图片、语音、视频等
+ IsChatRoom bool `json:"isChatRoom"` // 是否为群聊消息
+ ChatRoomSender string `json:"chatRoomSender"` // 群聊消息发送人
+
+ // Fill Info
+ // 从联系人等信息中填充
+ DisplayName string `json:"-"` // 显示名称
+ CharRoomName string `json:"-"` // 群聊名称
+
+ Version string `json:"-"` // 消息版本,内部判断
+}
+
+// CREATE TABLE MSG (
+// localId INTEGER PRIMARY KEY AUTOINCREMENT,
+// TalkerId INT DEFAULT 0,
+// MsgSvrID INT,
+// Type INT,
+// SubType INT,
+// IsSender INT,
+// CreateTime INT,
+// Sequence INT DEFAULT 0,
+// StatusEx INT DEFAULT 0,
+// FlagEx INT,
+// Status INT,
+// MsgServerSeq INT,
+// MsgSequence INT,
+// StrTalker TEXT,
+// StrContent TEXT,
+// DisplayContent TEXT,
+// Reserved0 INT DEFAULT 0,
+// Reserved1 INT DEFAULT 0,
+// Reserved2 INT DEFAULT 0,
+// Reserved3 INT DEFAULT 0,
+// Reserved4 TEXT,
+// Reserved5 TEXT,
+// Reserved6 TEXT,
+// CompressContent BLOB,
+// BytesExtra BLOB,
+// BytesTrans BLOB
+// )
+type MessageV3 struct {
+ Sequence int64 `json:"Sequence"` // 消息序号,10位时间戳 + 3位序号
+ CreateTime int64 `json:"CreateTime"` // 消息创建时间,10位时间戳
+ TalkerID int `json:"TalkerId"` // 聊天对象,Name2ID 表序号,索引值
+ StrTalker string `json:"StrTalker"` // 聊天对象,微信 ID or 群 ID
+ IsSender int `json:"IsSender"` // 是否为发送消息,0 接收消息,1 发送消息
+ Type int `json:"Type"` // 消息类型
+ SubType int `json:"SubType"` // 消息子类型
+ StrContent string `json:"StrContent"` // 消息内容,文字聊天内容 或 XML
+ CompressContent []byte `json:"CompressContent"` // 非文字聊天内容,如图片、语音、视频等
+ BytesExtra []byte `json:"BytesExtra"` // protobuf 额外数据,记录群聊发送人等信息
+
+ // 非关键信息,后续有需要再加入
+ // LocalID int64 `json:"localId"`
+ // MsgSvrID int64 `json:"MsgSvrID"`
+ // StatusEx int `json:"StatusEx"`
+ // FlagEx int `json:"FlagEx"`
+ // Status int `json:"Status"`
+ // MsgServerSeq int64 `json:"MsgServerSeq"`
+ // MsgSequence int64 `json:"MsgSequence"`
+ // DisplayContent string `json:"DisplayContent"`
+ // Reserved0 int `json:"Reserved0"`
+ // Reserved1 int `json:"Reserved1"`
+ // Reserved2 int `json:"Reserved2"`
+ // Reserved3 int `json:"Reserved3"`
+ // Reserved4 string `json:"Reserved4"`
+ // Reserved5 string `json:"Reserved5"`
+ // Reserved6 string `json:"Reserved6"`
+ // BytesTrans []byte `json:"BytesTrans"`
+}
+
+func (m *MessageV3) Wrap() *Message {
+
+ isChatRoom := strings.HasSuffix(m.StrTalker, "@chatroom")
+
+ var chatRoomSender string
+ if len(m.BytesExtra) != 0 && isChatRoom {
+ chatRoomSender = ParseBytesExtra(m.BytesExtra)
+ }
+
+ return &Message{
+ Sequence: m.Sequence,
+ CreateTime: time.Unix(m.CreateTime, 0),
+ TalkerID: m.TalkerID,
+ Talker: m.StrTalker,
+ IsSender: m.IsSender,
+ Type: m.Type,
+ SubType: m.SubType,
+ Content: m.StrContent,
+ CompressContent: m.CompressContent,
+ IsChatRoom: isChatRoom,
+ ChatRoomSender: chatRoomSender,
+ Version: WeChatV3,
+ }
+}
+
+// CREATE TABLE Msg_xxxxxxxxxxxx(
+// local_id INTEGER PRIMARY KEY AUTOINCREMENT,
+// server_id INTEGER,
+// local_type INTEGER,
+// sort_seq INTEGER,
+// real_sender_id INTEGER,
+// create_time INTEGER,
+// status INTEGER,
+// upload_status INTEGER,
+// download_status INTEGER,
+// server_seq INTEGER,
+// origin_source INTEGER,
+// source TEXT,
+// message_content TEXT,
+// compress_content TEXT,
+// packed_info_data BLOB,
+// WCDB_CT_message_content INTEGER DEFAULT NULL,
+// WCDB_CT_source INTEGER DEFAULT NULL
+// )
+type MessageV4 struct {
+ SortSeq int64 `json:"sort_seq"` // 消息序号,10位时间戳 + 3位序号
+ LocalType int `json:"local_type"` // 消息类型
+ RealSenderID int `json:"real_sender_id"` // 发送人 ID,对应 Name2Id 表序号
+ CreateTime int64 `json:"create_time"` // 消息创建时间,10位时间戳
+ MessageContent []byte `json:"message_content"` // 消息内容,文字聊天内容 或 zstd 压缩内容
+ PackedInfoData []byte `json:"packed_info_data"` // 额外数据,类似 proto,格式与 v3 有差异
+ Status int `json:"status"` // 消息状态,2 是已发送,4 是已接收,可以用于判断 IsSender(猜测)
+
+ // 非关键信息,后续有需要再加入
+ // LocalID int `json:"local_id"`
+ // ServerID int64 `json:"server_id"`
+
+ // UploadStatus int `json:"upload_status"`
+ // DownloadStatus int `json:"download_status"`
+ // ServerSeq int `json:"server_seq"`
+ // OriginSource int `json:"origin_source"`
+ // Source string `json:"source"`
+ // CompressContent string `json:"compress_content"`
+}
+
+func (m *MessageV4) Wrap(id2Name map[int]string, isChatRoom bool) *Message {
+
+ _m := &Message{
+ Sequence: m.SortSeq,
+ CreateTime: time.Unix(m.CreateTime, 0),
+ TalkerID: m.RealSenderID, // 依赖 Name2Id 表进行转换为 StrTalker
+ CompressContent: m.PackedInfoData,
+ Type: m.LocalType,
+ Version: WeChatV4,
+ }
+
+ if name, ok := id2Name[m.RealSenderID]; ok {
+ _m.Talker = name
+ }
+
+ if m.Status == 2 {
+ _m.IsSender = 1
+ }
+
+ if util.IsNormalString(m.MessageContent) {
+ _m.Content = string(m.MessageContent)
+ } else {
+ _m.CompressContent = m.MessageContent
+ }
+
+ if isChatRoom {
+ _m.IsChatRoom = true
+ split := strings.Split(_m.Content, "\n")
+ if len(split) > 1 {
+ _m.Content = split[1]
+ _m.ChatRoomSender = strings.TrimSuffix(split[0], ":")
+ }
+ }
+
+ return _m
+}
+
+// ParseBytesExtra 解析额外数据
+// 按需解析
+func ParseBytesExtra(b []byte) (chatRoomSender string) {
+ var pbMsg wxproto.BytesExtra
+ if err := proto.Unmarshal(b, &pbMsg); err != nil {
+ return
+ }
+ if pbMsg.Items == nil {
+ return
+ }
+
+ for _, item := range pbMsg.Items {
+ if item.Type == 1 {
+ return item.Value
+ }
+ }
+
+ return
+}
+
+func (m *Message) PlainText(showChatRoom bool) string {
+ buf := strings.Builder{}
+
+ talker := m.Talker
+ if m.IsSender == 1 {
+ talker = "我"
+ } else if m.IsChatRoom {
+ talker = m.ChatRoomSender
+ }
+ if m.DisplayName != "" {
+ buf.WriteString(m.DisplayName)
+ buf.WriteString("(")
+ buf.WriteString(talker)
+ buf.WriteString(")")
+ } else {
+ buf.WriteString(talker)
+ }
+ buf.WriteString(" ")
+
+ if m.IsChatRoom && showChatRoom {
+ buf.WriteString("[")
+ if m.CharRoomName != "" {
+ buf.WriteString(m.CharRoomName)
+ buf.WriteString("(")
+ buf.WriteString(m.Talker)
+ buf.WriteString(")")
+ } else {
+ buf.WriteString(m.Talker)
+ }
+ buf.WriteString("] ")
+ }
+
+ buf.WriteString(m.CreateTime.Format("2006-01-02 15:04:05"))
+ buf.WriteString("\n")
+
+ switch m.Type {
+ case 1:
+ buf.WriteString(m.Content)
+ case 3:
+ buf.WriteString("[图片]")
+ case 34:
+ buf.WriteString("[语音]")
+ case 43:
+ buf.WriteString("[视频]")
+ case 47:
+ buf.WriteString("[动画表情]")
+ case 49:
+ switch m.SubType {
+ case 6:
+ buf.WriteString("[文件]")
+ case 8:
+ buf.WriteString("[GIF表情]")
+ case 19:
+ buf.WriteString("[合并转发]")
+ case 33, 36:
+ buf.WriteString("[小程序]")
+ case 57:
+ buf.WriteString("[引用]")
+ case 63:
+ buf.WriteString("[视频号]")
+ case 87:
+ buf.WriteString("[群公告]")
+ case 2000:
+ buf.WriteString("[转账]")
+ case 2003:
+ buf.WriteString("[红包封面]")
+ default:
+ buf.WriteString("[分享]")
+ }
+ case 50:
+ buf.WriteString("[语音通话]")
+ case 10000:
+ buf.WriteString("[系统消息]")
+ default:
+ buf.WriteString(fmt.Sprintf("Type: %d Content: %s", m.Type, m.Content))
+ }
+ buf.WriteString("\n")
+
+ return buf.String()
+}
diff --git a/pkg/model/session.go b/pkg/model/session.go
new file mode 100644
index 0000000..97c1f55
--- /dev/null
+++ b/pkg/model/session.go
@@ -0,0 +1,133 @@
+package model
+
+import (
+ "strings"
+ "time"
+)
+
+type Session struct {
+ UserName string `json:"userName"`
+ NOrder int `json:"nOrder"`
+ NickName string `json:"nickName"`
+ Content string `json:"content"`
+ NTime time.Time `json:"nTime"`
+}
+
+// CREATE TABLE Session(
+// strUsrName TEXT PRIMARY KEY,
+// nOrder INT DEFAULT 0,
+// nUnReadCount INTEGER DEFAULT 0,
+// parentRef TEXT,
+// Reserved0 INTEGER DEFAULT 0,
+// Reserved1 TEXT,
+// strNickName TEXT,
+// nStatus INTEGER,
+// nIsSend INTEGER,
+// strContent TEXT,
+// nMsgType INTEGER,
+// nMsgLocalID INTEGER,
+// nMsgStatus INTEGER,
+// nTime INTEGER,
+// editContent TEXT,
+// othersAtMe INT,
+// Reserved2 INTEGER DEFAULT 0,
+// Reserved3 TEXT,
+// Reserved4 INTEGER DEFAULT 0,
+// Reserved5 TEXT,
+// bytesXml BLOB
+// )
+type SessionV3 struct {
+ StrUsrName string `json:"strUsrName"`
+ NOrder int `json:"nOrder"`
+ StrNickName string `json:"strNickName"`
+ StrContent string `json:"strContent"`
+ NTime int64 `json:"nTime"`
+
+ // NUnReadCount int `json:"nUnReadCount"`
+ // ParentRef string `json:"parentRef"`
+ // Reserved0 int `json:"Reserved0"`
+ // Reserved1 string `json:"Reserved1"`
+ // NStatus int `json:"nStatus"`
+ // NIsSend int `json:"nIsSend"`
+ // NMsgType int `json:"nMsgType"`
+ // NMsgLocalID int `json:"nMsgLocalID"`
+ // NMsgStatus int `json:"nMsgStatus"`
+ // EditContent string `json:"editContent"`
+ // OthersAtMe int `json:"othersAtMe"`
+ // Reserved2 int `json:"Reserved2"`
+ // Reserved3 string `json:"Reserved3"`
+ // Reserved4 int `json:"Reserved4"`
+ // Reserved5 string `json:"Reserved5"`
+ // BytesXml string `json:"bytesXml"`
+}
+
+func (s *SessionV3) Wrap() *Session {
+ return &Session{
+ UserName: s.StrUsrName,
+ NOrder: s.NOrder,
+ NickName: s.StrNickName,
+ Content: s.StrContent,
+ NTime: time.Unix(int64(s.NTime), 0),
+ }
+}
+
+// 注意,v4 session 是独立数据库文件
+// CREATE TABLE SessionTable(
+// username TEXT PRIMARY KEY,
+// type INTEGER,
+// unread_count INTEGER,
+// unread_first_msg_srv_id INTEGER,
+// is_hidden INTEGER,
+// summary TEXT,
+// draft TEXT,
+// status INTEGER,
+// last_timestamp INTEGER,
+// sort_timestamp INTEGER,
+// last_clear_unread_timestamp INTEGER,
+// last_msg_locald_id INTEGER,
+// last_msg_type INTEGER,
+// last_msg_sub_type INTEGER,
+// last_msg_sender TEXT,
+// last_sender_display_name TEXT,
+// last_msg_ext_type INTEGER
+// )
+type SessionV4 struct {
+ Username string `json:"username"`
+ Summary string `json:"summary"`
+ LastTimestamp int `json:"last_timestamp"`
+ LastMsgSender string `json:"last_msg_sender"`
+ LastSenderDisplayName string `json:"last_sender_display_name"`
+
+ // Type int `json:"type"`
+ // UnreadCount int `json:"unread_count"`
+ // UnreadFirstMsgSrvID int `json:"unread_first_msg_srv_id"`
+ // IsHidden int `json:"is_hidden"`
+ // Draft string `json:"draft"`
+ // Status int `json:"status"`
+ // SortTimestamp int `json:"sort_timestamp"`
+ // LastClearUnreadTimestamp int `json:"last_clear_unread_timestamp"`
+ // LastMsgLocaldID int `json:"last_msg_locald_id"`
+ // LastMsgType int `json:"last_msg_type"`
+ // LastMsgSubType int `json:"last_msg_sub_type"`
+ // LastMsgExtType int `json:"last_msg_ext_type"`
+}
+
+func (s *Session) PlainText(limit int) string {
+ buf := strings.Builder{}
+ buf.WriteString(s.NickName)
+ buf.WriteString("(")
+ buf.WriteString(s.UserName)
+ buf.WriteString(") ")
+ buf.WriteString(s.NTime.Format("2006-01-02 15:04:05"))
+ buf.WriteString("\n")
+ if limit > 0 {
+ if len(s.Content) > limit {
+ buf.WriteString(s.Content[:limit])
+ buf.WriteString(" <...>")
+ } else {
+ buf.WriteString(s.Content)
+ }
+ }
+ buf.WriteString("\n")
+ return buf.String()
+}
diff --git a/pkg/model/wxproto/bytesextra.pb.go b/pkg/model/wxproto/bytesextra.pb.go
new file mode 100644
index 0000000..648c0ca
--- /dev/null
+++ b/pkg/model/wxproto/bytesextra.pb.go
@@ -0,0 +1,254 @@
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// protoc-gen-go v1.36.5
+// protoc v5.29.3
+// source: bytesextra.proto
+
+package wxproto
+
+import (
+ protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+ protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+ reflect "reflect"
+ sync "sync"
+ unsafe "unsafe"
+)
+
+const (
+ // Verify that this generated code is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+ // Verify that runtime/protoimpl is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+type BytesExtraHeader struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Field1 int32 `protobuf:"varint,1,opt,name=field1,proto3" json:"field1,omitempty"`
+ Field2 int32 `protobuf:"varint,2,opt,name=field2,proto3" json:"field2,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *BytesExtraHeader) Reset() {
+ *x = BytesExtraHeader{}
+ mi := &file_bytesextra_proto_msgTypes[0]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *BytesExtraHeader) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*BytesExtraHeader) ProtoMessage() {}
+
+func (x *BytesExtraHeader) ProtoReflect() protoreflect.Message {
+ mi := &file_bytesextra_proto_msgTypes[0]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use BytesExtraHeader.ProtoReflect.Descriptor instead.
+func (*BytesExtraHeader) Descriptor() ([]byte, []int) {
+ return file_bytesextra_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *BytesExtraHeader) GetField1() int32 {
+ if x != nil {
+ return x.Field1
+ }
+ return 0
+}
+
+func (x *BytesExtraHeader) GetField2() int32 {
+ if x != nil {
+ return x.Field2
+ }
+ return 0
+}
+
+type BytesExtraItem struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Type int32 `protobuf:"varint,1,opt,name=type,proto3" json:"type,omitempty"`
+ Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *BytesExtraItem) Reset() {
+ *x = BytesExtraItem{}
+ mi := &file_bytesextra_proto_msgTypes[1]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *BytesExtraItem) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*BytesExtraItem) ProtoMessage() {}
+
+func (x *BytesExtraItem) ProtoReflect() protoreflect.Message {
+ mi := &file_bytesextra_proto_msgTypes[1]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use BytesExtraItem.ProtoReflect.Descriptor instead.
+func (*BytesExtraItem) Descriptor() ([]byte, []int) {
+ return file_bytesextra_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *BytesExtraItem) GetType() int32 {
+ if x != nil {
+ return x.Type
+ }
+ return 0
+}
+
+func (x *BytesExtraItem) GetValue() string {
+ if x != nil {
+ return x.Value
+ }
+ return ""
+}
+
+type BytesExtra struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Header *BytesExtraHeader `protobuf:"bytes,1,opt,name=header,proto3" json:"header,omitempty"`
+ Items []*BytesExtraItem `protobuf:"bytes,3,rep,name=items,proto3" json:"items,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *BytesExtra) Reset() {
+ *x = BytesExtra{}
+ mi := &file_bytesextra_proto_msgTypes[2]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *BytesExtra) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*BytesExtra) ProtoMessage() {}
+
+func (x *BytesExtra) ProtoReflect() protoreflect.Message {
+ mi := &file_bytesextra_proto_msgTypes[2]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use BytesExtra.ProtoReflect.Descriptor instead.
+func (*BytesExtra) Descriptor() ([]byte, []int) {
+ return file_bytesextra_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *BytesExtra) GetHeader() *BytesExtraHeader {
+ if x != nil {
+ return x.Header
+ }
+ return nil
+}
+
+func (x *BytesExtra) GetItems() []*BytesExtraItem {
+ if x != nil {
+ return x.Items
+ }
+ return nil
+}
+
+var File_bytesextra_proto protoreflect.FileDescriptor
+
+var file_bytesextra_proto_rawDesc = string([]byte{
+ 0x0a, 0x10, 0x62, 0x79, 0x74, 0x65, 0x73, 0x65, 0x78, 0x74, 0x72, 0x61, 0x2e, 0x70, 0x72, 0x6f,
+ 0x74, 0x6f, 0x12, 0x0c, 0x61, 0x70, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66,
+ 0x22, 0x42, 0x0a, 0x10, 0x42, 0x79, 0x74, 0x65, 0x73, 0x45, 0x78, 0x74, 0x72, 0x61, 0x48, 0x65,
+ 0x61, 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x31, 0x18, 0x01,
+ 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x31, 0x12, 0x16, 0x0a, 0x06,
+ 0x66, 0x69, 0x65, 0x6c, 0x64, 0x32, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x66, 0x69,
+ 0x65, 0x6c, 0x64, 0x32, 0x22, 0x3a, 0x0a, 0x0e, 0x42, 0x79, 0x74, 0x65, 0x73, 0x45, 0x78, 0x74,
+ 0x72, 0x61, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01,
+ 0x20, 0x01, 0x28, 0x05, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61,
+ 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65,
+ 0x22, 0x78, 0x0a, 0x0a, 0x42, 0x79, 0x74, 0x65, 0x73, 0x45, 0x78, 0x74, 0x72, 0x61, 0x12, 0x36,
+ 0x0a, 0x06, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e,
+ 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x42, 0x79,
+ 0x74, 0x65, 0x73, 0x45, 0x78, 0x74, 0x72, 0x61, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x52, 0x06,
+ 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x12, 0x32, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18,
+ 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74,
+ 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x42, 0x79, 0x74, 0x65, 0x73, 0x45, 0x78, 0x74, 0x72, 0x61, 0x49,
+ 0x74, 0x65, 0x6d, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x42, 0x0b, 0x5a, 0x09, 0x2e, 0x3b,
+ 0x77, 0x78, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+})
+
+var (
+ file_bytesextra_proto_rawDescOnce sync.Once
+ file_bytesextra_proto_rawDescData []byte
+)
+
+func file_bytesextra_proto_rawDescGZIP() []byte {
+ file_bytesextra_proto_rawDescOnce.Do(func() {
+ file_bytesextra_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_bytesextra_proto_rawDesc), len(file_bytesextra_proto_rawDesc)))
+ })
+ return file_bytesextra_proto_rawDescData
+}
+
+var file_bytesextra_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
+var file_bytesextra_proto_goTypes = []any{
+ (*BytesExtraHeader)(nil), // 0: app.protobuf.BytesExtraHeader
+ (*BytesExtraItem)(nil), // 1: app.protobuf.BytesExtraItem
+ (*BytesExtra)(nil), // 2: app.protobuf.BytesExtra
+}
+var file_bytesextra_proto_depIdxs = []int32{
+ 0, // 0: app.protobuf.BytesExtra.header:type_name -> app.protobuf.BytesExtraHeader
+ 1, // 1: app.protobuf.BytesExtra.items:type_name -> app.protobuf.BytesExtraItem
+ 2, // [2:2] is the sub-list for method output_type
+ 2, // [2:2] is the sub-list for method input_type
+ 2, // [2:2] is the sub-list for extension type_name
+ 2, // [2:2] is the sub-list for extension extendee
+ 0, // [0:2] is the sub-list for field type_name
+}
+
+func init() { file_bytesextra_proto_init() }
+func file_bytesextra_proto_init() {
+ if File_bytesextra_proto != nil {
+ return
+ }
+ type x struct{}
+ out := protoimpl.TypeBuilder{
+ File: protoimpl.DescBuilder{
+ GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+ RawDescriptor: unsafe.Slice(unsafe.StringData(file_bytesextra_proto_rawDesc), len(file_bytesextra_proto_rawDesc)),
+ NumEnums: 0,
+ NumMessages: 3,
+ NumExtensions: 0,
+ NumServices: 0,
+ },
+ GoTypes: file_bytesextra_proto_goTypes,
+ DependencyIndexes: file_bytesextra_proto_depIdxs,
+ MessageInfos: file_bytesextra_proto_msgTypes,
+ }.Build()
+ File_bytesextra_proto = out.File
+ file_bytesextra_proto_goTypes = nil
+ file_bytesextra_proto_depIdxs = nil
+}
diff --git a/pkg/model/wxproto/bytesextra.proto b/pkg/model/wxproto/bytesextra.proto
new file mode 100644
index 0000000..1c7a50e
--- /dev/null
+++ b/pkg/model/wxproto/bytesextra.proto
@@ -0,0 +1,18 @@
+syntax = "proto3";
+package app.protobuf;
+option go_package=".;wxproto";
+
+message BytesExtraHeader {
+ int32 field1 = 1;
+ int32 field2 = 2;
+}
+
+message BytesExtraItem {
+ int32 type = 1;
+ string value = 2;
+}
+
+message BytesExtra {
+ BytesExtraHeader header = 1;
+ repeated BytesExtraItem items = 3;
+}
\ No newline at end of file
diff --git a/pkg/model/wxproto/roomdata.pb.go b/pkg/model/wxproto/roomdata.pb.go
new file mode 100644
index 0000000..70a9fb4
--- /dev/null
+++ b/pkg/model/wxproto/roomdata.pb.go
@@ -0,0 +1,222 @@
+// v3 & v4 通用,可能会有部分字段差异
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// protoc-gen-go v1.36.5
+// protoc v5.29.3
+// source: roomdata.proto
+
+package wxproto
+
+import (
+ protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+ protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+ reflect "reflect"
+ sync "sync"
+ unsafe "unsafe"
+)
+
+const (
+ // Verify that this generated code is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+ // Verify that runtime/protoimpl is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+type RoomData struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Users []*RoomDataUser `protobuf:"bytes,1,rep,name=users,proto3" json:"users,omitempty"`
+ RoomCap *int32 `protobuf:"varint,5,opt,name=roomCap,proto3,oneof" json:"roomCap,omitempty"` // 只在第一份数据中出现,值为500
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *RoomData) Reset() {
+ *x = RoomData{}
+ mi := &file_roomdata_proto_msgTypes[0]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *RoomData) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*RoomData) ProtoMessage() {}
+
+func (x *RoomData) ProtoReflect() protoreflect.Message {
+ mi := &file_roomdata_proto_msgTypes[0]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use RoomData.ProtoReflect.Descriptor instead.
+func (*RoomData) Descriptor() ([]byte, []int) {
+ return file_roomdata_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *RoomData) GetUsers() []*RoomDataUser {
+ if x != nil {
+ return x.Users
+ }
+ return nil
+}
+
+func (x *RoomData) GetRoomCap() int32 {
+ if x != nil && x.RoomCap != nil {
+ return *x.RoomCap
+ }
+ return 0
+}
+
+type RoomDataUser struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ UserName string `protobuf:"bytes,1,opt,name=userName,proto3" json:"userName,omitempty"` // 用户ID或名称
+ DisplayName *string `protobuf:"bytes,2,opt,name=displayName,proto3,oneof" json:"displayName,omitempty"` // 显示名称,可能是UTF-8编码的中文,部分记录可能为空
+ Status int32 `protobuf:"varint,3,opt,name=status,proto3" json:"status,omitempty"` // 状态码,值范围0-9
+ Inviter *string `protobuf:"bytes,4,opt,name=inviter,proto3,oneof" json:"inviter,omitempty"` // 邀请人
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *RoomDataUser) Reset() {
+ *x = RoomDataUser{}
+ mi := &file_roomdata_proto_msgTypes[1]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *RoomDataUser) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*RoomDataUser) ProtoMessage() {}
+
+func (x *RoomDataUser) ProtoReflect() protoreflect.Message {
+ mi := &file_roomdata_proto_msgTypes[1]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use RoomDataUser.ProtoReflect.Descriptor instead.
+func (*RoomDataUser) Descriptor() ([]byte, []int) {
+ return file_roomdata_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *RoomDataUser) GetUserName() string {
+ if x != nil {
+ return x.UserName
+ }
+ return ""
+}
+
+func (x *RoomDataUser) GetDisplayName() string {
+ if x != nil && x.DisplayName != nil {
+ return *x.DisplayName
+ }
+ return ""
+}
+
+func (x *RoomDataUser) GetStatus() int32 {
+ if x != nil {
+ return x.Status
+ }
+ return 0
+}
+
+func (x *RoomDataUser) GetInviter() string {
+ if x != nil && x.Inviter != nil {
+ return *x.Inviter
+ }
+ return ""
+}
+
+var File_roomdata_proto protoreflect.FileDescriptor
+
+var file_roomdata_proto_rawDesc = string([]byte{
+ 0x0a, 0x0e, 0x72, 0x6f, 0x6f, 0x6d, 0x64, 0x61, 0x74, 0x61, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+ 0x12, 0x0c, 0x61, 0x70, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x22, 0x67,
+ 0x0a, 0x08, 0x52, 0x6f, 0x6f, 0x6d, 0x44, 0x61, 0x74, 0x61, 0x12, 0x30, 0x0a, 0x05, 0x75, 0x73,
+ 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x61, 0x70, 0x70, 0x2e,
+ 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x52, 0x6f, 0x6f, 0x6d, 0x44, 0x61, 0x74,
+ 0x61, 0x55, 0x73, 0x65, 0x72, 0x52, 0x05, 0x75, 0x73, 0x65, 0x72, 0x73, 0x12, 0x1d, 0x0a, 0x07,
+ 0x72, 0x6f, 0x6f, 0x6d, 0x43, 0x61, 0x70, 0x18, 0x05, 0x20, 0x01, 0x28, 0x05, 0x48, 0x00, 0x52,
+ 0x07, 0x72, 0x6f, 0x6f, 0x6d, 0x43, 0x61, 0x70, 0x88, 0x01, 0x01, 0x42, 0x0a, 0x0a, 0x08, 0x5f,
+ 0x72, 0x6f, 0x6f, 0x6d, 0x43, 0x61, 0x70, 0x22, 0xa4, 0x01, 0x0a, 0x0c, 0x52, 0x6f, 0x6f, 0x6d,
+ 0x44, 0x61, 0x74, 0x61, 0x55, 0x73, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72,
+ 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72,
+ 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x25, 0x0a, 0x0b, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e,
+ 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0b, 0x64, 0x69, 0x73,
+ 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x88, 0x01, 0x01, 0x12, 0x16, 0x0a, 0x06, 0x73,
+ 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x73, 0x74, 0x61,
+ 0x74, 0x75, 0x73, 0x12, 0x1d, 0x0a, 0x07, 0x69, 0x6e, 0x76, 0x69, 0x74, 0x65, 0x72, 0x18, 0x04,
+ 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x07, 0x69, 0x6e, 0x76, 0x69, 0x74, 0x65, 0x72, 0x88,
+ 0x01, 0x01, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61,
+ 0x6d, 0x65, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x69, 0x6e, 0x76, 0x69, 0x74, 0x65, 0x72, 0x42, 0x0b,
+ 0x5a, 0x09, 0x2e, 0x3b, 0x77, 0x78, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f,
+ 0x74, 0x6f, 0x33,
+})
+
+var (
+ file_roomdata_proto_rawDescOnce sync.Once
+ file_roomdata_proto_rawDescData []byte
+)
+
+func file_roomdata_proto_rawDescGZIP() []byte {
+ file_roomdata_proto_rawDescOnce.Do(func() {
+ file_roomdata_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_roomdata_proto_rawDesc), len(file_roomdata_proto_rawDesc)))
+ })
+ return file_roomdata_proto_rawDescData
+}
+
+var file_roomdata_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
+var file_roomdata_proto_goTypes = []any{
+ (*RoomData)(nil), // 0: app.protobuf.RoomData
+ (*RoomDataUser)(nil), // 1: app.protobuf.RoomDataUser
+}
+var file_roomdata_proto_depIdxs = []int32{
+ 1, // 0: app.protobuf.RoomData.users:type_name -> app.protobuf.RoomDataUser
+ 1, // [1:1] is the sub-list for method output_type
+ 1, // [1:1] is the sub-list for method input_type
+ 1, // [1:1] is the sub-list for extension type_name
+ 1, // [1:1] is the sub-list for extension extendee
+ 0, // [0:1] is the sub-list for field type_name
+}
+
+func init() { file_roomdata_proto_init() }
+func file_roomdata_proto_init() {
+ if File_roomdata_proto != nil {
+ return
+ }
+ file_roomdata_proto_msgTypes[0].OneofWrappers = []any{}
+ file_roomdata_proto_msgTypes[1].OneofWrappers = []any{}
+ type x struct{}
+ out := protoimpl.TypeBuilder{
+ File: protoimpl.DescBuilder{
+ GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+ RawDescriptor: unsafe.Slice(unsafe.StringData(file_roomdata_proto_rawDesc), len(file_roomdata_proto_rawDesc)),
+ NumEnums: 0,
+ NumMessages: 2,
+ NumExtensions: 0,
+ NumServices: 0,
+ },
+ GoTypes: file_roomdata_proto_goTypes,
+ DependencyIndexes: file_roomdata_proto_depIdxs,
+ MessageInfos: file_roomdata_proto_msgTypes,
+ }.Build()
+ File_roomdata_proto = out.File
+ file_roomdata_proto_goTypes = nil
+ file_roomdata_proto_depIdxs = nil
+}
diff --git a/pkg/model/wxproto/roomdata.proto b/pkg/model/wxproto/roomdata.proto
new file mode 100644
index 0000000..c7f1a97
--- /dev/null
+++ b/pkg/model/wxproto/roomdata.proto
@@ -0,0 +1,16 @@
+// v3 & v4 通用,可能会有部分字段差异
+syntax = "proto3";
+package app.protobuf;
+option go_package=".;wxproto";
+
+message RoomData {
+ repeated RoomDataUser users = 1;
+ optional int32 roomCap = 5; // 只在第一份数据中出现,值为500
+}
+
+message RoomDataUser {
+ string userName = 1; // 用户ID或名称
+ optional string displayName = 2; // 显示名称,可能是UTF-8编码的中文,部分记录可能为空
+ int32 status = 3; // 状态码,值范围0-9
+ optional string inviter = 4; // 邀请人
+}
diff --git a/pkg/util/os.go b/pkg/util/os.go
new file mode 100644
index 0000000..ae4ec4c
--- /dev/null
+++ b/pkg/util/os.go
@@ -0,0 +1,135 @@
+package util
+
+import (
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "regexp"
+ "runtime"
+
+ log "github.com/sirupsen/logrus"
+)
+
+// FindFilesWithPatterns 在指定目录下查找匹配多个正则表达式的文件
+// directory: 要搜索的目录路径
+// patterns: 正则表达式模式列表
+// recursive: 是否递归搜索子目录
+// 返回匹配的文件路径列表和可能的错误
+func FindFilesWithPatterns(directory string, pattern string, recursive bool) ([]string, error) {
+ // 编译所有正则表达式
+ re, err := regexp.Compile(pattern)
+ if err != nil {
+ return nil, fmt.Errorf("无效的正则表达式 '%s': %v", pattern, err)
+ }
+
+ // 检查目录是否存在
+ dirInfo, err := os.Stat(directory)
+ if err != nil {
+ return nil, fmt.Errorf("无法访问目录 '%s': %v", directory, err)
+ }
+ if !dirInfo.IsDir() {
+ return nil, fmt.Errorf("'%s' 不是一个目录", directory)
+ }
+
+ // 存储匹配的文件路径
+ var matchedFiles []string
+
+ // 创建文件系统
+ fsys := os.DirFS(directory)
+
+ // 遍历文件系统
+ err = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+
+ // 如果是目录且不递归,则跳过子目录
+ if d.IsDir() {
+ if !recursive && path != "." {
+ return fs.SkipDir
+ }
+ return nil
+ }
+
+ // 检查文件名是否匹配任何一个正则表达式
+ if re.MatchString(d.Name()) {
+ // 添加完整路径到结果列表
+ fullPath := filepath.Join(directory, path)
+ matchedFiles = append(matchedFiles, fullPath)
+ }
+
+ return nil
+ })
+
+ if err != nil {
+ return nil, fmt.Errorf("遍历目录时出错: %v", err)
+ }
+
+ return matchedFiles, nil
+}
+
+func DefaultWorkDir(account string) string {
+ if len(account) == 0 {
+ switch runtime.GOOS {
+ case "windows":
+ return filepath.Join(os.ExpandEnv("${USERPROFILE}"), "Documents", "chatlog")
+ case "darwin":
+ return filepath.Join(os.ExpandEnv("${HOME}"), "Documents", "chatlog")
+ default:
+ return filepath.Join(os.ExpandEnv("${HOME}"), "chatlog")
+ }
+ }
+ switch runtime.GOOS {
+ case "windows":
+ return filepath.Join(os.ExpandEnv("${USERPROFILE}"), "Documents", "chatlog", account)
+ case "darwin":
+ return filepath.Join(os.ExpandEnv("${HOME}"), "Documents", "chatlog", account)
+ default:
+ return filepath.Join(os.ExpandEnv("${HOME}"), "chatlog", account)
+ }
+}
+
+func GetDirSize(dir string) string {
+ var size int64
+ filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
+ if err == nil {
+ size += info.Size()
+ }
+ return nil
+ })
+ return ByteCountSI(size)
+}
+
+func ByteCountSI(b int64) string {
+ const unit = 1000
+ if b < unit {
+ return fmt.Sprintf("%d B", b)
+ }
+ div, exp := int64(unit), 0
+ for n := b / unit; n >= unit; n /= unit {
+ div *= unit
+ exp++
+ }
+ return fmt.Sprintf("%.1f %cB",
+ float64(b)/float64(div), "kMGTPE"[exp])
+}
+
+// PrepareDir ensures that the specified directory path exists.
+// If the directory does not exist, it attempts to create it.
+func PrepareDir(path string) error {
+ stat, err := os.Stat(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ if err := os.MkdirAll(path, 0755); err != nil {
+ return err
+ }
+ } else {
+ return err
+ }
+ } else if !stat.IsDir() {
+ log.Debugf("%s is not a directory", path)
+ return fmt.Errorf("%s is not a directory", path)
+ }
+ return nil
+}
diff --git a/pkg/util/os_windows.go b/pkg/util/os_windows.go
new file mode 100644
index 0000000..b9caa16
--- /dev/null
+++ b/pkg/util/os_windows.go
@@ -0,0 +1,15 @@
+package util
+
+import (
+ "fmt"
+
+ "golang.org/x/sys/windows"
+)
+
+func Is64Bit(handle windows.Handle) (bool, error) {
+ var is32Bit bool
+ if err := windows.IsWow64Process(handle, &is32Bit); err != nil {
+ return false, fmt.Errorf("检查进程位数失败: %w", err)
+ }
+ return !is32Bit, nil
+}
diff --git a/pkg/util/strings.go b/pkg/util/strings.go
new file mode 100644
index 0000000..2ec791c
--- /dev/null
+++ b/pkg/util/strings.go
@@ -0,0 +1,34 @@
+package util
+
+import (
+ "fmt"
+ "strconv"
+ "unicode"
+ "unicode/utf8"
+)
+
+func IsNormalString(b []byte) bool {
+ str := string(b)
+
+ // 检查是否为有效的 UTF-8
+ if !utf8.ValidString(str) {
+ return false
+ }
+
+ // 检查是否全部为可打印字符
+ for _, r := range str {
+ if !unicode.IsPrint(r) {
+ return false
+ }
+ }
+
+ return true
+}
+
+func MustAnyToInt(v interface{}) int {
+ str := fmt.Sprintf("%v", v)
+ if i, err := strconv.Atoi(str); err == nil {
+ return i
+ }
+ return 0
+}
diff --git a/pkg/util/time.go b/pkg/util/time.go
new file mode 100644
index 0000000..cc63085
--- /dev/null
+++ b/pkg/util/time.go
@@ -0,0 +1,636 @@
+package util
+
+import (
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+)
+
+var zoneStr = time.Now().Format("-0700")
+
+// 时间粒度常量
+type TimeGranularity int
+
+const (
+ GranularityUnknown TimeGranularity = iota // 未知粒度
+ GranularitySecond // 精确到秒
+ GranularityMinute // 精确到分钟
+ GranularityHour // 精确到小时
+ GranularityDay // 精确到天
+ GranularityMonth // 精确到月
+ GranularityQuarter // 精确到季度
+ GranularityYear // 精确到年
+)
+
+// timeOf 内部函数,解析各种格式的时间点,并返回时间粒度
+// 支持以下格式:
+// 1. 时间戳(秒): 1609459200 (GranularitySecond)
+// 2. 标准日期: 20060102, 2006-01-02 (GranularityDay)
+// 3. 带时间的日期: 20060102/15:04, 2006-01-02/15:04 (GranularityMinute)
+// 4. 完整时间: 20060102150405 (GranularitySecond)
+// 5. RFC3339: 2006-01-02T15:04:05Z07:00 (GranularitySecond)
+// 6. 相对时间: 5h-ago, 3d-ago, 1w-ago, 1m-ago, 1y-ago (根据单位确定粒度)
+// 7. 自然语言: now (GranularitySecond), today, yesterday (GranularityDay)
+// 8. 年份: 2006 (GranularityYear)
+// 9. 月份: 200601, 2006-01 (GranularityMonth)
+// 10. 季度: 2006Q1, 2006Q2, 2006Q3, 2006Q4 (GranularityQuarter)
+// 11. 年月日时分: 200601021504 (GranularityMinute)
+func timeOf(str string) (t time.Time, g TimeGranularity, ok bool) {
+ if str == "" {
+ return time.Time{}, GranularityUnknown, false
+ }
+
+ str = strings.TrimSpace(str)
+
+ // 处理自然语言时间
+ switch strings.ToLower(str) {
+ case "now":
+ return time.Now(), GranularitySecond, true
+ case "today":
+ now := time.Now()
+ return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()), GranularityDay, true
+ case "yesterday":
+ now := time.Now().AddDate(0, 0, -1)
+ return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()), GranularityDay, true
+ case "this-week":
+ now := time.Now()
+ weekday := int(now.Weekday())
+ if weekday == 0 { // 周日
+ weekday = 7
+ }
+ // 本周一
+ monday := now.AddDate(0, 0, -(weekday - 1))
+ return time.Date(monday.Year(), monday.Month(), monday.Day(), 0, 0, 0, 0, now.Location()), GranularityDay, true
+ case "last-week":
+ now := time.Now()
+ weekday := int(now.Weekday())
+ if weekday == 0 { // 周日
+ weekday = 7
+ }
+ // 上周一
+ lastMonday := now.AddDate(0, 0, -(weekday-1)-7)
+ return time.Date(lastMonday.Year(), lastMonday.Month(), lastMonday.Day(), 0, 0, 0, 0, now.Location()), GranularityDay, true
+ case "this-month":
+ now := time.Now()
+ return time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()), GranularityMonth, true
+ case "last-month":
+ now := time.Now()
+ return time.Date(now.Year(), now.Month()-1, 1, 0, 0, 0, 0, now.Location()), GranularityMonth, true
+ case "this-year":
+ now := time.Now()
+ return time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location()), GranularityYear, true
+ case "last-year":
+ now := time.Now()
+ return time.Date(now.Year()-1, 1, 1, 0, 0, 0, 0, now.Location()), GranularityYear, true
+ case "all":
+ // 返回零值时间
+ return time.Time{}, GranularityYear, true
+ }
+
+ // 处理相对时间: 5h-ago, 3d-ago, 1w-ago, 1m-ago, 1y-ago
+ if strings.HasSuffix(str, "-ago") {
+ str = strings.TrimSuffix(str, "-ago")
+
+ // 特殊处理 0d-ago 为当天开始
+ if str == "0d" {
+ now := time.Now()
+ return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()), GranularityDay, true
+ }
+
+ // 解析数字和单位
+ re := regexp.MustCompile(`^(\d+)([hdwmy])$`)
+ matches := re.FindStringSubmatch(str)
+ if len(matches) == 3 {
+ num, err := strconv.Atoi(matches[1])
+ if err != nil {
+ return time.Time{}, GranularityUnknown, false
+ }
+
+ // 确保数字是正数
+ if num <= 0 {
+ // 对于0d-ago已经特殊处理,其他0或负数都是无效的
+ if num == 0 && matches[2] != "d" {
+ return time.Time{}, GranularityUnknown, false
+ }
+ return time.Time{}, GranularityUnknown, false
+ }
+
+ now := time.Now()
+ var resultTime time.Time
+ var granularity TimeGranularity
+
+ switch matches[2] {
+ case "h": // 小时
+ resultTime = now.Add(-time.Duration(num) * time.Hour)
+ granularity = GranularityHour
+ case "d": // 天
+ resultTime = now.AddDate(0, 0, -num)
+ granularity = GranularityDay
+ case "w": // 周
+ resultTime = now.AddDate(0, 0, -num*7)
+ granularity = GranularityDay
+ case "m": // 月
+ resultTime = now.AddDate(0, -num, 0)
+ granularity = GranularityMonth
+ case "y": // 年
+ resultTime = now.AddDate(-num, 0, 0)
+ granularity = GranularityYear
+ default:
+ return time.Time{}, GranularityUnknown, false
+ }
+
+ return resultTime, granularity, true
+ }
+
+ // 尝试标准 duration 解析
+ dur, err := time.ParseDuration(str)
+ if err == nil {
+ // 根据duration单位确定粒度
+ hours := dur.Hours()
+ if hours < 1 {
+ return time.Now().Add(-dur), GranularitySecond, true
+ } else if hours < 24 {
+ return time.Now().Add(-dur), GranularityHour, true
+ } else {
+ return time.Now().Add(-dur), GranularityDay, true
+ }
+ }
+
+ return time.Time{}, GranularityUnknown, false
+ }
+
+ // 处理季度: 2006Q1, 2006Q2, 2006Q3, 2006Q4
+ if matched, _ := regexp.MatchString(`^\d{4}Q[1-4]$`, str); matched {
+ re := regexp.MustCompile(`^(\d{4})Q([1-4])$`)
+ matches := re.FindStringSubmatch(str)
+ if len(matches) == 3 {
+ year, _ := strconv.Atoi(matches[1])
+ quarter, _ := strconv.Atoi(matches[2])
+
+ // 验证年份范围
+ if year < 1970 || year > 9999 {
+ return time.Time{}, GranularityUnknown, false
+ }
+
+ // 计算季度的开始月份
+ startMonth := time.Month((quarter-1)*3 + 1)
+
+ return time.Date(year, startMonth, 1, 0, 0, 0, 0, time.Local), GranularityQuarter, true
+ }
+ }
+
+ // 处理年份: 2006
+ if len(str) == 4 && isDigitsOnly(str) {
+ year, err := strconv.Atoi(str)
+ if err == nil && year >= 1970 && year <= 9999 {
+ return time.Date(year, 1, 1, 0, 0, 0, 0, time.Local), GranularityYear, true
+ }
+ return time.Time{}, GranularityUnknown, false
+ }
+
+ // 处理月份: 200601 或 2006-01
+ if (len(str) == 6 && isDigitsOnly(str)) || (len(str) == 7 && strings.Count(str, "-") == 1) {
+ var year, month int
+ var err error
+
+ if len(str) == 6 && isDigitsOnly(str) {
+ year, err = strconv.Atoi(str[0:4])
+ if err != nil {
+ return time.Time{}, GranularityUnknown, false
+ }
+ month, err = strconv.Atoi(str[4:6])
+ if err != nil {
+ return time.Time{}, GranularityUnknown, false
+ }
+ } else { // 2006-01
+ parts := strings.Split(str, "-")
+ if len(parts) != 2 {
+ return time.Time{}, GranularityUnknown, false
+ }
+ year, err = strconv.Atoi(parts[0])
+ if err != nil {
+ return time.Time{}, GranularityUnknown, false
+ }
+ month, err = strconv.Atoi(parts[1])
+ if err != nil {
+ return time.Time{}, GranularityUnknown, false
+ }
+ }
+
+ if year < 1970 || year > 9999 || month < 1 || month > 12 {
+ return time.Time{}, GranularityUnknown, false
+ }
+
+ return time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.Local), GranularityMonth, true
+ }
+
+ // 处理日期格式: 20060102 或 2006-01-02
+ if len(str) == 8 && isDigitsOnly(str) {
+ // 验证年月日
+ year, _ := strconv.Atoi(str[0:4])
+ month, _ := strconv.Atoi(str[4:6])
+ day, _ := strconv.Atoi(str[6:8])
+
+ if year < 1970 || year > 9999 || month < 1 || month > 12 || day < 1 || day > 31 {
+ return time.Time{}, GranularityUnknown, false
+ }
+
+ // 进一步验证日期是否有效
+ if !isValidDate(year, month, day) {
+ return time.Time{}, GranularityUnknown, false
+ }
+
+ // 直接构造时间
+ result := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.Local)
+ return result, GranularityDay, true
+ } else if len(str) == 10 && strings.Count(str, "-") == 2 {
+ // 验证年月日
+ parts := strings.Split(str, "-")
+ if len(parts) != 3 {
+ return time.Time{}, GranularityUnknown, false
+ }
+
+ year, err1 := strconv.Atoi(parts[0])
+ month, err2 := strconv.Atoi(parts[1])
+ day, err3 := strconv.Atoi(parts[2])
+
+ if err1 != nil || err2 != nil || err3 != nil {
+ return time.Time{}, GranularityUnknown, false
+ }
+
+ if year < 1970 || year > 9999 || month < 1 || month > 12 || day < 1 || day > 31 {
+ return time.Time{}, GranularityUnknown, false
+ }
+
+ // 进一步验证日期是否有效
+ if !isValidDate(year, month, day) {
+ return time.Time{}, GranularityUnknown, false
+ }
+
+ // 直接构造时间
+ result := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.Local)
+ return result, GranularityDay, true
+ }
+
+ // 处理年月日时分: 200601021504
+ if len(str) == 12 && isDigitsOnly(str) {
+ year, _ := strconv.Atoi(str[0:4])
+ month, _ := strconv.Atoi(str[4:6])
+ day, _ := strconv.Atoi(str[6:8])
+ hour, _ := strconv.Atoi(str[8:10])
+ minute, _ := strconv.Atoi(str[10:12])
+
+ if year < 1970 || year > 9999 || month < 1 || month > 12 || day < 1 || day > 31 ||
+ hour < 0 || hour > 23 || minute < 0 || minute > 59 {
+ return time.Time{}, GranularityUnknown, false
+ }
+
+ // 进一步验证日期是否有效
+ if !isValidDate(year, month, day) {
+ return time.Time{}, GranularityUnknown, false
+ }
+
+ // 直接构造时间
+ result := time.Date(year, time.Month(month), day, hour, minute, 0, 0, time.Local)
+ return result, GranularityMinute, true
+ }
+
+ // 处理带时间的日期: 20060102/15:04 或 2006-01-02/15:04
+ if strings.Contains(str, "/") {
+ parts := strings.Split(str, "/")
+ if len(parts) != 2 {
+ return time.Time{}, GranularityUnknown, false
+ }
+
+ datePart := parts[0]
+ timePart := parts[1]
+
+ // 验证日期部分
+ var year, month, day int
+ var err1, err2, err3 error
+
+ if len(datePart) == 8 && isDigitsOnly(datePart) {
+ year, err1 = strconv.Atoi(datePart[0:4])
+ month, err2 = strconv.Atoi(datePart[4:6])
+ day, err3 = strconv.Atoi(datePart[6:8])
+ } else if len(datePart) == 10 && strings.Count(datePart, "-") == 2 {
+ dateParts := strings.Split(datePart, "-")
+ if len(dateParts) != 3 {
+ return time.Time{}, GranularityUnknown, false
+ }
+ year, err1 = strconv.Atoi(dateParts[0])
+ month, err2 = strconv.Atoi(dateParts[1])
+ day, err3 = strconv.Atoi(dateParts[2])
+ } else {
+ return time.Time{}, GranularityUnknown, false
+ }
+
+ if err1 != nil || err2 != nil || err3 != nil {
+ return time.Time{}, GranularityUnknown, false
+ }
+
+ if year < 1970 || year > 9999 || month < 1 || month > 12 || day < 1 || day > 31 {
+ return time.Time{}, GranularityUnknown, false
+ }
+
+ // 进一步验证日期是否有效
+ if !isValidDate(year, month, day) {
+ return time.Time{}, GranularityUnknown, false
+ }
+
+ // 验证时间部分
+ if !regexp.MustCompile(`^\d{2}:\d{2}$`).MatchString(timePart) {
+ return time.Time{}, GranularityUnknown, false
+ }
+
+ timeParts := strings.Split(timePart, ":")
+ hour, err1 := strconv.Atoi(timeParts[0])
+ minute, err2 := strconv.Atoi(timeParts[1])
+
+ if err1 != nil || err2 != nil {
+ return time.Time{}, GranularityUnknown, false
+ }
+
+ if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
+ return time.Time{}, GranularityUnknown, false
+ }
+
+ // 直接构造时间
+ result := time.Date(year, time.Month(month), day, hour, minute, 0, 0, time.Local)
+ return result, GranularityMinute, true
+ }
+
+ // 处理完整时间: 20060102150405
+ if len(str) == 14 && isDigitsOnly(str) {
+ year, _ := strconv.Atoi(str[0:4])
+ month, _ := strconv.Atoi(str[4:6])
+ day, _ := strconv.Atoi(str[6:8])
+ hour, _ := strconv.Atoi(str[8:10])
+ minute, _ := strconv.Atoi(str[10:12])
+ second, _ := strconv.Atoi(str[12:14])
+
+ if year < 1970 || year > 9999 || month < 1 || month > 12 || day < 1 || day > 31 ||
+ hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59 {
+ return time.Time{}, GranularityUnknown, false
+ }
+
+ // 进一步验证日期是否有效
+ if !isValidDate(year, month, day) {
+ return time.Time{}, GranularityUnknown, false
+ }
+
+ // 直接构造时间
+ result := time.Date(year, time.Month(month), day, hour, minute, second, 0, time.Local)
+ return result, GranularitySecond, true
+ }
+
+ // 处理时间戳(秒)
+ if isDigitsOnly(str) {
+ n, err := strconv.ParseInt(str, 10, 64)
+ if err == nil {
+ // 检查是否是合理的时间戳范围
+ if n >= 1000000000 && n <= 253402300799 { // 2001年到2286年的秒级时间戳
+ return time.Unix(n, 0), GranularitySecond, true
+ }
+ }
+ return time.Time{}, GranularityUnknown, false
+ }
+
+ // 处理 RFC3339: 2006-01-02T15:04:05Z07:00
+ if strings.Contains(str, "T") && (strings.Contains(str, "Z") || strings.Contains(str, "+") || strings.Contains(str, "-")) {
+ t, err := time.Parse(time.RFC3339, str)
+ if err != nil {
+ // 尝试不带秒的格式
+ t, err = time.Parse("2006-01-02T15:04Z07:00", str)
+ }
+ if err == nil {
+ return t, GranularitySecond, true
+ }
+ }
+
+ // 排除所有其他不支持的格式
+ return time.Time{}, GranularityUnknown, false
+}
+
+// TimeOf 解析各种格式的时间点
+// 支持以下格式:
+// 1. 时间戳(秒): 1609459200
+// 2. 标准日期: 20060102, 2006-01-02
+// 3. 带时间的日期: 20060102/15:04, 2006-01-02/15:04
+// 4. 完整时间: 20060102150405
+// 5. RFC3339: 2006-01-02T15:04:05Z07:00
+// 6. 相对时间: 5h-ago, 3d-ago, 1w-ago, 1m-ago, 1y-ago (小时、天、周、月、年)
+// 7. 自然语言: now, today, yesterday
+// 8. 年份: 2006
+// 9. 月份: 200601, 2006-01
+// 10. 季度: 2006Q1, 2006Q2, 2006Q3, 2006Q4
+// 11. 年月日时分: 200601021504
+func TimeOf(str string) (t time.Time, ok bool) {
+ t, _, ok = timeOf(str)
+ return
+}
+
+// TimeRangeOf 解析各种格式的时间范围
+// 支持以下格式:
+// 1. 单个时间点: 根据时间粒度确定合适的时间范围
+// - 精确到秒/分钟/小时: 扩展为当天范围
+// - 精确到天: 当天 00:00:00 ~ 23:59:59
+// - 精确到月: 当月第一天 ~ 最后一天
+// - 精确到季度: 季度第一天 ~ 最后一天
+// - 精确到年: 当年第一天 ~ 最后一天
+//
+// 2. 时间区间: 2006-01-01~2006-01-31, 2006-01-01,2006-01-31, 2006-01-01 to 2006-01-31
+// 3. 相对时间: last-7d, last-30d, last-3m, last-1y (最近7天、30天、3个月、1年)
+// 4. 特定时间段: today, yesterday, this-week, last-week, this-month, last-month, this-year, last-year
+// 5. all: 表示所有时间
+func TimeRangeOf(str string) (start, end time.Time, ok bool) {
+ if str == "" {
+ return time.Time{}, time.Time{}, false
+ }
+
+ str = strings.TrimSpace(str)
+
+ // 处理 all 特殊情况
+ if strings.ToLower(str) == "all" {
+ start = time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)
+ end = time.Date(9999, 12, 31, 23, 59, 59, 999999999, time.UTC)
+ return start, end, true
+ }
+
+ // 处理相对时间范围: last-7d, last-30d, last-3m, last-1y
+ if matched, _ := regexp.MatchString(`^last-\d+[dwmy]$`, str); matched {
+ re := regexp.MustCompile(`^last-(\d+)([dwmy])$`)
+ matches := re.FindStringSubmatch(str)
+ if len(matches) == 3 {
+ num, err := strconv.Atoi(matches[1])
+ if err != nil || num <= 0 {
+ return time.Time{}, time.Time{}, false
+ }
+
+ now := time.Now()
+ end = time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 999999999, now.Location())
+
+ switch matches[2] {
+ case "d": // 天
+ start = now.AddDate(0, 0, -num)
+ start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location())
+ return start, end, true
+ case "w": // 周
+ start = now.AddDate(0, 0, -num*7)
+ start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location())
+ return start, end, true
+ case "m": // 月
+ start = now.AddDate(0, -num, 0)
+ start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location())
+ return start, end, true
+ case "y": // 年
+ start = now.AddDate(-num, 0, 0)
+ start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location())
+ return start, end, true
+ }
+ }
+ }
+
+ // 处理时间区间: 2006-01-01~2006-01-31, 2006-01-01,2006-01-31, 2006-01-01 to 2006-01-31
+ separators := []string{"~", ",", " to "}
+ for _, sep := range separators {
+ if strings.Contains(str, sep) {
+ parts := strings.Split(str, sep)
+ if len(parts) == 2 {
+ startTime, startGran, startOk := timeOf(strings.TrimSpace(parts[0]))
+ endTime, endGran, endOk := timeOf(strings.TrimSpace(parts[1]))
+
+ if startOk && endOk {
+ // 根据粒度调整时间范围
+ start = adjustStartTime(startTime, startGran)
+ end = adjustEndTime(endTime, endGran)
+
+ // 确保开始时间早于结束时间
+ if start.After(end) {
+ // 正确交换开始和结束时间
+ start, end = adjustStartTime(endTime, endGran), adjustEndTime(startTime, startGran)
+ }
+
+ return start, end, true
+ }
+ }
+ }
+ }
+
+ // 处理单个时间点,根据粒度确定合适的时间范围
+ t, g, ok := timeOf(str)
+ if ok {
+ switch g {
+ case GranularitySecond, GranularityMinute, GranularityHour:
+ // 精确到秒/分钟/小时的时间点,扩展为当天范围
+ start = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
+ end = time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 999999999, t.Location())
+ case GranularityDay:
+ // 精确到天的时间点
+ start = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
+ end = time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 999999999, t.Location())
+ case GranularityMonth:
+ // 精确到月的时间点
+ start = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location())
+ end = time.Date(t.Year(), t.Month()+1, 0, 23, 59, 59, 999999999, t.Location())
+ case GranularityQuarter:
+ // 精确到季度的时间点
+ quarter := (t.Month()-1)/3 + 1
+ startMonth := time.Month((int(quarter)-1)*3 + 1)
+ endMonth := startMonth + 2
+ start = time.Date(t.Year(), startMonth, 1, 0, 0, 0, 0, t.Location())
+ end = time.Date(t.Year(), endMonth+1, 0, 23, 59, 59, 999999999, t.Location())
+ case GranularityYear:
+ // 精确到年的时间点
+ start = time.Date(t.Year(), 1, 1, 0, 0, 0, 0, t.Location())
+ end = time.Date(t.Year(), 12, 31, 23, 59, 59, 999999999, t.Location())
+ }
+ return start, end, true
+ }
+
+ return time.Time{}, time.Time{}, false
+}
+
+// adjustStartTime 根据时间粒度调整开始时间
+func adjustStartTime(t time.Time, g TimeGranularity) time.Time {
+ switch g {
+ case GranularitySecond, GranularityMinute, GranularityHour:
+ // 对于精确到秒/分钟/小时的时间,保持原样
+ return t
+ case GranularityDay:
+ // 精确到天,设置为当天开始
+ return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
+ case GranularityMonth:
+ // 精确到月,设置为当月第一天
+ return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location())
+ case GranularityQuarter:
+ // 精确到季度,设置为季度第一天
+ quarter := (t.Month()-1)/3 + 1
+ startMonth := time.Month((int(quarter)-1)*3 + 1)
+ return time.Date(t.Year(), startMonth, 1, 0, 0, 0, 0, t.Location())
+ case GranularityYear:
+ // 精确到年,设置为当年第一天
+ return time.Date(t.Year(), 1, 1, 0, 0, 0, 0, t.Location())
+ default:
+ // 未知粒度,默认为当天开始
+ return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
+ }
+}
+
+// adjustEndTime 根据时间粒度调整结束时间
+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())
+ case GranularityDay:
+ // 精确到天,设置为当天结束
+ return time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 999999999, t.Location())
+ case GranularityMonth:
+ // 精确到月,设置为当月最后一天
+ return time.Date(t.Year(), t.Month()+1, 0, 23, 59, 59, 999999999, t.Location())
+ case GranularityQuarter:
+ // 精确到季度,设置为季度最后一天
+ quarter := (t.Month()-1)/3 + 1
+ startMonth := time.Month((int(quarter)-1)*3 + 1)
+ endMonth := startMonth + 2
+ return time.Date(t.Year(), endMonth+1, 0, 23, 59, 59, 999999999, t.Location())
+ case GranularityYear:
+ // 精确到年,设置为当年最后一天
+ return time.Date(t.Year(), 12, 31, 23, 59, 59, 999999999, t.Location())
+ default:
+ // 未知粒度,默认为当天结束
+ return time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 999999999, t.Location())
+ }
+}
+
+// isDigitsOnly 检查字符串是否只包含数字
+func isDigitsOnly(s string) bool {
+ for _, c := range s {
+ if c < '0' || c > '9' {
+ return false
+ }
+ }
+ return len(s) > 0
+}
+
+// isValidDate 检查日期是否有效
+func isValidDate(year, month, day int) bool {
+ // 检查月份的天数
+ daysInMonth := 31
+
+ switch month {
+ case 4, 6, 9, 11:
+ daysInMonth = 30
+ case 2:
+ // 闰年判断
+ if (year%4 == 0 && year%100 != 0) || year%400 == 0 {
+ daysInMonth = 29
+ } else {
+ daysInMonth = 28
+ }
+ }
+
+ return day <= daysInMonth
+}
diff --git a/pkg/util/time_test.go b/pkg/util/time_test.go
new file mode 100644
index 0000000..1df70ca
--- /dev/null
+++ b/pkg/util/time_test.go
@@ -0,0 +1,1004 @@
+package util
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestTimeOf(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ wantTime time.Time
+ wantOk bool
+ }{
+ // 空输入
+ {
+ name: "empty string",
+ input: "",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "whitespace only",
+ input: " ",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+
+ // 自然语言时间
+ {
+ name: "now",
+ input: "now",
+ wantOk: true, // 不检查具体时间,因为会随运行时间变化
+ },
+ {
+ name: "today",
+ input: "today",
+ wantOk: true,
+ },
+ {
+ name: "yesterday",
+ input: "yesterday",
+ wantOk: true,
+ },
+ {
+ name: "this-week",
+ input: "this-week",
+ wantOk: true,
+ },
+ {
+ name: "last-week",
+ input: "last-week",
+ wantOk: true,
+ },
+ {
+ name: "this-month",
+ input: "this-month",
+ wantOk: true,
+ },
+ {
+ name: "last-month",
+ input: "last-month",
+ wantOk: true,
+ },
+ {
+ name: "this-year",
+ input: "this-year",
+ wantOk: true,
+ },
+ {
+ name: "last-year",
+ input: "last-year",
+ wantOk: true,
+ },
+ {
+ name: "all",
+ input: "all",
+ wantOk: true,
+ },
+
+ // 相对时间
+ {
+ name: "1h-ago",
+ input: "1h-ago",
+ wantOk: true,
+ },
+ {
+ name: "24h-ago",
+ input: "24h-ago",
+ wantOk: true,
+ },
+ {
+ name: "1d-ago",
+ input: "1d-ago",
+ wantOk: true,
+ },
+ {
+ name: "7d-ago",
+ input: "7d-ago",
+ wantOk: true,
+ },
+ {
+ name: "1w-ago",
+ input: "1w-ago",
+ wantOk: true,
+ },
+ {
+ name: "1m-ago",
+ input: "1m-ago",
+ wantOk: true,
+ },
+ {
+ name: "1y-ago",
+ input: "1y-ago",
+ wantOk: true,
+ },
+ {
+ name: "invalid-ago",
+ input: "invalid-ago",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "0d-ago",
+ input: "0d-ago",
+ wantOk: true, // 应该是今天
+ },
+ {
+ name: "-1d-ago",
+ input: "-1d-ago",
+ wantTime: time.Time{},
+ wantOk: false, // 负数应该无效
+ },
+
+ // 季度
+ {
+ name: "2020Q1",
+ input: "2020Q1",
+ wantTime: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "2020Q2",
+ input: "2020Q2",
+ wantTime: time.Date(2020, 4, 1, 0, 0, 0, 0, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "2020Q3",
+ input: "2020Q3",
+ wantTime: time.Date(2020, 7, 1, 0, 0, 0, 0, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "2020Q4",
+ input: "2020Q4",
+ wantTime: time.Date(2020, 10, 1, 0, 0, 0, 0, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "2020Q5", // 无效季度
+ input: "2020Q5",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "1969Q1", // 早于1970年
+ input: "1969Q1",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "10000Q1", // 超过9999年
+ input: "10000Q1",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+
+ // 年份
+ {
+ name: "2020",
+ input: "2020",
+ wantTime: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "1970", // 最早有效年份
+ input: "1970",
+ wantTime: time.Date(1970, 1, 1, 0, 0, 0, 0, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "9999", // 最晚有效年份
+ input: "9999",
+ wantTime: time.Date(9999, 1, 1, 0, 0, 0, 0, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "1969", // 早于1970年
+ input: "1969",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "10000", // 超过9999年
+ input: "10000",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "202", // 不是4位数字
+ input: "202",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+
+ // 月份
+ {
+ name: "202001",
+ input: "202001",
+ wantTime: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "202012",
+ input: "202012",
+ wantTime: time.Date(2020, 12, 1, 0, 0, 0, 0, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "2020-01",
+ input: "2020-01",
+ wantTime: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "2020-12",
+ input: "2020-12",
+ wantTime: time.Date(2020, 12, 1, 0, 0, 0, 0, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "202013", // 无效月份
+ input: "202013",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "2020-13", // 无效月份
+ input: "2020-13",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "2020-00", // 无效月份
+ input: "2020-00",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "196912", // 早于1970年
+ input: "196912",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "1969-12", // 早于1970年
+ input: "1969-12",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+
+ // 日期格式
+ {
+ name: "20200101",
+ input: "20200101",
+ wantTime: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "20201231",
+ input: "20201231",
+ wantTime: time.Date(2020, 12, 31, 0, 0, 0, 0, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "2020-01-01",
+ input: "2020-01-01",
+ wantTime: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "2020-12-31",
+ input: "2020-12-31",
+ wantTime: time.Date(2020, 12, 31, 0, 0, 0, 0, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "20200229", // 闰年2月29日
+ input: "20200229",
+ wantTime: time.Date(2020, 2, 29, 0, 0, 0, 0, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "20190229", // 非闰年2月29日
+ input: "20190229",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "20200230", // 无效日期
+ input: "20200230",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "2020-02-30", // 无效日期
+ input: "2020-02-30",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "20200000", // 无效日期
+ input: "20200000",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "20200132", // 无效日期
+ input: "20200132",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "19691231", // 早于1970年
+ input: "19691231",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "1969-12-31", // 早于1970年
+ input: "1969-12-31",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+
+ // 带时间的日期
+ {
+ name: "20200101/12:34",
+ input: "20200101/12:34",
+ wantTime: time.Date(2020, 1, 1, 12, 34, 0, 0, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "2020-01-01/12:34",
+ input: "2020-01-01/12:34",
+ wantTime: time.Date(2020, 1, 1, 12, 34, 0, 0, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "20200101/24:00", // 无效时间
+ input: "20200101/24:00",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "20200101/12:60", // 无效时间
+ input: "20200101/12:60",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "20200101/12:34:56", // 不支持的格式
+ input: "20200101/12:34:56",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "20200101/12-34", // 不支持的格式
+ input: "20200101/12-34",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "19691231/12:34", // 早于1970年
+ input: "19691231/12:34",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+
+ // 完整时间
+ {
+ name: "20200101120000",
+ input: "20200101120000",
+ wantTime: time.Date(2020, 1, 1, 12, 0, 0, 0, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "20201231235959",
+ input: "20201231235959",
+ wantTime: time.Date(2020, 12, 31, 23, 59, 59, 0, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "20200101240000", // 无效时间
+ input: "20200101240000",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "20200101126000", // 无效时间
+ input: "20200101126000",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "20200101120060", // 无效时间
+ input: "20200101120060",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "2020010112000", // 长度不对
+ input: "2020010112000",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "202001011200000", // 长度不对
+ input: "202001011200000",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "19691231235959", // 早于1970年
+ input: "19691231235959",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+
+ // 时间戳(秒)
+ {
+ name: "1577836800", // 2020-01-01 00:00:00
+ input: "1577836800",
+ wantTime: time.Unix(1577836800, 0),
+ wantOk: true,
+ },
+ {
+ name: "1609459199", // 2020-12-31 23:59:59
+ input: "1609459199",
+ wantTime: time.Unix(1609459199, 0),
+ wantOk: true,
+ },
+ {
+ name: "999999999", // 小于1000000000
+ input: "999999999",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "253402300800", // 大于253402300799
+ input: "253402300800",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "abc", // 非数字
+ input: "abc",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+
+ // RFC3339
+ {
+ name: "2020-01-01T12:00:00Z",
+ input: "2020-01-01T12:00:00Z",
+ wantTime: time.Date(2020, 1, 1, 12, 0, 0, 0, time.UTC),
+ wantOk: true,
+ },
+ {
+ name: "2020-01-01T12:00:00+08:00",
+ input: "2020-01-01T12:00:00+08:00",
+ wantTime: time.Date(2020, 1, 1, 12, 0, 0, 0, time.FixedZone("", 8*60*60)),
+ wantOk: true,
+ },
+ {
+ name: "2020-01-01T12:00Z",
+ input: "2020-01-01T12:00Z",
+ wantTime: time.Date(2020, 1, 1, 12, 0, 0, 0, time.UTC),
+ wantOk: true,
+ },
+ {
+ name: "2020-01-01T12:00:00", // 缺少时区
+ input: "2020-01-01T12:00:00",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "2020-01-01 12:00:00Z", // 格式不对
+ input: "2020-01-01 12:00:00Z",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+
+ // 边界情况和特殊情况
+ {
+ name: "99999", // 不是有效的时间戳
+ input: "99999",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "2020/01/01", // 不支持的格式
+ input: "2020/01/01",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "01/01/2020", // 不支持的格式
+ input: "01/01/2020",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "2020-1-1", // 不支持的格式
+ input: "2020-1-1",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "20201-01-01", // 不支持的格式
+ input: "20201-01-01",
+ wantTime: time.Time{},
+ wantOk: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotTime, gotOk := TimeOf(tt.input)
+
+ if tt.wantOk != gotOk {
+ t.Errorf("TimeOf() ok = %v, want %v", gotOk, tt.wantOk)
+ return
+ }
+
+ if !tt.wantOk {
+ return // 不需要检查时间值
+ }
+
+ if tt.input == "now" || strings.HasSuffix(tt.input, "-ago") ||
+ tt.input == "today" || tt.input == "yesterday" ||
+ tt.input == "this-week" || tt.input == "last-week" ||
+ tt.input == "this-month" || tt.input == "last-month" ||
+ tt.input == "this-year" || tt.input == "last-year" {
+ // 对于相对时间,不检查具体值
+ return
+ }
+
+ if !tt.wantTime.Equal(gotTime) {
+ t.Errorf("TimeOf() = %v, want %v", gotTime, tt.wantTime)
+ }
+ })
+ }
+}
+
+func TestTimeRangeOf(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ wantStart time.Time
+ wantEnd time.Time
+ wantOk bool
+ }{
+ // 空输入
+ {
+ name: "empty string",
+ input: "",
+ wantStart: time.Time{},
+ wantEnd: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "whitespace only",
+ input: " ",
+ wantStart: time.Time{},
+ wantEnd: time.Time{},
+ wantOk: false,
+ },
+
+ // all 特殊情况
+ {
+ name: "all",
+ input: "all",
+ wantStart: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC),
+ wantEnd: time.Date(9999, 12, 31, 23, 59, 59, 999999999, time.UTC),
+ wantOk: true,
+ },
+ {
+ name: "ALL (uppercase)",
+ input: "ALL",
+ wantStart: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC),
+ wantEnd: time.Date(9999, 12, 31, 23, 59, 59, 999999999, time.UTC),
+ wantOk: true,
+ },
+
+ // 相对时间范围
+ {
+ name: "last-1d",
+ input: "last-1d",
+ wantOk: true, // 不检查具体时间,因为会随运行时间变化
+ },
+ {
+ name: "last-7d",
+ input: "last-7d",
+ wantOk: true,
+ },
+ {
+ name: "last-30d",
+ input: "last-30d",
+ wantOk: true,
+ },
+ {
+ name: "last-1w",
+ input: "last-1w",
+ wantOk: true,
+ },
+ {
+ name: "last-4w",
+ input: "last-4w",
+ wantOk: true,
+ },
+ {
+ name: "last-1m",
+ input: "last-1m",
+ wantOk: true,
+ },
+ {
+ name: "last-3m",
+ input: "last-3m",
+ wantOk: true,
+ },
+ {
+ name: "last-1y",
+ input: "last-1y",
+ wantOk: true,
+ },
+ {
+ name: "last-0d", // 无效输入
+ input: "last-0d",
+ wantStart: time.Time{},
+ wantEnd: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "last--1d", // 无效输入
+ input: "last--1d",
+ wantStart: time.Time{},
+ wantEnd: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "last-1x", // 无效输入
+ input: "last-1x",
+ wantStart: time.Time{},
+ wantEnd: time.Time{},
+ wantOk: false,
+ },
+
+ // 时间区间
+ {
+ name: "2020-01-01~2020-01-31",
+ input: "2020-01-01~2020-01-31",
+ wantStart: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local),
+ wantEnd: time.Date(2020, 1, 31, 23, 59, 59, 999999999, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "2020-01-01,2020-01-31",
+ input: "2020-01-01,2020-01-31",
+ wantStart: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local),
+ wantEnd: time.Date(2020, 1, 31, 23, 59, 59, 999999999, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "2020-01-01 to 2020-01-31",
+ input: "2020-01-01 to 2020-01-31",
+ wantStart: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local),
+ wantEnd: time.Date(2020, 1, 31, 23, 59, 59, 999999999, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "20200101~20200131",
+ input: "20200101~20200131",
+ wantStart: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local),
+ wantEnd: time.Date(2020, 1, 31, 23, 59, 59, 999999999, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "2020-01-31~2020-01-01", // 开始时间晚于结束时间
+ input: "2020-01-31~2020-01-01",
+ wantStart: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local),
+ wantEnd: time.Date(2020, 1, 31, 23, 59, 59, 999999999, time.Local),
+ wantOk: true, // 应该自动交换
+ },
+ {
+ name: "2020-01-01~invalid", // 结束时间无效
+ input: "2020-01-01~invalid",
+ wantStart: time.Time{},
+ wantEnd: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "invalid~2020-01-31", // 开始时间无效
+ input: "invalid~2020-01-31",
+ wantStart: time.Time{},
+ wantEnd: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "2020-01-01~2020-02-30", // 结束时间无效日期
+ input: "2020-01-01~2020-02-30",
+ wantStart: time.Time{},
+ wantEnd: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "2020-01-01~", // 缺少结束时间
+ input: "2020-01-01~",
+ wantStart: time.Time{},
+ wantEnd: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "~2020-01-31", // 缺少开始时间
+ input: "~2020-01-31",
+ wantStart: time.Time{},
+ wantEnd: time.Time{},
+ wantOk: false,
+ },
+
+ // 单个时间点,根据粒度确定范围
+ {
+ name: "2020-01-01", // 精确到天
+ input: "2020-01-01",
+ wantStart: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local),
+ wantEnd: time.Date(2020, 1, 1, 23, 59, 59, 999999999, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "20200101", // 精确到天
+ input: "20200101",
+ wantStart: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local),
+ wantEnd: time.Date(2020, 1, 1, 23, 59, 59, 999999999, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "2020-01", // 精确到月
+ input: "2020-01",
+ wantStart: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local),
+ wantEnd: time.Date(2020, 1, 31, 23, 59, 59, 999999999, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "202001", // 精确到月
+ input: "202001",
+ wantStart: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local),
+ wantEnd: time.Date(2020, 1, 31, 23, 59, 59, 999999999, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "2020Q1", // 精确到季度
+ input: "2020Q1",
+ wantStart: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local),
+ wantEnd: time.Date(2020, 3, 31, 23, 59, 59, 999999999, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "2020", // 精确到年
+ input: "2020",
+ wantStart: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local),
+ wantEnd: time.Date(2020, 12, 31, 23, 59, 59, 999999999, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "2020-01-01/12:34", // 精确到分钟
+ input: "2020-01-01/12:34",
+ wantStart: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local),
+ wantEnd: time.Date(2020, 1, 1, 23, 59, 59, 999999999, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "20200101120000", // 精确到秒
+ input: "20200101120000",
+ wantStart: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local),
+ wantEnd: time.Date(2020, 1, 1, 23, 59, 59, 999999999, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "1577836800", // 时间戳 2020-01-01 00:00:00
+ input: "1577836800",
+ wantStart: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local),
+ wantEnd: time.Date(2020, 1, 1, 23, 59, 59, 999999999, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "2020-01-01T12:00:00Z", // RFC3339
+ input: "2020-01-01T12:00:00Z",
+ wantStart: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC),
+ wantEnd: time.Date(2020, 1, 1, 23, 59, 59, 999999999, time.UTC),
+ wantOk: true,
+ },
+
+ // 自然语言时间
+ {
+ name: "today",
+ input: "today",
+ wantOk: true,
+ },
+ {
+ name: "yesterday",
+ input: "yesterday",
+ wantOk: true,
+ },
+ {
+ name: "this-week",
+ input: "this-week",
+ wantOk: true,
+ },
+ {
+ name: "last-week",
+ input: "last-week",
+ wantOk: true,
+ },
+ {
+ name: "this-month",
+ input: "this-month",
+ wantOk: true,
+ },
+ {
+ name: "last-month",
+ input: "last-month",
+ wantOk: true,
+ },
+ {
+ name: "this-year",
+ input: "this-year",
+ wantOk: true,
+ },
+ {
+ name: "last-year",
+ input: "last-year",
+ wantOk: true,
+ },
+
+ // 边界情况和特殊情况
+ {
+ name: "invalid", // 无效输入
+ input: "invalid",
+ wantStart: time.Time{},
+ wantEnd: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "2020-02-29", // 闰年2月29日
+ input: "2020-02-29",
+ wantStart: time.Date(2020, 2, 29, 0, 0, 0, 0, time.Local),
+ wantEnd: time.Date(2020, 2, 29, 23, 59, 59, 999999999, time.Local),
+ wantOk: true,
+ },
+ {
+ name: "2019-02-29", // 非闰年2月29日
+ input: "2019-02-29",
+ wantStart: time.Time{},
+ wantEnd: time.Time{},
+ wantOk: false,
+ },
+ {
+ name: "2020-04-31", // 无效日期
+ input: "2020-04-31",
+ wantStart: time.Time{},
+ wantEnd: time.Time{},
+ wantOk: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotStart, gotEnd, gotOk := TimeRangeOf(tt.input)
+
+ if tt.wantOk != gotOk {
+ t.Errorf("TimeRangeOf() ok = %v, want %v", gotOk, tt.wantOk)
+ return
+ }
+
+ if !tt.wantOk {
+ return // 不需要检查时间值
+ }
+
+ if tt.input == "today" || tt.input == "yesterday" ||
+ tt.input == "this-week" || tt.input == "last-week" ||
+ tt.input == "this-month" || tt.input == "last-month" ||
+ tt.input == "this-year" || tt.input == "last-year" ||
+ strings.HasPrefix(tt.input, "last-") {
+ // 对于相对时间,不检查具体值
+ return
+ }
+
+ if !tt.wantStart.Equal(gotStart) {
+ t.Errorf("TimeRangeOf() start = %v, want %v", gotStart, tt.wantStart)
+ }
+
+ if !tt.wantEnd.Equal(gotEnd) {
+ t.Errorf("TimeRangeOf() end = %v, want %v", gotEnd, tt.wantEnd)
+ }
+ })
+ }
+}
+
+// 测试边界情况
+func TestTimeOfEdgeCases(t *testing.T) {
+ // 测试非常长的数字字符串
+ longDigits := "99999999999999999999999999999999999999"
+ _, ok := TimeOf(longDigits)
+ if ok {
+ t.Errorf("TimeOf(%s) should return false for very long digit string", longDigits)
+ }
+
+ // 测试非常长的字符串
+ longString := strings.Repeat("a", 10000)
+ _, ok = TimeOf(longString)
+ if ok {
+ t.Errorf("TimeOf(%s) should return false for very long string", "very_long_string")
+ }
+
+ // 测试特殊字符
+ specialChars := "!@#$%^&*()"
+ _, ok = TimeOf(specialChars)
+ if ok {
+ t.Errorf("TimeOf(%s) should return false for special characters", specialChars)
+ }
+
+ // 测试SQL注入类字符串
+ sqlInjection := "2020-01-01' OR '1'='1"
+ _, ok = TimeOf(sqlInjection)
+ if ok {
+ t.Errorf("TimeOf(%s) should return false for SQL injection attempt", sqlInjection)
+ }
+}
+
+// 测试时区处理
+func TestTimeOfTimezones(t *testing.T) {
+ // RFC3339格式的时区处理
+ utcTime, ok := TimeOf("2020-01-01T12:00:00Z")
+ if !ok {
+ t.Fatalf("TimeOf(2020-01-01T12:00:00Z) failed")
+ }
+
+ estTime, ok := TimeOf("2020-01-01T12:00:00-05:00")
+ if !ok {
+ t.Fatalf("TimeOf(2020-01-01T12:00:00-05:00) failed")
+ }
+
+ // UTC比EST时区快5小时,所以相同时钟时间的UTC应该比EST早5小时
+ // 转换为UTC后比较
+ utcInUTC := utcTime.UTC()
+ estInUTC := estTime.UTC()
+
+ // EST时区是-5小时,所以相同时钟时间的EST转为UTC后应该比UTC的时钟时间多5小时
+ hourDiff := estInUTC.Hour() - utcInUTC.Hour()
+ if hourDiff != 5 {
+ t.Errorf("Expected 5 hour difference between UTC and EST, got %v", hourDiff)
+ }
+}
+
+// 测试闰年处理
+func TestLeapYearHandling(t *testing.T) {
+ // 测试闰年2月29日
+ leapDay, ok := TimeOf("20200229")
+ if !ok {
+ t.Fatalf("TimeOf(20200229) failed for leap year")
+ }
+ if leapDay.Day() != 29 || leapDay.Month() != 2 || leapDay.Year() != 2020 {
+ t.Errorf("Expected 2020-02-29, got %v", leapDay)
+ }
+
+ // 测试非闰年2月29日
+ _, ok = TimeOf("20190229")
+ if ok {
+ t.Errorf("TimeOf(20190229) should fail for non-leap year")
+ }
+
+ // 测试世纪闰年规则 (2000是闰年,2100不是)
+ _, ok = TimeOf("20000229")
+ if !ok {
+ t.Errorf("TimeOf(20000229) should succeed for century leap year")
+ }
+
+ _, ok = TimeOf("21000229")
+ if ok {
+ t.Errorf("TimeOf(21000229) should fail for non-leap century year")
+ }
+}
diff --git a/pkg/version/version.go b/pkg/version/version.go
new file mode 100644
index 0000000..cf026ee
--- /dev/null
+++ b/pkg/version/version.go
@@ -0,0 +1,32 @@
+package version
+
+import (
+ "fmt"
+ "runtime"
+ "runtime/debug"
+ "strings"
+)
+
+var (
+ Version = "(dev)"
+ buildInfo = debug.BuildInfo{}
+)
+
+func init() {
+ if bi, ok := debug.ReadBuildInfo(); ok {
+ buildInfo = *bi
+ if len(bi.Main.Version) > 0 {
+ Version = bi.Main.Version
+ }
+ }
+}
+
+func GetMore(mod bool) string {
+ if mod {
+ mod := buildInfo.String()
+ if len(mod) > 0 {
+ return fmt.Sprintf("\t%s\n", strings.ReplaceAll(mod[:len(mod)-1], "\n", "\n\t"))
+ }
+ }
+ return fmt.Sprintf("version %s %s %s/%s\n", Version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
+}
diff --git a/script/package.sh b/script/package.sh
new file mode 100755
index 0000000..0df4b9b
--- /dev/null
+++ b/script/package.sh
@@ -0,0 +1,60 @@
+#!/bin/bash
+
+set -eu
+set -o pipefail
+[ "$#" = "1" ] && [ "$1" = '-v' ] && set -x
+
+OUTPUT_DIR="bin"
+PACKAGES_DIR="packages"
+TEMP_DIR="temp_package"
+VERSION=$(git describe --tags --always --dirty="-dev")
+CHECKSUMS_FILE="$PACKAGES_DIR/checksums.txt"
+
+make -f Makefile crossbuild
+
+rm -rf $PACKAGES_DIR $TEMP_DIR
+
+mkdir -p $PACKAGES_DIR $TEMP_DIR
+
+echo "" > $CHECKSUMS_FILE
+
+for binary in $OUTPUT_DIR/chatlog_*_*; do
+ binary_name=$(basename $binary)
+
+ # quick start
+ if [[ $binary_name == "chatlog_darwin_amd64" ]]; then
+ cp "$binary" "$PACKAGES_DIR/chatlog_macos"
+ echo "$(sha256sum $PACKAGES_DIR/chatlog_macos | sed "s|$PACKAGES_DIR/||")" >> $CHECKSUMS_FILE
+ elif [[ $binary_name == "chatlog_windows_amd64" ]]; then
+ cp "$binary" "$PACKAGES_DIR/chatlog_windows.exe"
+ echo "$(sha256sum $PACKAGES_DIR/chatlog_windows.exe | sed "s|$PACKAGES_DIR/||")" >> $CHECKSUMS_FILE
+ elif [[ $binary_name == "chatlog_linux_amd64" ]]; then
+ cp "$binary" "$PACKAGES_DIR/chatlog_linux"
+ echo "$(sha256sum $PACKAGES_DIR/chatlog_linux | sed "s|$PACKAGES_DIR/||")" >> $CHECKSUMS_FILE
+ fi
+
+ cp "README.md" "LICENSE" $TEMP_DIR
+
+ package_name=""
+ os_arch=$(echo $binary_name | cut -d'_' -f 2-)
+ if [[ $binary_name == *"_windows_"* ]]; then
+ cp "$binary" "$TEMP_DIR/chatlog.exe"
+ package_name="chatlog_${VERSION}_${os_arch}.zip"
+ zip -j "$PACKAGES_DIR/$package_name" -r $TEMP_DIR/*
+ else
+ cp "$binary" "$TEMP_DIR/chatlog"
+ package_name="chatlog_${VERSION}_${os_arch}.tar.gz"
+ tar -czf "$PACKAGES_DIR/$package_name" -C $TEMP_DIR .
+ fi
+
+ rm -rf $TEMP_DIR/*
+
+ if [[ ! -z "$package_name" ]]; then
+ echo "$(sha256sum $PACKAGES_DIR/$package_name | sed "s|$PACKAGES_DIR/||")" >> $CHECKSUMS_FILE
+ fi
+
+done
+
+rm -rf $TEMP_DIR
+
+echo "📦 All packages and their sha256 checksums have been created in $PACKAGES_DIR/"
\ No newline at end of file