From 78cce92ce3d541ee93ed195261b2b7add96ed622 Mon Sep 17 00:00:00 2001 From: Shen Junzheng Date: Wed, 12 Mar 2025 01:19:35 +0800 Subject: [PATCH] x --- .github/workflows/release.yml | 51 ++ .gitignore | 6 + Makefile | 54 ++ README.md | 141 +++- cmd/chatlog/cmd_decrypt.go | 40 + cmd/chatlog/cmd_key.go | 34 + cmd/chatlog/cmd_version.go | 27 + cmd/chatlog/log.go | 50 ++ cmd/chatlog/root.go | 48 ++ go.mod | 72 ++ go.sum | 245 ++++++ internal/chatlog/app.go | 409 ++++++++++ internal/chatlog/conf/config.go | 60 ++ internal/chatlog/conf/service.go | 68 ++ internal/chatlog/ctx/context.go | 158 ++++ internal/chatlog/database/service.go | 77 ++ internal/chatlog/http/route.go | 245 ++++++ internal/chatlog/http/service.go | 90 +++ internal/chatlog/http/static/index.htm | 156 ++++ internal/chatlog/manager.go | 202 +++++ internal/chatlog/mcp/const.go | 96 +++ internal/chatlog/mcp/service.go | 357 +++++++++ internal/chatlog/wechat/service.go | 113 +++ internal/errors/errors.go | 85 ++ internal/mcp/error.go | 55 ++ internal/mcp/initialize.go | 78 ++ internal/mcp/jsonrpc.go | 62 ++ internal/mcp/mcp.go | 107 +++ internal/mcp/prompt.go | 137 ++++ internal/mcp/resource.go | 74 ++ internal/mcp/session.go | 48 ++ internal/mcp/sse.go | 160 ++++ internal/mcp/stdio.go | 1 + internal/mcp/tool.go | 142 ++++ internal/ui/footer/footer.go | 68 ++ internal/ui/help/help.go | 87 ++ internal/ui/infobar/infobar.go | 182 +++++ internal/ui/menu/menu.go | 162 ++++ internal/ui/menu/submenu.go | 232 ++++++ internal/ui/style/style.go | 78 ++ internal/ui/style/style_windows.go | 81 ++ internal/wechat/decrypt.go | 415 ++++++++++ internal/wechat/info.go | 50 ++ internal/wechat/info_others.go | 16 + internal/wechat/info_windows.go | 507 ++++++++++++ internal/wechat/manager.go | 57 ++ internal/wechatdb/contact.go | 269 +++++++ internal/wechatdb/message.go | 321 ++++++++ internal/wechatdb/wechatdb.go | 117 +++ main.go | 12 + pkg/config/config.go | 148 ++++ pkg/config/default.go | 251 ++++++ pkg/dllver/version.go | 25 + pkg/dllver/version_others.go | 7 + pkg/dllver/version_windows.go | 142 ++++ pkg/model/chatroom.go | 142 ++++ pkg/model/contact.go | 158 ++++ pkg/model/message.go | 301 +++++++ pkg/model/session.go | 133 ++++ pkg/model/wxproto/bytesextra.pb.go | 254 ++++++ pkg/model/wxproto/bytesextra.proto | 18 + pkg/model/wxproto/roomdata.pb.go | 222 ++++++ pkg/model/wxproto/roomdata.proto | 16 + pkg/util/os.go | 135 ++++ pkg/util/os_windows.go | 15 + pkg/util/strings.go | 34 + pkg/util/time.go | 636 +++++++++++++++ pkg/util/time_test.go | 1004 ++++++++++++++++++++++++ pkg/version/version.go | 32 + script/package.sh | 60 ++ 70 files changed, 10134 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release.yml create mode 100644 Makefile create mode 100644 cmd/chatlog/cmd_decrypt.go create mode 100644 cmd/chatlog/cmd_key.go create mode 100644 cmd/chatlog/cmd_version.go create mode 100644 cmd/chatlog/log.go create mode 100644 cmd/chatlog/root.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/chatlog/app.go create mode 100644 internal/chatlog/conf/config.go create mode 100644 internal/chatlog/conf/service.go create mode 100644 internal/chatlog/ctx/context.go create mode 100644 internal/chatlog/database/service.go create mode 100644 internal/chatlog/http/route.go create mode 100644 internal/chatlog/http/service.go create mode 100644 internal/chatlog/http/static/index.htm create mode 100644 internal/chatlog/manager.go create mode 100644 internal/chatlog/mcp/const.go create mode 100644 internal/chatlog/mcp/service.go create mode 100644 internal/chatlog/wechat/service.go create mode 100644 internal/errors/errors.go create mode 100644 internal/mcp/error.go create mode 100644 internal/mcp/initialize.go create mode 100644 internal/mcp/jsonrpc.go create mode 100644 internal/mcp/mcp.go create mode 100644 internal/mcp/prompt.go create mode 100644 internal/mcp/resource.go create mode 100644 internal/mcp/session.go create mode 100644 internal/mcp/sse.go create mode 100644 internal/mcp/stdio.go create mode 100644 internal/mcp/tool.go create mode 100644 internal/ui/footer/footer.go create mode 100644 internal/ui/help/help.go create mode 100644 internal/ui/infobar/infobar.go create mode 100644 internal/ui/menu/menu.go create mode 100644 internal/ui/menu/submenu.go create mode 100644 internal/ui/style/style.go create mode 100644 internal/ui/style/style_windows.go create mode 100644 internal/wechat/decrypt.go create mode 100644 internal/wechat/info.go create mode 100644 internal/wechat/info_others.go create mode 100644 internal/wechat/info_windows.go create mode 100644 internal/wechat/manager.go create mode 100644 internal/wechatdb/contact.go create mode 100644 internal/wechatdb/message.go create mode 100644 internal/wechatdb/wechatdb.go create mode 100644 main.go create mode 100644 pkg/config/config.go create mode 100644 pkg/config/default.go create mode 100644 pkg/dllver/version.go create mode 100644 pkg/dllver/version_others.go create mode 100644 pkg/dllver/version_windows.go create mode 100644 pkg/model/chatroom.go create mode 100644 pkg/model/contact.go create mode 100644 pkg/model/message.go create mode 100644 pkg/model/session.go create mode 100644 pkg/model/wxproto/bytesextra.pb.go create mode 100644 pkg/model/wxproto/bytesextra.proto create mode 100644 pkg/model/wxproto/roomdata.pb.go create mode 100644 pkg/model/wxproto/roomdata.proto create mode 100644 pkg/util/os.go create mode 100644 pkg/util/os_windows.go create mode 100644 pkg/util/strings.go create mode 100644 pkg/util/time.go create mode 100644 pkg/util/time_test.go create mode 100644 pkg/version/version.go create mode 100755 script/package.sh 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