x
This commit is contained in:
@@ -2,6 +2,7 @@ package chatlog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/chatlog/ctx"
|
||||
@@ -109,7 +110,7 @@ func (a *App) refresh() {
|
||||
return
|
||||
case <-tick.C:
|
||||
a.infoBar.UpdateAccount(a.ctx.Account)
|
||||
a.infoBar.UpdateBasicInfo(a.ctx.PID, a.ctx.Version, a.ctx.ExePath)
|
||||
a.infoBar.UpdateBasicInfo(a.ctx.PID, a.ctx.FullVersion, a.ctx.ExePath)
|
||||
a.infoBar.UpdateStatus(a.ctx.Status)
|
||||
a.infoBar.UpdateDataKey(a.ctx.DataKey)
|
||||
a.infoBar.UpdateDataUsageDir(a.ctx.DataUsage, a.ctx.DataDir)
|
||||
@@ -159,11 +160,36 @@ func (a *App) initMenu() {
|
||||
Name: "获取数据密钥",
|
||||
Description: "从进程获取数据密钥",
|
||||
Selected: func(i *menu.Item) {
|
||||
if err := a.m.GetDataKey(); err != nil {
|
||||
a.showError(err)
|
||||
return
|
||||
modal := tview.NewModal()
|
||||
if runtime.GOOS == "darwin" {
|
||||
modal.SetText("获取数据密钥中...\n预计需要 20 秒左右的时间,期间微信会卡住,请耐心等待")
|
||||
} else {
|
||||
modal.SetText("获取数据密钥中...")
|
||||
}
|
||||
a.showInfo("获取数据密钥成功")
|
||||
a.mainPages.AddPage("modal", modal, true, true)
|
||||
a.SetFocus(modal)
|
||||
|
||||
go func() {
|
||||
err := a.m.GetDataKey()
|
||||
|
||||
// 在主线程中更新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)
|
||||
})
|
||||
}()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -264,7 +290,7 @@ func (a *App) initMenu() {
|
||||
modal.SetText("已停止 HTTP 服务")
|
||||
// 更改菜单项名称
|
||||
i.Name = "启动 HTTP 服务"
|
||||
i.Description = "启动本地 HTTP 服务器"
|
||||
i.Description = "启动本地 HTTP & MCP 服务器"
|
||||
}
|
||||
|
||||
// 添加确认按钮
|
||||
|
||||
@@ -9,17 +9,18 @@ type Config struct {
|
||||
}
|
||||
|
||||
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 string `mapstructure:"type" json:"type"`
|
||||
Account string `mapstructure:"account" json:"account"`
|
||||
Platform string `mapstructure:"platform" json:"platform"`
|
||||
Version int `mapstructure:"version" json:"version"`
|
||||
FullVersion string `mapstructure:"full_version" json:"full_version"`
|
||||
DataDir string `mapstructure:"data_dir" json:"data_dir"`
|
||||
DataKey string `mapstructure:"data_key" json:"data_key"`
|
||||
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 {
|
||||
|
||||
@@ -17,29 +17,30 @@ type Context struct {
|
||||
History map[string]conf.ProcessConfig
|
||||
|
||||
// 微信账号相关状态
|
||||
Account string
|
||||
Version string
|
||||
MajorVersion int
|
||||
DataKey string
|
||||
DataUsage string
|
||||
DataDir string
|
||||
Account string
|
||||
Platform string
|
||||
Version int
|
||||
FullVersion string
|
||||
DataDir string
|
||||
DataKey string
|
||||
DataUsage string
|
||||
|
||||
// 工作目录相关状态
|
||||
WorkUsage string
|
||||
WorkDir string
|
||||
WorkUsage string
|
||||
|
||||
// HTTP服务相关状态
|
||||
HTTPEnabled bool
|
||||
HTTPAddr string
|
||||
|
||||
// 当前选中的微信实例
|
||||
Current *wechat.Info
|
||||
Current *wechat.Account
|
||||
PID int
|
||||
ExePath string
|
||||
Status string
|
||||
|
||||
// 所有可用的微信实例
|
||||
WeChatInstances []*wechat.Info
|
||||
WeChatInstances []*wechat.Account
|
||||
}
|
||||
|
||||
func New(conf *conf.Service) *Context {
|
||||
@@ -65,8 +66,9 @@ func (c *Context) SwitchHistory(account string) {
|
||||
history, ok := c.History[account]
|
||||
if ok {
|
||||
c.Account = history.Account
|
||||
c.Platform = history.Platform
|
||||
c.Version = history.Version
|
||||
c.MajorVersion = history.MajorVersion
|
||||
c.FullVersion = history.FullVersion
|
||||
c.DataKey = history.DataKey
|
||||
c.DataDir = history.DataDir
|
||||
c.WorkDir = history.WorkDir
|
||||
@@ -75,8 +77,8 @@ func (c *Context) SwitchHistory(account string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Context) SwitchCurrent(info *wechat.Info) {
|
||||
c.SwitchHistory(info.AccountName)
|
||||
func (c *Context) SwitchCurrent(info *wechat.Account) {
|
||||
c.SwitchHistory(info.Name)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.Current = info
|
||||
@@ -85,9 +87,10 @@ func (c *Context) SwitchCurrent(info *wechat.Info) {
|
||||
}
|
||||
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.Account = c.Current.Name
|
||||
c.Platform = c.Current.Platform
|
||||
c.Version = c.Current.Version
|
||||
c.FullVersion = c.Current.FullVersion
|
||||
c.PID = int(c.Current.PID)
|
||||
c.ExePath = c.Current.ExePath
|
||||
c.Status = c.Current.Status
|
||||
@@ -143,15 +146,16 @@ func (c *Context) SetDataDir(dir string) {
|
||||
// 更新配置
|
||||
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,
|
||||
Type: "wechat",
|
||||
Account: c.Account,
|
||||
Platform: c.Platform,
|
||||
Version: c.Version,
|
||||
FullVersion: c.FullVersion,
|
||||
DataDir: c.DataDir,
|
||||
DataKey: c.DataKey,
|
||||
WorkDir: c.WorkDir,
|
||||
HTTPEnabled: c.HTTPEnabled,
|
||||
HTTPAddr: c.HTTPAddr,
|
||||
}
|
||||
conf := c.conf.GetConfig()
|
||||
conf.UpdateHistory(c.Account, pconf)
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/chatlog/ctx"
|
||||
"github.com/sjzar/chatlog/internal/model"
|
||||
"github.com/sjzar/chatlog/internal/wechatdb"
|
||||
"github.com/sjzar/chatlog/pkg/model"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@@ -20,7 +20,7 @@ func NewService(ctx *ctx.Context) *Service {
|
||||
}
|
||||
|
||||
func (s *Service) Start() error {
|
||||
db, err := wechatdb.New(s.ctx.WorkDir, s.ctx.MajorVersion)
|
||||
db, err := wechatdb.New(s.ctx.WorkDir, s.ctx.Platform, s.ctx.Version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -36,42 +36,29 @@ func (s *Service) Stop() error {
|
||||
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)
|
||||
func (s *Service) GetContacts(key string, limit, offset int) (*wechatdb.GetContactsResp, error) {
|
||||
return s.db.GetContacts(key, limit, offset)
|
||||
}
|
||||
|
||||
// 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()
|
||||
func (s *Service) GetChatRooms(key string, limit, offset int) (*wechatdb.GetChatRoomsResp, error) {
|
||||
return s.db.GetChatRooms(key, limit, offset)
|
||||
}
|
||||
|
||||
// GetSession retrieves session information
|
||||
func (s *Service) GetSession(limit int) (*wechatdb.GetSessionResp, error) {
|
||||
return s.db.GetSession(limit)
|
||||
func (s *Service) GetSessions(key string, limit, offset int) (*wechatdb.GetSessionsResp, error) {
|
||||
return s.db.GetSessions(key, limit, offset)
|
||||
}
|
||||
|
||||
// Close closes the database connection
|
||||
func (s *Service) Close() {
|
||||
// Add cleanup code if needed
|
||||
s.db.Close()
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/errors"
|
||||
@@ -19,8 +18,6 @@ import (
|
||||
//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()
|
||||
@@ -43,9 +40,9 @@ func (s *Service) initRouter() {
|
||||
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)
|
||||
api.GET("/contact", s.GetContacts)
|
||||
api.GET("/chatroom", s.GetChatRooms)
|
||||
api.GET("/session", s.GetSessions)
|
||||
}
|
||||
|
||||
router.NoRoute(s.NoRoute)
|
||||
@@ -66,47 +63,39 @@ func (s *Service) NoRoute(c *gin.Context) {
|
||||
|
||||
func (s *Service) GetChatlog(c *gin.Context) {
|
||||
|
||||
q := struct {
|
||||
Time string `form:"time"`
|
||||
Talker string `form:"talker"`
|
||||
Limit int `form:"limit"`
|
||||
Offset int `form:"offset"`
|
||||
Format string `form:"format"`
|
||||
}{}
|
||||
|
||||
if err := c.BindQuery(&q); err != nil {
|
||||
errors.Err(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
start, end, ok := util.TimeRangeOf(c.Query("time"))
|
||||
start, end, ok := util.TimeRangeOf(q.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
|
||||
}
|
||||
if q.Limit < 0 {
|
||||
q.Limit = 0
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if q.Offset < 0 {
|
||||
q.Offset = 0
|
||||
}
|
||||
|
||||
talker := c.Query("talker")
|
||||
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
}
|
||||
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
messages, err := s.db.GetMessages(start, end, talker, limit, offset)
|
||||
messages, err := s.db.GetMessages(start, end, q.Talker, q.Limit, q.Offset)
|
||||
if err != nil {
|
||||
errors.Err(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
switch strings.ToLower(c.Query("format")) {
|
||||
switch strings.ToLower(q.Format) {
|
||||
case "csv":
|
||||
case "json":
|
||||
// json
|
||||
@@ -119,21 +108,34 @@ func (s *Service) GetChatlog(c *gin.Context) {
|
||||
c.Writer.Flush()
|
||||
|
||||
for _, m := range messages {
|
||||
c.Writer.WriteString(m.PlainText(len(talker) == 0))
|
||||
c.Writer.WriteString(m.PlainText(len(q.Talker) == 0))
|
||||
c.Writer.WriteString("\n")
|
||||
c.Writer.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) ListContact(c *gin.Context) {
|
||||
list, err := s.db.ListContact()
|
||||
func (s *Service) GetContacts(c *gin.Context) {
|
||||
|
||||
q := struct {
|
||||
Key string `form:"key"`
|
||||
Limit int `form:"limit"`
|
||||
Offset int `form:"offset"`
|
||||
Format string `form:"format"`
|
||||
}{}
|
||||
|
||||
if err := c.BindQuery(&q); err != nil {
|
||||
errors.Err(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
list, err := s.db.GetContacts(q.Key, q.Limit, q.Offset)
|
||||
if err != nil {
|
||||
errors.Err(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
format := strings.ToLower(c.Query("format"))
|
||||
format := strings.ToLower(q.Format)
|
||||
switch format {
|
||||
case "json":
|
||||
// json
|
||||
@@ -158,22 +160,26 @@ func (s *Service) ListContact(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) ListChatRoom(c *gin.Context) {
|
||||
func (s *Service) GetChatRooms(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
|
||||
}
|
||||
q := struct {
|
||||
Key string `form:"key"`
|
||||
Limit int `form:"limit"`
|
||||
Offset int `form:"offset"`
|
||||
Format string `form:"format"`
|
||||
}{}
|
||||
|
||||
if err := c.BindQuery(&q); err != nil {
|
||||
errors.Err(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
list, err := s.db.ListChatRoom()
|
||||
list, err := s.db.GetChatRooms(q.Key, q.Limit, q.Offset)
|
||||
if err != nil {
|
||||
errors.Err(c, err)
|
||||
return
|
||||
}
|
||||
format := strings.ToLower(c.Query("format"))
|
||||
format := strings.ToLower(q.Format)
|
||||
switch format {
|
||||
case "json":
|
||||
// json
|
||||
@@ -190,32 +196,34 @@ func (s *Service) ListChatRoom(c *gin.Context) {
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.Flush()
|
||||
|
||||
c.Writer.WriteString("Name,Owner,UserCount\n")
|
||||
c.Writer.WriteString("Name,Remark,NickName,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.WriteString(fmt.Sprintf("%s,%s,%s,%s,%d\n", chatRoom.Name, chatRoom.Remark, chatRoom.NickName, chatRoom.Owner, len(chatRoom.Users)))
|
||||
}
|
||||
c.Writer.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) GetSession(c *gin.Context) {
|
||||
func (s *Service) GetSessions(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
|
||||
}
|
||||
q := struct {
|
||||
Key string `form:"key"`
|
||||
Limit int `form:"limit"`
|
||||
Offset int `form:"offset"`
|
||||
Format string `form:"format"`
|
||||
}{}
|
||||
|
||||
if err := c.BindQuery(&q); err != nil {
|
||||
errors.Err(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
sessions, err := s.db.GetSession(limit)
|
||||
sessions, err := s.db.GetSessions(q.Key, q.Limit, q.Offset)
|
||||
if err != nil {
|
||||
errors.Err(c, err)
|
||||
return
|
||||
}
|
||||
format := strings.ToLower(c.Query("format"))
|
||||
format := strings.ToLower(q.Format)
|
||||
switch format {
|
||||
case "csv":
|
||||
c.Writer.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
|
||||
@@ -14,6 +14,10 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
DefalutHTTPAddr = "127.0.0.1:5030"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
ctx *ctx.Context
|
||||
db *database.Service
|
||||
@@ -34,7 +38,8 @@ func NewService(ctx *ctx.Context, db *database.Service, mcp *mcp.Service) *Servi
|
||||
|
||||
// Middleware
|
||||
router.Use(
|
||||
gin.Recovery(),
|
||||
errors.RecoveryMiddleware(),
|
||||
errors.ErrorHandlerMiddleware(),
|
||||
gin.LoggerWithWriter(log.StandardLogger().Out),
|
||||
)
|
||||
|
||||
@@ -50,6 +55,11 @@ func NewService(ctx *ctx.Context, db *database.Service, mcp *mcp.Service) *Servi
|
||||
}
|
||||
|
||||
func (s *Service) Start() error {
|
||||
|
||||
if s.ctx.HTTPAddr == "" {
|
||||
s.ctx.HTTPAddr = DefalutHTTPAddr
|
||||
}
|
||||
|
||||
s.server = &http.Server{
|
||||
Addr: s.ctx.HTTPAddr,
|
||||
Handler: s.router,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package chatlog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
@@ -67,7 +68,7 @@ func (m *Manager) Run() error {
|
||||
if m.ctx.HTTPEnabled {
|
||||
// 启动HTTP服务
|
||||
if err := m.StartService(); err != nil {
|
||||
return err
|
||||
m.StopService()
|
||||
}
|
||||
}
|
||||
// 启动终端UI
|
||||
@@ -152,7 +153,7 @@ func (m *Manager) DecryptDBFiles() error {
|
||||
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 {
|
||||
if err := m.wechat.DecryptDBFiles(m.ctx.DataDir, m.ctx.WorkDir, m.ctx.DataKey, m.ctx.Platform, m.ctx.Version); err != nil {
|
||||
return err
|
||||
}
|
||||
m.ctx.Refresh()
|
||||
@@ -166,24 +167,24 @@ func (m *Manager) CommandKey(pid int) (string, error) {
|
||||
return "", fmt.Errorf("wechat process not found")
|
||||
}
|
||||
if len(instances) == 1 {
|
||||
return instances[0].GetKey()
|
||||
return instances[0].GetKey(context.Background())
|
||||
}
|
||||
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)
|
||||
str += fmt.Sprintf("PID: %d. %s[Version: %s Data Dir: %s ]\n", ins.PID, ins.Name, ins.FullVersion, ins.DataDir)
|
||||
}
|
||||
return str, nil
|
||||
}
|
||||
for _, ins := range instances {
|
||||
if ins.PID == uint32(pid) {
|
||||
return ins.GetKey()
|
||||
return ins.GetKey(context.Background())
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("wechat process not found")
|
||||
}
|
||||
|
||||
func (m *Manager) CommandDecrypt(dataDir string, workDir string, key string, version int) error {
|
||||
func (m *Manager) CommandDecrypt(dataDir string, workDir string, key string, platform string, version int) error {
|
||||
if dataDir == "" {
|
||||
return fmt.Errorf("dataDir is required")
|
||||
}
|
||||
@@ -194,7 +195,7 @@ func (m *Manager) CommandDecrypt(dataDir string, workDir string, key string, ver
|
||||
workDir = util.DefaultWorkDir(filepath.Base(filepath.Dir(dataDir)))
|
||||
}
|
||||
|
||||
if err := m.wechat.DecryptDBFiles(dataDir, workDir, key, version); err != nil {
|
||||
if err := m.wechat.DecryptDBFiles(dataDir, workDir, key, platform, version); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -134,53 +134,39 @@ func (s *Service) toolsCall(session *mcp.Session, req *mcp.Request) error {
|
||||
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)
|
||||
limit := util.MustAnyToInt(callReq.Arguments["limit"])
|
||||
offset := util.MustAnyToInt(callReq.Arguments["offset"])
|
||||
list, err := s.db.GetContacts(query, limit, offset)
|
||||
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))
|
||||
}
|
||||
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)
|
||||
limit := util.MustAnyToInt(callReq.Arguments["limit"])
|
||||
offset := util.MustAnyToInt(callReq.Arguments["offset"])
|
||||
list, err := s.db.GetChatRooms(query, limit, offset)
|
||||
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)))
|
||||
}
|
||||
case "query_recent_chat":
|
||||
data, err := s.db.GetSession(0)
|
||||
query := ""
|
||||
if v, ok := callReq.Arguments["query"]; ok {
|
||||
query = v.(string)
|
||||
}
|
||||
limit := util.MustAnyToInt(callReq.Arguments["limit"])
|
||||
offset := util.MustAnyToInt(callReq.Arguments["offset"])
|
||||
data, err := s.db.GetSessions(query, limit, offset)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法获取会话列表: %v", err)
|
||||
}
|
||||
@@ -245,49 +231,26 @@ func (s *Service) resourcesRead(session *mcp.Session, req *mcp.Request) error {
|
||||
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)
|
||||
|
||||
list, err := s.db.GetContacts(u.Host, 0, 0)
|
||||
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))
|
||||
}
|
||||
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)
|
||||
list, err := s.db.GetChatRooms(u.Host, 0, 0)
|
||||
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)))
|
||||
}
|
||||
case "session":
|
||||
data, err := s.db.GetSession(0)
|
||||
data, err := s.db.GetSessions("", 0, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法获取会话列表: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/chatlog/ctx"
|
||||
"github.com/sjzar/chatlog/internal/errors"
|
||||
"github.com/sjzar/chatlog/internal/wechat"
|
||||
"github.com/sjzar/chatlog/internal/wechat/decrypt"
|
||||
"github.com/sjzar/chatlog/pkg/util"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@@ -22,18 +27,18 @@ func NewService(ctx *ctx.Context) *Service {
|
||||
}
|
||||
|
||||
// GetWeChatInstances returns all running WeChat instances
|
||||
func (s *Service) GetWeChatInstances() []*wechat.Info {
|
||||
func (s *Service) GetWeChatInstances() []*wechat.Account {
|
||||
wechat.Load()
|
||||
return wechat.Items
|
||||
return wechat.GetAccounts()
|
||||
}
|
||||
|
||||
// GetDataKey extracts the encryption key from a WeChat process
|
||||
func (s *Service) GetDataKey(info *wechat.Info) (string, error) {
|
||||
func (s *Service) GetDataKey(info *wechat.Account) (string, error) {
|
||||
if info == nil {
|
||||
return "", fmt.Errorf("no WeChat instance selected")
|
||||
}
|
||||
|
||||
key, err := info.GetKey()
|
||||
key, err := info.GetKey(context.Background())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -89,23 +94,42 @@ func (s *Service) FindDBFiles(rootDir string, recursive bool) ([]string, error)
|
||||
return dbFiles, nil
|
||||
}
|
||||
|
||||
func (s *Service) DecryptDBFiles(dataDir string, workDir string, key string, version int) error {
|
||||
func (s *Service) DecryptDBFiles(dataDir string, workDir string, key string, platform string, version int) error {
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
dbfiles, err := s.FindDBFiles(dataDir, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
decryptor, err := decrypt.NewDecryptor(platform, version)
|
||||
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 {
|
||||
|
||||
outputFile, err := os.Create(output)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %v", err)
|
||||
}
|
||||
defer outputFile.Close()
|
||||
|
||||
if err := decryptor.Decrypt(ctx, dbfile, key, outputFile); err != nil {
|
||||
log.Debugf("failed to decrypt %s: %v", dbfile, err)
|
||||
if err == errors.ErrAlreadyDecrypted {
|
||||
if data, err := os.ReadFile(dbfile); err == nil {
|
||||
outputFile.Write(data)
|
||||
}
|
||||
continue
|
||||
}
|
||||
return err
|
||||
continue
|
||||
// return err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
151
internal/errors/domain_errors.go
Normal file
151
internal/errors/domain_errors.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// 微信相关错误
|
||||
|
||||
// WeChatProcessNotFound 创建微信进程未找到错误
|
||||
func WeChatProcessNotFound() *AppError {
|
||||
return New(ErrTypeWeChat, "wechat process not found", nil, http.StatusNotFound).WithStack()
|
||||
}
|
||||
|
||||
// WeChatKeyExtractFailed 创建微信密钥提取失败错误
|
||||
func WeChatKeyExtractFailed(cause error) *AppError {
|
||||
return New(ErrTypeWeChat, "failed to extract wechat key", cause, http.StatusInternalServerError).WithStack()
|
||||
}
|
||||
|
||||
// WeChatDecryptFailed 创建微信解密失败错误
|
||||
func WeChatDecryptFailed(cause error) *AppError {
|
||||
return New(ErrTypeWeChat, "failed to decrypt wechat database", cause, http.StatusInternalServerError).WithStack()
|
||||
}
|
||||
|
||||
// WeChatAccountNotSelected 创建未选择微信账号错误
|
||||
func WeChatAccountNotSelected() *AppError {
|
||||
return New(ErrTypeWeChat, "no wechat account selected", nil, http.StatusBadRequest).WithStack()
|
||||
}
|
||||
|
||||
// 数据库相关错误
|
||||
|
||||
// DBConnectionFailed 创建数据库连接失败错误
|
||||
func DBConnectionFailed(cause error) *AppError {
|
||||
return New(ErrTypeDatabase, "database connection failed", cause, http.StatusInternalServerError).WithStack()
|
||||
}
|
||||
|
||||
// DBQueryFailed 创建数据库查询失败错误
|
||||
func DBQueryFailed(operation string, cause error) *AppError {
|
||||
return New(ErrTypeDatabase, fmt.Sprintf("database query failed: %s", operation), cause, http.StatusInternalServerError).WithStack()
|
||||
}
|
||||
|
||||
// DBRecordNotFound 创建数据库记录未找到错误
|
||||
func DBRecordNotFound(resource string) *AppError {
|
||||
return New(ErrTypeNotFound, fmt.Sprintf("record not found: %s", resource), nil, http.StatusNotFound).WithStack()
|
||||
}
|
||||
|
||||
// 配置相关错误
|
||||
|
||||
// ConfigInvalid 创建配置无效错误
|
||||
func ConfigInvalid(field string, cause error) *AppError {
|
||||
return New(ErrTypeConfig, fmt.Sprintf("invalid configuration: %s", field), cause, http.StatusInternalServerError).WithStack()
|
||||
}
|
||||
|
||||
// ConfigMissing 创建配置缺失错误
|
||||
func ConfigMissing(field string) *AppError {
|
||||
return New(ErrTypeConfig, fmt.Sprintf("missing configuration: %s", field), nil, http.StatusBadRequest).WithStack()
|
||||
}
|
||||
|
||||
// 平台相关错误
|
||||
|
||||
// PlatformUnsupported 创建不支持的平台错误
|
||||
func PlatformUnsupported(platform string, version int) *AppError {
|
||||
return New(ErrTypeInvalidArg, fmt.Sprintf("unsupported platform: %s v%d", platform, version), nil, http.StatusBadRequest).WithStack()
|
||||
}
|
||||
|
||||
// 文件系统错误
|
||||
|
||||
// FileNotFound 创建文件未找到错误
|
||||
func FileNotFound(path string) *AppError {
|
||||
return New(ErrTypeNotFound, fmt.Sprintf("file not found: %s", path), nil, http.StatusNotFound).WithStack()
|
||||
}
|
||||
|
||||
// FileReadFailed 创建文件读取失败错误
|
||||
func FileReadFailed(path string, cause error) *AppError {
|
||||
return New(ErrTypeInternal, fmt.Sprintf("failed to read file: %s", path), cause, http.StatusInternalServerError).WithStack()
|
||||
}
|
||||
|
||||
// FileWriteFailed 创建文件写入失败错误
|
||||
func FileWriteFailed(path string, cause error) *AppError {
|
||||
return New(ErrTypeInternal, fmt.Sprintf("failed to write file: %s", path), cause, http.StatusInternalServerError).WithStack()
|
||||
}
|
||||
|
||||
// 参数验证错误
|
||||
|
||||
// RequiredParam 创建必需参数缺失错误
|
||||
func RequiredParam(param string) *AppError {
|
||||
return New(ErrTypeInvalidArg, fmt.Sprintf("required parameter missing: %s", param), nil, http.StatusBadRequest).WithStack()
|
||||
}
|
||||
|
||||
// InvalidParam 创建参数无效错误
|
||||
func InvalidParam(param string, reason string) *AppError {
|
||||
message := fmt.Sprintf("invalid parameter: %s", param)
|
||||
if reason != "" {
|
||||
message = fmt.Sprintf("%s (%s)", message, reason)
|
||||
}
|
||||
return New(ErrTypeInvalidArg, message, nil, http.StatusBadRequest).WithStack()
|
||||
}
|
||||
|
||||
// 解密相关错误
|
||||
|
||||
// DecryptInvalidKey 创建无效密钥格式错误
|
||||
func DecryptInvalidKey(cause error) *AppError {
|
||||
return New(ErrTypeWeChat, "invalid key format", cause, http.StatusBadRequest).
|
||||
WithStack()
|
||||
}
|
||||
|
||||
// DecryptCreateCipherFailed 创建无法创建加密器错误
|
||||
func DecryptCreateCipherFailed(cause error) *AppError {
|
||||
return New(ErrTypeWeChat, "failed to create cipher", cause, http.StatusInternalServerError).
|
||||
WithStack()
|
||||
}
|
||||
|
||||
// DecryptDecodeKeyFailed 创建无法解码十六进制密钥错误
|
||||
func DecryptDecodeKeyFailed(cause error) *AppError {
|
||||
return New(ErrTypeWeChat, "failed to decode hex key", cause, http.StatusBadRequest).
|
||||
WithStack()
|
||||
}
|
||||
|
||||
// DecryptWriteOutputFailed 创建无法写入输出错误
|
||||
func DecryptWriteOutputFailed(cause error) *AppError {
|
||||
return New(ErrTypeWeChat, "failed to write decryption output", cause, http.StatusInternalServerError).
|
||||
WithStack()
|
||||
}
|
||||
|
||||
// DecryptOperationCanceled 创建解密操作被取消错误
|
||||
func DecryptOperationCanceled() *AppError {
|
||||
return New(ErrTypeWeChat, "decryption operation was canceled", nil, http.StatusBadRequest).
|
||||
WithStack()
|
||||
}
|
||||
|
||||
// DecryptOpenFileFailed 创建无法打开数据库文件错误
|
||||
func DecryptOpenFileFailed(path string, cause error) *AppError {
|
||||
return New(ErrTypeWeChat, fmt.Sprintf("failed to open database file: %s", path), cause, http.StatusInternalServerError).
|
||||
WithStack()
|
||||
}
|
||||
|
||||
// DecryptReadFileFailed 创建无法读取数据库文件错误
|
||||
func DecryptReadFileFailed(path string, cause error) *AppError {
|
||||
return New(ErrTypeWeChat, fmt.Sprintf("failed to read database file: %s", path), cause, http.StatusInternalServerError).
|
||||
WithStack()
|
||||
}
|
||||
|
||||
// DecryptIncompleteRead 创建不完整的头部读取错误
|
||||
func DecryptIncompleteRead(cause error) *AppError {
|
||||
return New(ErrTypeWeChat, "incomplete header read during decryption", cause, http.StatusInternalServerError).
|
||||
WithStack()
|
||||
}
|
||||
|
||||
var ErrAlreadyDecrypted = New(ErrTypeWeChat, "database file is already decrypted", nil, http.StatusBadRequest)
|
||||
var ErrDecryptHashVerificationFailed = New(ErrTypeWeChat, "hash verification failed during decryption", nil, http.StatusBadRequest)
|
||||
var ErrDecryptIncorrectKey = New(ErrTypeWeChat, "incorrect decryption key", nil, http.StatusBadRequest)
|
||||
@@ -1,8 +1,11 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -14,16 +17,25 @@ const (
|
||||
ErrTypeHTTP = "http"
|
||||
ErrTypeConfig = "config"
|
||||
ErrTypeInvalidArg = "invalid_argument"
|
||||
ErrTypeAuth = "authentication"
|
||||
ErrTypePermission = "permission"
|
||||
ErrTypeNotFound = "not_found"
|
||||
ErrTypeValidation = "validation"
|
||||
ErrTypeRateLimit = "rate_limit"
|
||||
ErrTypeInternal = "internal"
|
||||
)
|
||||
|
||||
// AppError 表示应用程序错误
|
||||
type AppError struct {
|
||||
Type string `json:"type"` // 错误类型
|
||||
Message string `json:"message"` // 错误消息
|
||||
Cause error `json:"-"` // 原始错误
|
||||
Code int `json:"-"` // HTTP Code
|
||||
Type string `json:"type"` // 错误类型
|
||||
Message string `json:"message"` // 错误消息
|
||||
Cause error `json:"-"` // 原始错误
|
||||
Code int `json:"-"` // HTTP Code
|
||||
Stack []string `json:"-"` // 错误堆栈
|
||||
RequestID string `json:"request_id,omitempty"` // 请求ID,用于跟踪
|
||||
}
|
||||
|
||||
// Error 实现 error 接口
|
||||
func (e *AppError) Error() string {
|
||||
if e.Cause != nil {
|
||||
return fmt.Sprintf("%s: %s: %v", e.Type, e.Message, e.Cause)
|
||||
@@ -31,10 +43,44 @@ func (e *AppError) Error() string {
|
||||
return fmt.Sprintf("%s: %s", e.Type, e.Message)
|
||||
}
|
||||
|
||||
// String 返回错误的字符串表示
|
||||
func (e *AppError) String() string {
|
||||
return e.Error()
|
||||
}
|
||||
|
||||
// Unwrap 实现 errors.Unwrap 接口,用于错误链
|
||||
func (e *AppError) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// WithStack 添加堆栈信息到错误
|
||||
func (e *AppError) WithStack() *AppError {
|
||||
const depth = 32
|
||||
var pcs [depth]uintptr
|
||||
n := runtime.Callers(2, pcs[:])
|
||||
frames := runtime.CallersFrames(pcs[:n])
|
||||
|
||||
stack := make([]string, 0, n)
|
||||
for {
|
||||
frame, more := frames.Next()
|
||||
if !strings.Contains(frame.File, "runtime/") {
|
||||
stack = append(stack, fmt.Sprintf("%s:%d %s", frame.File, frame.Line, frame.Function))
|
||||
}
|
||||
if !more {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
e.Stack = stack
|
||||
return e
|
||||
}
|
||||
|
||||
// WithRequestID 添加请求ID到错误
|
||||
func (e *AppError) WithRequestID(requestID string) *AppError {
|
||||
e.RequestID = requestID
|
||||
return e
|
||||
}
|
||||
|
||||
// New 创建新的应用错误
|
||||
func New(errType, message string, cause error, code int) *AppError {
|
||||
return &AppError{
|
||||
@@ -45,41 +91,155 @@ func New(errType, message string, cause error, code int) *AppError {
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap 包装现有错误为 AppError
|
||||
func Wrap(err error, errType, message string, code int) *AppError {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 如果已经是 AppError,保留原始类型但更新消息
|
||||
if appErr, ok := err.(*AppError); ok {
|
||||
return &AppError{
|
||||
Type: appErr.Type,
|
||||
Message: message,
|
||||
Cause: appErr.Cause,
|
||||
Code: appErr.Code,
|
||||
Stack: appErr.Stack,
|
||||
}
|
||||
}
|
||||
|
||||
return New(errType, message, err, code)
|
||||
}
|
||||
|
||||
// Is 检查错误是否为特定类型
|
||||
func Is(err error, errType string) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var appErr *AppError
|
||||
if errors.As(err, &appErr) {
|
||||
return appErr.Type == errType
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// GetType 获取错误类型
|
||||
func GetType(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var appErr *AppError
|
||||
if errors.As(err, &appErr) {
|
||||
return appErr.Type
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// GetCode 获取错误的 HTTP 状态码
|
||||
func GetCode(err error) int {
|
||||
if err == nil {
|
||||
return http.StatusOK
|
||||
}
|
||||
|
||||
var appErr *AppError
|
||||
if errors.As(err, &appErr) {
|
||||
return appErr.Code
|
||||
}
|
||||
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
// RootCause 获取错误链中的根本原因
|
||||
func RootCause(err error) error {
|
||||
for err != nil {
|
||||
unwrapped := errors.Unwrap(err)
|
||||
if unwrapped == nil {
|
||||
return err
|
||||
}
|
||||
err = unwrapped
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ErrInvalidArg 无效参数错误
|
||||
func ErrInvalidArg(param string) *AppError {
|
||||
return New(ErrTypeInvalidArg, fmt.Sprintf("invalid arg: %s", param), nil, http.StatusBadRequest)
|
||||
return New(ErrTypeInvalidArg, fmt.Sprintf("invalid arg: %s", param), nil, http.StatusBadRequest).WithStack()
|
||||
}
|
||||
|
||||
// Database 创建数据库错误
|
||||
func Database(message string, cause error) *AppError {
|
||||
return New(ErrTypeDatabase, message, cause, http.StatusInternalServerError)
|
||||
return New(ErrTypeDatabase, message, cause, http.StatusInternalServerError).WithStack()
|
||||
}
|
||||
|
||||
// WeChat 创建微信相关错误
|
||||
func WeChat(message string, cause error) *AppError {
|
||||
return New(ErrTypeWeChat, message, cause, http.StatusInternalServerError)
|
||||
return New(ErrTypeWeChat, message, cause, http.StatusInternalServerError).WithStack()
|
||||
}
|
||||
|
||||
// HTTP 创建HTTP服务错误
|
||||
func HTTP(message string, cause error) *AppError {
|
||||
return New(ErrTypeHTTP, message, cause, http.StatusInternalServerError)
|
||||
return New(ErrTypeHTTP, message, cause, http.StatusInternalServerError).WithStack()
|
||||
}
|
||||
|
||||
// Config 创建配置错误
|
||||
func Config(message string, cause error) *AppError {
|
||||
return New(ErrTypeConfig, message, cause, http.StatusInternalServerError)
|
||||
return New(ErrTypeConfig, message, cause, http.StatusInternalServerError).WithStack()
|
||||
}
|
||||
|
||||
// NotFound 创建资源不存在错误
|
||||
func NotFound(resource string, cause error) *AppError {
|
||||
message := fmt.Sprintf("resource not found: %s", resource)
|
||||
return New(ErrTypeNotFound, message, cause, http.StatusNotFound).WithStack()
|
||||
}
|
||||
|
||||
// Unauthorized 创建未授权错误
|
||||
func Unauthorized(message string, cause error) *AppError {
|
||||
return New(ErrTypeAuth, message, cause, http.StatusUnauthorized).WithStack()
|
||||
}
|
||||
|
||||
// Forbidden 创建权限不足错误
|
||||
func Forbidden(message string, cause error) *AppError {
|
||||
return New(ErrTypePermission, message, cause, http.StatusForbidden).WithStack()
|
||||
}
|
||||
|
||||
// Validation 创建数据验证错误
|
||||
func Validation(message string, cause error) *AppError {
|
||||
return New(ErrTypeValidation, message, cause, http.StatusBadRequest).WithStack()
|
||||
}
|
||||
|
||||
// RateLimit 创建请求频率限制错误
|
||||
func RateLimit(message string, cause error) *AppError {
|
||||
return New(ErrTypeRateLimit, message, cause, http.StatusTooManyRequests).WithStack()
|
||||
}
|
||||
|
||||
// Internal 创建内部服务器错误
|
||||
func Internal(message string, cause error) *AppError {
|
||||
return New(ErrTypeInternal, message, cause, http.StatusInternalServerError).WithStack()
|
||||
}
|
||||
|
||||
// Err 在HTTP响应中返回错误
|
||||
func Err(c *gin.Context, err error) {
|
||||
// 获取请求ID(如果有)
|
||||
requestID := c.GetString("RequestID")
|
||||
|
||||
if appErr, ok := err.(*AppError); ok {
|
||||
if requestID != "" {
|
||||
appErr.RequestID = requestID
|
||||
}
|
||||
c.JSON(appErr.Code, appErr)
|
||||
return
|
||||
}
|
||||
|
||||
// 未知错误
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"type": "unknown",
|
||||
"message": err.Error(),
|
||||
})
|
||||
unknownErr := &AppError{
|
||||
Type: "unknown",
|
||||
Message: err.Error(),
|
||||
Code: http.StatusInternalServerError,
|
||||
RequestID: requestID,
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, unknownErr)
|
||||
}
|
||||
|
||||
165
internal/errors/errors_test.go
Normal file
165
internal/errors/errors_test.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestErrorCreation(t *testing.T) {
|
||||
// 测试创建基本错误
|
||||
err := New("test", "test message", nil, http.StatusBadRequest)
|
||||
if err.Type != "test" || err.Message != "test message" || err.Code != http.StatusBadRequest {
|
||||
t.Errorf("New() created incorrect error: %v", err)
|
||||
}
|
||||
|
||||
// 测试创建带原因的错误
|
||||
cause := fmt.Errorf("original error")
|
||||
err = New("test", "test with cause", cause, http.StatusInternalServerError)
|
||||
if err.Cause != cause {
|
||||
t.Errorf("New() did not set cause correctly: %v", err)
|
||||
}
|
||||
|
||||
// 测试错误消息格式
|
||||
expected := "test: test with cause: original error"
|
||||
if err.Error() != expected {
|
||||
t.Errorf("Error() = %q, want %q", err.Error(), expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorWrapping(t *testing.T) {
|
||||
// 测试包装普通错误
|
||||
original := fmt.Errorf("original error")
|
||||
wrapped := Wrap(original, "wrapped", "wrapped message", http.StatusBadRequest)
|
||||
|
||||
if wrapped.Type != "wrapped" || wrapped.Message != "wrapped message" {
|
||||
t.Errorf("Wrap() created incorrect error: %v", wrapped)
|
||||
}
|
||||
|
||||
if wrapped.Cause != original {
|
||||
t.Errorf("Wrap() did not set cause correctly")
|
||||
}
|
||||
|
||||
// 测试包装 AppError
|
||||
appErr := New("app", "app error", nil, http.StatusNotFound)
|
||||
rewrapped := Wrap(appErr, "ignored", "new message", http.StatusBadRequest)
|
||||
|
||||
if rewrapped.Type != "app" {
|
||||
t.Errorf("Wrap() did not preserve original AppError type: got %s, want %s",
|
||||
rewrapped.Type, appErr.Type)
|
||||
}
|
||||
|
||||
if rewrapped.Message != "new message" {
|
||||
t.Errorf("Wrap() did not update message: got %s, want %s",
|
||||
rewrapped.Message, "new message")
|
||||
}
|
||||
|
||||
if rewrapped.Code != appErr.Code {
|
||||
t.Errorf("Wrap() did not preserve original status code: got %d, want %d",
|
||||
rewrapped.Code, appErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorTypeChecking(t *testing.T) {
|
||||
// 创建不同类型的错误
|
||||
dbErr := Database("db error", nil)
|
||||
httpErr := HTTP("http error", nil)
|
||||
|
||||
// 测试 Is 函数
|
||||
if !Is(dbErr, ErrTypeDatabase) {
|
||||
t.Errorf("Is() failed to identify database error")
|
||||
}
|
||||
|
||||
if Is(dbErr, ErrTypeHTTP) {
|
||||
t.Errorf("Is() incorrectly identified database error as HTTP error")
|
||||
}
|
||||
|
||||
if !Is(httpErr, ErrTypeHTTP) {
|
||||
t.Errorf("Is() failed to identify HTTP error")
|
||||
}
|
||||
|
||||
// 测试 GetType 函数
|
||||
if GetType(dbErr) != ErrTypeDatabase {
|
||||
t.Errorf("GetType() returned incorrect type: got %s, want %s",
|
||||
GetType(dbErr), ErrTypeDatabase)
|
||||
}
|
||||
|
||||
if GetType(httpErr) != ErrTypeHTTP {
|
||||
t.Errorf("GetType() returned incorrect type: got %s, want %s",
|
||||
GetType(httpErr), ErrTypeHTTP)
|
||||
}
|
||||
|
||||
// 测试普通错误
|
||||
stdErr := fmt.Errorf("standard error")
|
||||
if GetType(stdErr) != "unknown" {
|
||||
t.Errorf("GetType() for standard error should return 'unknown', got %s",
|
||||
GetType(stdErr))
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorUnwrapping(t *testing.T) {
|
||||
// 创建嵌套错误
|
||||
innermost := fmt.Errorf("innermost error")
|
||||
inner := Wrap(innermost, "inner", "inner error", http.StatusBadRequest)
|
||||
outer := Wrap(inner, "outer", "outer error", http.StatusInternalServerError)
|
||||
|
||||
// 测试 Unwrap
|
||||
if unwrapped := outer.Unwrap(); unwrapped != inner.Cause {
|
||||
t.Errorf("Unwrap() did not return correct inner error")
|
||||
}
|
||||
|
||||
// 测试 RootCause
|
||||
if root := RootCause(outer); root != innermost {
|
||||
t.Errorf("RootCause() did not return innermost error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorHelperFunctions(t *testing.T) {
|
||||
// 测试辅助函数
|
||||
invalidArg := ErrInvalidArg("username")
|
||||
if invalidArg.Type != ErrTypeInvalidArg {
|
||||
t.Errorf("ErrInvalidArg() created error with wrong type: %s", invalidArg.Type)
|
||||
}
|
||||
|
||||
dbErr := Database("query failed", nil)
|
||||
if dbErr.Type != ErrTypeDatabase {
|
||||
t.Errorf("Database() created error with wrong type: %s", dbErr.Type)
|
||||
}
|
||||
|
||||
notFound := NotFound("user", nil)
|
||||
if notFound.Type != ErrTypeNotFound || notFound.Code != http.StatusNotFound {
|
||||
t.Errorf("NotFound() created error with wrong type or code: %s, %d",
|
||||
notFound.Type, notFound.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorUtilityFunctions(t *testing.T) {
|
||||
// 测试 JoinErrors
|
||||
err1 := fmt.Errorf("error 1")
|
||||
err2 := fmt.Errorf("error 2")
|
||||
|
||||
// 单个错误
|
||||
if joined := JoinErrors(err1); joined != err1 {
|
||||
t.Errorf("JoinErrors() with single error should return that error")
|
||||
}
|
||||
|
||||
// 多个错误
|
||||
joined := JoinErrors(err1, err2)
|
||||
if joined == nil {
|
||||
t.Errorf("JoinErrors() returned nil for multiple errors")
|
||||
}
|
||||
|
||||
// nil 错误
|
||||
if joined := JoinErrors(nil, nil); joined != nil {
|
||||
t.Errorf("JoinErrors() with all nil should return nil")
|
||||
}
|
||||
|
||||
// 测试 WrapIfErr
|
||||
if wrapped := WrapIfErr(nil, "test", "message", http.StatusOK); wrapped != nil {
|
||||
t.Errorf("WrapIfErr() with nil should return nil")
|
||||
}
|
||||
|
||||
if wrapped := WrapIfErr(err1, "test", "message", http.StatusBadRequest); wrapped == nil {
|
||||
t.Errorf("WrapIfErr() with non-nil error should return non-nil")
|
||||
}
|
||||
}
|
||||
66
internal/errors/middleware.go
Normal file
66
internal/errors/middleware.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ErrorHandlerMiddleware 是一个 Gin 中间件,用于统一处理请求过程中的错误
|
||||
// 它会为每个请求生成一个唯一的请求 ID,并在错误发生时将其添加到错误响应中
|
||||
func ErrorHandlerMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 生成请求 ID
|
||||
requestID := uuid.New().String()
|
||||
c.Set("RequestID", requestID)
|
||||
c.Header("X-Request-ID", requestID)
|
||||
|
||||
// 处理请求
|
||||
c.Next()
|
||||
|
||||
// 检查是否有错误
|
||||
if len(c.Errors) > 0 {
|
||||
// 获取第一个错误
|
||||
err := c.Errors[0].Err
|
||||
|
||||
// 使用 Err 函数处理错误响应
|
||||
Err(c, err)
|
||||
|
||||
// 已经处理过错误,不需要继续
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RecoveryMiddleware 是一个 Gin 中间件,用于从 panic 恢复并返回 500 错误
|
||||
func RecoveryMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// 获取请求 ID
|
||||
requestID, _ := c.Get("RequestID")
|
||||
requestIDStr, _ := requestID.(string)
|
||||
|
||||
// 创建内部服务器错误
|
||||
var err *AppError
|
||||
switch v := r.(type) {
|
||||
case error:
|
||||
err = Internal("panic recovered", v).WithRequestID(requestIDStr)
|
||||
default:
|
||||
err = Internal(fmt.Sprintf("panic recovered: %v", r), nil).WithRequestID(requestIDStr)
|
||||
}
|
||||
|
||||
// 记录错误日志
|
||||
fmt.Printf("PANIC RECOVERED: %v\n", err)
|
||||
|
||||
// 返回 500 错误
|
||||
c.JSON(http.StatusInternalServerError, err)
|
||||
c.Abort()
|
||||
}
|
||||
}()
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
131
internal/errors/utils.go
Normal file
131
internal/errors/utils.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// WrapIfErr 如果 err 不为 nil,则包装错误并返回,否则返回 nil
|
||||
func WrapIfErr(err error, errType, message string, code int) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return Wrap(err, errType, message, code)
|
||||
}
|
||||
|
||||
// JoinErrors 将多个错误合并为一个错误
|
||||
// 如果只有一个错误不为 nil,则返回该错误
|
||||
// 如果有多个错误不为 nil,则创建一个包含所有错误信息的新错误
|
||||
func JoinErrors(errs ...error) error {
|
||||
var nonNilErrs []error
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
nonNilErrs = append(nonNilErrs, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(nonNilErrs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(nonNilErrs) == 1 {
|
||||
return nonNilErrs[0]
|
||||
}
|
||||
|
||||
// 合并多个错误
|
||||
var messages []string
|
||||
for _, err := range nonNilErrs {
|
||||
messages = append(messages, err.Error())
|
||||
}
|
||||
|
||||
return Internal(
|
||||
fmt.Sprintf("multiple errors occurred: %s", strings.Join(messages, "; ")),
|
||||
nonNilErrs[0],
|
||||
)
|
||||
}
|
||||
|
||||
// IsNil 检查错误是否为 nil
|
||||
func IsNil(err error) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// IsNotNil 检查错误是否不为 nil
|
||||
func IsNotNil(err error) bool {
|
||||
return err != nil
|
||||
}
|
||||
|
||||
// IsType 检查错误是否为指定类型
|
||||
func IsType(err error, errType string) bool {
|
||||
return Is(err, errType)
|
||||
}
|
||||
|
||||
// HasCause 检查错误是否包含指定的原因
|
||||
func HasCause(err error, cause error) bool {
|
||||
if err == nil || cause == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var appErr *AppError
|
||||
if stderrors.As(err, &appErr) {
|
||||
if appErr.Cause == cause {
|
||||
return true
|
||||
}
|
||||
return HasCause(appErr.Cause, cause)
|
||||
}
|
||||
|
||||
return err == cause
|
||||
}
|
||||
|
||||
// AsAppError 将错误转换为 AppError 类型
|
||||
func AsAppError(err error) (*AppError, bool) {
|
||||
var appErr *AppError
|
||||
if stderrors.As(err, &appErr) {
|
||||
return appErr, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// FormatErrorChain 格式化错误链,便于调试
|
||||
func FormatErrorChain(err error) string {
|
||||
if err == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
result.WriteString(err.Error())
|
||||
|
||||
// 获取 AppError 类型的堆栈信息
|
||||
var appErr *AppError
|
||||
if stderrors.As(err, &appErr) && len(appErr.Stack) > 0 {
|
||||
result.WriteString("\nStack Trace:\n")
|
||||
for _, frame := range appErr.Stack {
|
||||
result.WriteString(" ")
|
||||
result.WriteString(frame)
|
||||
result.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// 递归处理错误链
|
||||
cause := stderrors.Unwrap(err)
|
||||
if cause != nil {
|
||||
result.WriteString("\nCaused by: ")
|
||||
result.WriteString(FormatErrorChain(cause))
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// GetErrorDetails 返回错误的详细信息,包括类型、消息、HTTP状态码和请求ID
|
||||
func GetErrorDetails(err error) (errType string, message string, code int, requestID string) {
|
||||
if err == nil {
|
||||
return "", "", 0, ""
|
||||
}
|
||||
|
||||
var appErr *AppError
|
||||
if stderrors.As(err, &appErr) {
|
||||
return appErr.Type, appErr.Message, appErr.Code, appErr.RequestID
|
||||
}
|
||||
|
||||
return "unknown", err.Error(), 500, ""
|
||||
}
|
||||
115
internal/model/chatroom.go
Normal file
115
internal/model/chatroom.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/sjzar/chatlog/internal/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,
|
||||
}
|
||||
}
|
||||
|
||||
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 ""
|
||||
}
|
||||
97
internal/model/chatroom_darwinv3.go
Normal file
97
internal/model/chatroom_darwinv3.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package model
|
||||
|
||||
import "strings"
|
||||
|
||||
// CREATE TABLE GroupContact(
|
||||
// m_nsUsrName TEXT PRIMARY KEY ASC,
|
||||
// m_uiConType INTEGER,
|
||||
// nickname TEXT,
|
||||
// m_nsFullPY TEXT,
|
||||
// m_nsShortPY TEXT,
|
||||
// m_nsRemark TEXT,
|
||||
// m_nsRemarkPYFull TEXT,
|
||||
// m_nsRemarkPYShort TEXT,
|
||||
// m_uiCertificationFlag INTEGER,
|
||||
// m_uiSex INTEGER,
|
||||
// m_uiType INTEGER,
|
||||
// m_nsImgStatus TEXT,
|
||||
// m_uiImgKey INTEGER,
|
||||
// m_nsHeadImgUrl TEXT,
|
||||
// m_nsHeadHDImgUrl TEXT,
|
||||
// m_nsHeadHDMd5 TEXT,
|
||||
// m_nsChatRoomMemList TEXT,
|
||||
// m_nsChatRoomAdminList TEXT,
|
||||
// m_uiChatRoomStatus INTEGER,
|
||||
// m_nsChatRoomDesc TEXT,
|
||||
// m_nsDraft TEXT,
|
||||
// m_nsBrandIconUrl TEXT,
|
||||
// m_nsGoogleContactName TEXT,
|
||||
// m_nsAliasName TEXT,
|
||||
// m_nsEncodeUserName TEXT,
|
||||
// m_uiChatRoomVersion INTEGER,
|
||||
// m_uiChatRoomMaxCount INTEGER,
|
||||
// m_uiChatRoomType INTEGER,
|
||||
// m_patSuffix TEXT,
|
||||
// richChatRoomDesc TEXT,
|
||||
// _packed_WCContactData BLOB,
|
||||
// openIMInfo BLOB
|
||||
// )
|
||||
type ChatRoomDarwinV3 struct {
|
||||
M_nsUsrName string `json:"m_nsUsrName"`
|
||||
Nickname string `json:"nickname"`
|
||||
M_nsRemark string `json:"m_nsRemark"`
|
||||
M_nsChatRoomMemList string `json:"m_nsChatRoomMemList"`
|
||||
M_nsChatRoomAdminList string `json:"m_nsChatRoomAdminList"`
|
||||
|
||||
// M_uiConType int `json:"m_uiConType"`
|
||||
// M_nsFullPY string `json:"m_nsFullPY"`
|
||||
// M_nsShortPY string `json:"m_nsShortPY"`
|
||||
// M_nsRemarkPYFull string `json:"m_nsRemarkPYFull"`
|
||||
// M_nsRemarkPYShort string `json:"m_nsRemarkPYShort"`
|
||||
// M_uiCertificationFlag int `json:"m_uiCertificationFlag"`
|
||||
// M_uiSex int `json:"m_uiSex"`
|
||||
// M_uiType int `json:"m_uiType"`
|
||||
// M_nsImgStatus string `json:"m_nsImgStatus"`
|
||||
// M_uiImgKey int `json:"m_uiImgKey"`
|
||||
// M_nsHeadImgUrl string `json:"m_nsHeadImgUrl"`
|
||||
// M_nsHeadHDImgUrl string `json:"m_nsHeadHDImgUrl"`
|
||||
// M_nsHeadHDMd5 string `json:"m_nsHeadHDMd5"`
|
||||
// M_uiChatRoomStatus int `json:"m_uiChatRoomStatus"`
|
||||
// M_nsChatRoomDesc string `json:"m_nsChatRoomDesc"`
|
||||
// M_nsDraft string `json:"m_nsDraft"`
|
||||
// M_nsBrandIconUrl string `json:"m_nsBrandIconUrl"`
|
||||
// M_nsGoogleContactName string `json:"m_nsGoogleContactName"`
|
||||
// M_nsAliasName string `json:"m_nsAliasName"`
|
||||
// M_nsEncodeUserName string `json:"m_nsEncodeUserName"`
|
||||
// M_uiChatRoomVersion int `json:"m_uiChatRoomVersion"`
|
||||
// M_uiChatRoomMaxCount int `json:"m_uiChatRoomMaxCount"`
|
||||
// M_uiChatRoomType int `json:"m_uiChatRoomType"`
|
||||
// M_patSuffix string `json:"m_patSuffix"`
|
||||
// RichChatRoomDesc string `json:"richChatRoomDesc"`
|
||||
// Packed_WCContactData []byte `json:"_packed_WCContactData"`
|
||||
// OpenIMInfo []byte `json:"openIMInfo"`
|
||||
}
|
||||
|
||||
func (c *ChatRoomDarwinV3) Wrap(user2DisplayName map[string]string) *ChatRoom {
|
||||
|
||||
split := strings.Split(c.M_nsChatRoomMemList, ";")
|
||||
users := make([]ChatRoomUser, 0, len(split))
|
||||
_user2DisplayName := make(map[string]string)
|
||||
for _, v := range split {
|
||||
users = append(users, ChatRoomUser{
|
||||
UserName: v,
|
||||
})
|
||||
if name, ok := user2DisplayName[v]; ok {
|
||||
_user2DisplayName[v] = name
|
||||
}
|
||||
}
|
||||
|
||||
return &ChatRoom{
|
||||
Name: c.M_nsUsrName,
|
||||
Owner: c.M_nsChatRoomAdminList,
|
||||
Remark: c.M_nsRemark,
|
||||
NickName: c.Nickname,
|
||||
Users: users,
|
||||
User2DisplayName: _user2DisplayName,
|
||||
}
|
||||
}
|
||||
28
internal/model/chatroom_v4.go
Normal file
28
internal/model/chatroom_v4.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package model
|
||||
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
97
internal/model/contact.go
Normal file
97
internal/model/contact.go
Normal file
@@ -0,0 +1,97 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Contact) DisplayName() string {
|
||||
switch {
|
||||
case c.Remark != "":
|
||||
return c.Remark
|
||||
case c.NickName != "":
|
||||
return c.NickName
|
||||
}
|
||||
return ""
|
||||
}
|
||||
80
internal/model/contact_darwinv3.go
Normal file
80
internal/model/contact_darwinv3.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package model
|
||||
|
||||
// CREATE TABLE WCContact(
|
||||
// m_nsUsrName TEXT PRIMARY KEY ASC,
|
||||
// m_uiConType INTEGER,
|
||||
// nickname TEXT,
|
||||
// m_nsFullPY TEXT,
|
||||
// m_nsShortPY TEXT,
|
||||
// m_nsRemark TEXT,
|
||||
// m_nsRemarkPYFull TEXT,
|
||||
// m_nsRemarkPYShort TEXT,
|
||||
// m_uiCertificationFlag INTEGER,
|
||||
// m_uiSex INTEGER,
|
||||
// m_uiType INTEGER,
|
||||
// m_nsImgStatus TEXT,
|
||||
// m_uiImgKey INTEGER,
|
||||
// m_nsHeadImgUrl TEXT,
|
||||
// m_nsHeadHDImgUrl TEXT,
|
||||
// m_nsHeadHDMd5 TEXT,
|
||||
// m_nsChatRoomMemList TEXT,
|
||||
// m_nsChatRoomAdminList TEXT,
|
||||
// m_uiChatRoomStatus INTEGER,
|
||||
// m_nsChatRoomDesc TEXT,
|
||||
// m_nsDraft TEXT,
|
||||
// m_nsBrandIconUrl TEXT,
|
||||
// m_nsGoogleContactName TEXT,
|
||||
// m_nsAliasName TEXT,
|
||||
// m_nsEncodeUserName TEXT,
|
||||
// m_uiChatRoomVersion INTEGER,
|
||||
// m_uiChatRoomMaxCount INTEGER,
|
||||
// m_uiChatRoomType INTEGER,
|
||||
// m_patSuffix TEXT,
|
||||
// richChatRoomDesc TEXT,
|
||||
// _packed_WCContactData BLOB,
|
||||
// openIMInfo BLOB
|
||||
// )
|
||||
type ContactDarwinV3 struct {
|
||||
M_nsUsrName string `json:"m_nsUsrName"`
|
||||
Nickname string `json:"nickname"`
|
||||
M_nsRemark string `json:"m_nsRemark"`
|
||||
M_uiSex int `json:"m_uiSex"`
|
||||
M_nsAliasName string `json:"m_nsAliasName"`
|
||||
|
||||
// M_uiConType int `json:"m_uiConType"`
|
||||
// M_nsShortPY string `json:"m_nsShortPY"`
|
||||
// M_nsRemarkPYFull string `json:"m_nsRemarkPYFull"`
|
||||
// M_nsRemarkPYShort string `json:"m_nsRemarkPYShort"`
|
||||
// M_uiCertificationFlag int `json:"m_uiCertificationFlag"`
|
||||
// M_uiType int `json:"m_uiType"` // 本来想拿这个字段来区分是否是好友,但是数据比较乱,好在 darwin v3 Contact 表中没有群聊成员
|
||||
// M_nsImgStatus string `json:"m_nsImgStatus"`
|
||||
// M_uiImgKey int `json:"m_uiImgKey"`
|
||||
// M_nsHeadImgUrl string `json:"m_nsHeadImgUrl"`
|
||||
// M_nsHeadHDImgUrl string `json:"m_nsHeadHDImgUrl"`
|
||||
// M_nsHeadHDMd5 string `json:"m_nsHeadHDMd5"`
|
||||
// M_nsChatRoomMemList string `json:"m_nsChatRoomMemList"`
|
||||
// M_nsChatRoomAdminList string `json:"m_nsChatRoomAdminList"`
|
||||
// M_uiChatRoomStatus int `json:"m_uiChatRoomStatus"`
|
||||
// M_nsChatRoomDesc string `json:"m_nsChatRoomDesc"`
|
||||
// M_nsDraft string `json:"m_nsDraft"`
|
||||
// M_nsBrandIconUrl string `json:"m_nsBrandIconUrl"`
|
||||
// M_nsGoogleContactName string `json:"m_nsGoogleContactName"`
|
||||
// M_nsEncodeUserName string `json:"m_nsEncodeUserName"`
|
||||
// M_uiChatRoomVersion int `json:"m_uiChatRoomVersion"`
|
||||
// M_uiChatRoomMaxCount int `json:"m_uiChatRoomMaxCount"`
|
||||
// M_uiChatRoomType int `json:"m_uiChatRoomType"`
|
||||
// M_patSuffix string `json:"m_patSuffix"`
|
||||
// RichChatRoomDesc string `json:"richChatRoomDesc"`
|
||||
// Packed_WCContactData string `json:"_packed_WCContactData"`
|
||||
// OpenIMInfo string `json:"openIMInfo"`
|
||||
}
|
||||
|
||||
func (c *ContactDarwinV3) Wrap() *Contact {
|
||||
return &Contact{
|
||||
UserName: c.M_nsUsrName,
|
||||
Alias: c.M_nsAliasName,
|
||||
Remark: c.M_nsRemark,
|
||||
NickName: c.Nickname,
|
||||
IsFriend: true,
|
||||
}
|
||||
}
|
||||
62
internal/model/contact_v4.go
Normal file
62
internal/model/contact_v4.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package model
|
||||
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
228
internal/model/message.go
Normal file
228
internal/model/message.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/model/wxproto"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
// Source
|
||||
WeChatV3 = "wechatv3"
|
||||
WeChatV4 = "wechatv4"
|
||||
WeChatDarwinV3 = "wechatdarwinv3"
|
||||
)
|
||||
|
||||
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:"-"` // 显示名称
|
||||
ChatRoomName 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,
|
||||
}
|
||||
}
|
||||
|
||||
// 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.ChatRoomName != "" {
|
||||
buf.WriteString(m.ChatRoomName)
|
||||
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:
|
||||
content := m.Content
|
||||
if len(content) > 120 {
|
||||
content = content[:120] + "<...>"
|
||||
}
|
||||
buf.WriteString(fmt.Sprintf("Type: %d Content: %s", m.Type, content))
|
||||
}
|
||||
buf.WriteString("\n")
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
69
internal/model/message_darwinv3.go
Normal file
69
internal/model/message_darwinv3.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CREATE TABLE Chat_md5(talker)(
|
||||
// mesLocalID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
// mesSvrID INTEGER,msgCreateTime INTEGER,
|
||||
// msgContent TEXT,msgStatus INTEGER,
|
||||
// msgImgStatus INTEGER,
|
||||
// messageType INTEGER,
|
||||
// mesDes INTEGER,
|
||||
// msgSource TEXT,
|
||||
// IntRes1 INTEGER,
|
||||
// IntRes2 INTEGER,
|
||||
// StrRes1 TEXT,
|
||||
// StrRes2 TEXT,
|
||||
// msgVoiceText TEXT,
|
||||
// msgSeq INTEGER,
|
||||
// CompressContent BLOB,
|
||||
// ConBlob BLOB
|
||||
// )
|
||||
type MessageDarwinV3 struct {
|
||||
MesCreateTime int64 `json:"mesCreateTime"`
|
||||
MesContent string `json:"mesContent"`
|
||||
MesType int `json:"mesType"`
|
||||
MesDes int `json:"mesDes"` // 0: 发送, 1: 接收
|
||||
MesSource string `json:"mesSource"`
|
||||
|
||||
// MesLocalID int64 `json:"mesLocalID"`
|
||||
// MesSvrID int64 `json:"mesSvrID"`
|
||||
// MesStatus int `json:"mesStatus"`
|
||||
// MesImgStatus int `json:"mesImgStatus"`
|
||||
// IntRes1 int `json:"IntRes1"`
|
||||
// IntRes2 int `json:"IntRes2"`
|
||||
// StrRes1 string `json:"StrRes1"`
|
||||
// StrRes2 string `json:"StrRes2"`
|
||||
// MesVoiceText string `json:"mesVoiceText"`
|
||||
// MesSeq int `json:"mesSeq"`
|
||||
// CompressContent []byte `json:"CompressContent"`
|
||||
// ConBlob []byte `json:"ConBlob"`
|
||||
}
|
||||
|
||||
func (m *MessageDarwinV3) Wrap(talker string) *Message {
|
||||
isChatRoom := strings.HasSuffix(talker, "@chatroom")
|
||||
|
||||
var chatRoomSender string
|
||||
content := m.MesContent
|
||||
if isChatRoom {
|
||||
split := strings.SplitN(m.MesContent, ":\n", 2)
|
||||
if len(split) == 2 {
|
||||
chatRoomSender = split[0]
|
||||
content = split[1]
|
||||
}
|
||||
}
|
||||
|
||||
return &Message{
|
||||
CreateTime: time.Unix(m.MesCreateTime, 0),
|
||||
Content: content,
|
||||
Talker: talker,
|
||||
Type: m.MesType,
|
||||
IsSender: (m.MesDes + 1) % 2,
|
||||
IsChatRoom: isChatRoom,
|
||||
ChatRoomSender: chatRoomSender,
|
||||
Version: WeChatDarwinV3,
|
||||
}
|
||||
}
|
||||
91
internal/model/message_v4.go
Normal file
91
internal/model/message_v4.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sjzar/chatlog/pkg/util/zstd"
|
||||
)
|
||||
|
||||
// CREATE TABLE Msg_md5(talker)(
|
||||
// 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 _m.Type == 1 {
|
||||
_m.Content = string(m.MessageContent)
|
||||
} else {
|
||||
if bytes.HasPrefix(m.MessageContent, []byte{0x28, 0xb5, 0x2f, 0xfd}) {
|
||||
if b, err := zstd.Decompress(m.MessageContent); err == nil {
|
||||
_m.Content = string(b)
|
||||
}
|
||||
} else {
|
||||
_m.CompressContent = m.MessageContent
|
||||
}
|
||||
}
|
||||
|
||||
if isChatRoom {
|
||||
_m.IsChatRoom = true
|
||||
split := strings.SplitN(_m.Content, ":\n", 2)
|
||||
if len(split) == 2 {
|
||||
_m.ChatRoomSender = split[0]
|
||||
_m.Content = split[1]
|
||||
}
|
||||
}
|
||||
|
||||
return _m
|
||||
}
|
||||
92
internal/model/session.go
Normal file
92
internal/model/session.go
Normal file
@@ -0,0 +1,92 @@
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
41
internal/model/session_darwinv3.go
Normal file
41
internal/model/session_darwinv3.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// CREATE TABLE SessionAbstract(
|
||||
// m_nsUserName TEXT PRIMARY KEY,
|
||||
// m_uUnReadCount INTEGER,
|
||||
// m_bShowUnReadAsRedDot INTEGER,
|
||||
// m_bMarkUnread INTEGER,
|
||||
// m_uLastTime INTEGER,
|
||||
// strRes1 TEXT,
|
||||
// strRes2 TEXT,
|
||||
// strRes3 TEXT,
|
||||
// intRes1 INTEGER,
|
||||
// intRes2 INTEGER,
|
||||
// intRes3 INTEGER,
|
||||
// _packed_MMSessionInfo BLOB
|
||||
// )
|
||||
type SessionDarwinV3 struct {
|
||||
M_nsUserName string `json:"m_nsUserName"`
|
||||
M_uLastTime int `json:"m_uLastTime"`
|
||||
|
||||
// M_uUnReadCount int `json:"m_uUnReadCount"`
|
||||
// M_bShowUnReadAsRedDot int `json:"m_bShowUnReadAsRedDot"`
|
||||
// M_bMarkUnread int `json:"m_bMarkUnread"`
|
||||
// StrRes1 string `json:"strRes1"`
|
||||
// StrRes2 string `json:"strRes2"`
|
||||
// StrRes3 string `json:"strRes3"`
|
||||
// IntRes1 int `json:"intRes1"`
|
||||
// IntRes2 int `json:"intRes2"`
|
||||
// IntRes3 int `json:"intRes3"`
|
||||
// PackedMMSessionInfo string `json:"_packed_MMSessionInfo"` // TODO: decode
|
||||
}
|
||||
|
||||
func (s *SessionDarwinV3) Wrap() *Session {
|
||||
return &Session{
|
||||
UserName: s.M_nsUserName,
|
||||
NOrder: s.M_uLastTime,
|
||||
NTime: time.Unix(int64(s.M_uLastTime), 0),
|
||||
}
|
||||
}
|
||||
54
internal/model/session_v4.go
Normal file
54
internal/model/session_v4.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// 注意,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 *SessionV4) Wrap() *Session {
|
||||
return &Session{
|
||||
UserName: s.Username,
|
||||
NOrder: s.LastTimestamp,
|
||||
NickName: s.LastSenderDisplayName,
|
||||
Content: s.Summary,
|
||||
NTime: time.Unix(int64(s.LastTimestamp), 0),
|
||||
}
|
||||
}
|
||||
254
internal/model/wxproto/bytesextra.pb.go
Normal file
254
internal/model/wxproto/bytesextra.pb.go
Normal file
@@ -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
|
||||
}
|
||||
18
internal/model/wxproto/bytesextra.proto
Normal file
18
internal/model/wxproto/bytesextra.proto
Normal file
@@ -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;
|
||||
}
|
||||
222
internal/model/wxproto/roomdata.pb.go
Normal file
222
internal/model/wxproto/roomdata.pb.go
Normal file
@@ -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
|
||||
}
|
||||
16
internal/model/wxproto/roomdata.proto
Normal file
16
internal/model/wxproto/roomdata.proto
Normal file
@@ -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; // 邀请人
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func New() *Footer {
|
||||
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.copyRight.SetText(fmt.Sprintf("[%s::b]%s[-:-:-]", style.GetColorHex(style.PageHeaderFgColor), fmt.Sprintf(" @ Sarv's Chatlog %s", version.Version)))
|
||||
|
||||
footer.help.
|
||||
SetDynamicColors(true).
|
||||
|
||||
@@ -22,20 +22,22 @@ const (
|
||||
|
||||
[green]使用步骤:[white]
|
||||
|
||||
[yellow]1. 获取数据密钥[white]
|
||||
选择"获取数据密钥"菜单项,程序会自动从运行中的微信进程获取密钥。
|
||||
如果有多个微信进程,会自动选择当前账号的进程。
|
||||
确保微信正在运行,否则无法获取密钥。
|
||||
[yellow]1. 下载并安装微信客户端[white]
|
||||
|
||||
[yellow]2. 解密数据[white]
|
||||
选择"解密数据"菜单项,程序会使用获取的密钥解密微信数据库文件。
|
||||
[yellow]2. 迁移手机微信聊天记录[white]
|
||||
手机微信上操作 [yellow]我 - 设置 - 通用 - 聊天记录迁移与备份 - 迁移 - 迁移到电脑[white]。
|
||||
这一步的目的是将手机中的聊天记录传输到电脑上。
|
||||
可以放心操作,不会影响到手机上的聊天记录。
|
||||
|
||||
[yellow]3. 解密数据[white]
|
||||
重新打开 chatlog,选择"解密数据"菜单项,程序会使用获取的密钥解密微信数据库文件。
|
||||
解密后的文件会保存到工作目录中(可在设置中修改)。
|
||||
|
||||
[yellow]3. 启动 HTTP 服务[white]
|
||||
[yellow]4. 启动 HTTP 服务[white]
|
||||
选择"启动 HTTP 服务"菜单项,启动 HTTP 和 MCP 服务。
|
||||
启动后可以通过浏览器访问 http://localhost:5030 查看聊天记录。
|
||||
|
||||
[yellow]4. 设置选项[white]
|
||||
[yellow]5. 设置选项[white]
|
||||
选择"设置"菜单项,可以配置:
|
||||
• HTTP 服务端口 - 更改 HTTP 服务的监听端口
|
||||
• 工作目录 - 更改解密数据的存储位置
|
||||
|
||||
@@ -1,415 +0,0 @@
|
||||
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)
|
||||
}
|
||||
138
internal/wechat/decrypt/common/common.go
Normal file
138
internal/wechat/decrypt/common/common.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
KeySize = 32
|
||||
SaltSize = 16
|
||||
AESBlockSize = 16
|
||||
SQLiteHeader = "SQLite format 3\x00"
|
||||
IVSize = 16
|
||||
)
|
||||
|
||||
type DBFile struct {
|
||||
Path string
|
||||
Salt []byte
|
||||
TotalPages int64
|
||||
FirstPage []byte
|
||||
}
|
||||
|
||||
func OpenDBFile(dbPath string, pageSize int) (*DBFile, error) {
|
||||
fp, err := os.Open(dbPath)
|
||||
if err != nil {
|
||||
return nil, errors.DecryptOpenFileFailed(dbPath, err)
|
||||
}
|
||||
defer fp.Close()
|
||||
|
||||
fileInfo, err := fp.Stat()
|
||||
if err != nil {
|
||||
return nil, errors.WeChatDecryptFailed(err)
|
||||
}
|
||||
|
||||
fileSize := fileInfo.Size()
|
||||
totalPages := fileSize / int64(pageSize)
|
||||
if fileSize%int64(pageSize) > 0 {
|
||||
totalPages++
|
||||
}
|
||||
|
||||
buffer := make([]byte, pageSize)
|
||||
n, err := io.ReadFull(fp, buffer)
|
||||
if err != nil {
|
||||
return nil, errors.DecryptReadFileFailed(dbPath, err)
|
||||
}
|
||||
if n != pageSize {
|
||||
return nil, errors.DecryptIncompleteRead(fmt.Errorf("read %d bytes, expected %d", n, pageSize))
|
||||
}
|
||||
|
||||
if bytes.Equal(buffer[:len(SQLiteHeader)-1], []byte(SQLiteHeader[:len(SQLiteHeader)-1])) {
|
||||
return nil, errors.ErrAlreadyDecrypted
|
||||
}
|
||||
|
||||
return &DBFile{
|
||||
Path: dbPath,
|
||||
Salt: buffer[:SaltSize],
|
||||
FirstPage: buffer,
|
||||
TotalPages: totalPages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func XorBytes(a []byte, b byte) []byte {
|
||||
result := make([]byte, len(a))
|
||||
for i := range a {
|
||||
result[i] = a[i] ^ b
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func ValidateKey(page1 []byte, key []byte, salt []byte, hashFunc func() hash.Hash, hmacSize int, reserve int, pageSize int, deriveKeys func([]byte, []byte) ([]byte, []byte)) bool {
|
||||
if len(key) != KeySize {
|
||||
return false
|
||||
}
|
||||
|
||||
_, macKey := deriveKeys(key, salt)
|
||||
|
||||
mac := hmac.New(hashFunc, macKey)
|
||||
dataEnd := pageSize - reserve + IVSize
|
||||
mac.Write(page1[SaltSize:dataEnd])
|
||||
|
||||
pageNoBytes := make([]byte, 4)
|
||||
binary.LittleEndian.PutUint32(pageNoBytes, 1)
|
||||
mac.Write(pageNoBytes)
|
||||
|
||||
calculatedMAC := mac.Sum(nil)
|
||||
storedMAC := page1[dataEnd : dataEnd+hmacSize]
|
||||
|
||||
return hmac.Equal(calculatedMAC, storedMAC)
|
||||
}
|
||||
|
||||
func DecryptPage(pageBuf []byte, encKey []byte, macKey []byte, pageNum int64, hashFunc func() hash.Hash, hmacSize int, reserve int, pageSize int) ([]byte, error) {
|
||||
offset := 0
|
||||
if pageNum == 0 {
|
||||
offset = SaltSize
|
||||
}
|
||||
|
||||
mac := hmac.New(hashFunc, macKey)
|
||||
mac.Write(pageBuf[offset : pageSize-reserve+IVSize])
|
||||
|
||||
pageNoBytes := make([]byte, 4)
|
||||
binary.LittleEndian.PutUint32(pageNoBytes, uint32(pageNum+1))
|
||||
mac.Write(pageNoBytes)
|
||||
|
||||
hashMac := mac.Sum(nil)
|
||||
|
||||
hashMacStartOffset := pageSize - reserve + IVSize
|
||||
hashMacEndOffset := hashMacStartOffset + hmacSize
|
||||
|
||||
if !bytes.Equal(hashMac, pageBuf[hashMacStartOffset:hashMacEndOffset]) {
|
||||
return nil, errors.ErrDecryptHashVerificationFailed
|
||||
}
|
||||
|
||||
iv := pageBuf[pageSize-reserve : pageSize-reserve+IVSize]
|
||||
block, err := aes.NewCipher(encKey)
|
||||
if err != nil {
|
||||
return nil, errors.DecryptCreateCipherFailed(err)
|
||||
}
|
||||
|
||||
mode := cipher.NewCBCDecrypter(block, iv)
|
||||
|
||||
encrypted := make([]byte, pageSize-reserve-offset)
|
||||
copy(encrypted, pageBuf[offset:pageSize-reserve])
|
||||
|
||||
mode.CryptBlocks(encrypted, encrypted)
|
||||
|
||||
decryptedPage := append(encrypted, pageBuf[pageSize-reserve:pageSize]...)
|
||||
|
||||
return decryptedPage, nil
|
||||
}
|
||||
184
internal/wechat/decrypt/darwin/v3.go
Normal file
184
internal/wechat/decrypt/darwin/v3.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package darwin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"hash"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/errors"
|
||||
"github.com/sjzar/chatlog/internal/wechat/decrypt/common"
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
)
|
||||
|
||||
// 常量定义
|
||||
const (
|
||||
V3PageSize = 1024
|
||||
HmacSHA1Size = 20
|
||||
)
|
||||
|
||||
// V3Decryptor 实现 macOS V3 版本的解密器
|
||||
type V3Decryptor struct {
|
||||
// macOS V3 特定参数
|
||||
hmacSize int
|
||||
hashFunc func() hash.Hash
|
||||
reserve int
|
||||
pageSize int
|
||||
version string
|
||||
}
|
||||
|
||||
// NewV3Decryptor 创建 macOS V3 解密器
|
||||
func NewV3Decryptor() *V3Decryptor {
|
||||
hashFunc := sha1.New
|
||||
hmacSize := HmacSHA1Size
|
||||
reserve := common.IVSize + hmacSize
|
||||
if reserve%common.AESBlockSize != 0 {
|
||||
reserve = ((reserve / common.AESBlockSize) + 1) * common.AESBlockSize
|
||||
}
|
||||
|
||||
return &V3Decryptor{
|
||||
hmacSize: hmacSize,
|
||||
hashFunc: hashFunc,
|
||||
reserve: reserve,
|
||||
pageSize: V3PageSize,
|
||||
version: "macOS v3",
|
||||
}
|
||||
}
|
||||
|
||||
// deriveKeys 派生 MAC 密钥
|
||||
// 注意:macOS V3 版本直接使用提供的密钥作为加密密钥,不进行 PBKDF2 派生
|
||||
func (d *V3Decryptor) deriveKeys(key []byte, salt []byte) ([]byte, []byte) {
|
||||
// 对于 macOS V3,直接使用密钥作为加密密钥
|
||||
encKey := key
|
||||
|
||||
// 生成 MAC 密钥
|
||||
macSalt := common.XorBytes(salt, 0x3a)
|
||||
macKey := pbkdf2.Key(encKey, macSalt, 2, common.KeySize, d.hashFunc)
|
||||
|
||||
return encKey, macKey
|
||||
}
|
||||
|
||||
// Validate 验证密钥是否有效
|
||||
func (d *V3Decryptor) Validate(page1 []byte, key []byte) bool {
|
||||
if len(page1) < d.pageSize || len(key) != common.KeySize {
|
||||
return false
|
||||
}
|
||||
|
||||
salt := page1[:common.SaltSize]
|
||||
return common.ValidateKey(page1, key, salt, d.hashFunc, d.hmacSize, d.reserve, d.pageSize, d.deriveKeys)
|
||||
}
|
||||
|
||||
// Decrypt 解密数据库
|
||||
func (d *V3Decryptor) Decrypt(ctx context.Context, dbfile string, hexKey string, output io.Writer) error {
|
||||
// 解码密钥
|
||||
key, err := hex.DecodeString(hexKey)
|
||||
if err != nil {
|
||||
return errors.DecryptDecodeKeyFailed(err)
|
||||
}
|
||||
|
||||
// 打开数据库文件并读取基本信息
|
||||
dbInfo, err := common.OpenDBFile(dbfile, d.pageSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 验证密钥
|
||||
if !d.Validate(dbInfo.FirstPage, key) {
|
||||
return errors.ErrDecryptIncorrectKey
|
||||
}
|
||||
|
||||
// 计算密钥
|
||||
encKey, macKey := d.deriveKeys(key, dbInfo.Salt)
|
||||
|
||||
// 打开数据库文件
|
||||
dbFile, err := os.Open(dbfile)
|
||||
if err != nil {
|
||||
return errors.DecryptOpenFileFailed(dbfile, err)
|
||||
}
|
||||
defer dbFile.Close()
|
||||
|
||||
// 写入 SQLite 头
|
||||
_, err = output.Write([]byte(common.SQLiteHeader))
|
||||
if err != nil {
|
||||
return errors.DecryptWriteOutputFailed(err)
|
||||
}
|
||||
|
||||
// 处理每一页
|
||||
pageBuf := make([]byte, d.pageSize)
|
||||
|
||||
for curPage := int64(0); curPage < dbInfo.TotalPages; curPage++ {
|
||||
// 检查是否取消
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return errors.DecryptOperationCanceled()
|
||||
default:
|
||||
// 继续处理
|
||||
}
|
||||
|
||||
// 读取一页
|
||||
n, err := io.ReadFull(dbFile, pageBuf)
|
||||
if err != nil {
|
||||
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||||
// 处理最后一部分页面
|
||||
if n > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return errors.DecryptReadFileFailed(dbfile, err)
|
||||
}
|
||||
|
||||
// 检查页面是否全为零
|
||||
allZeros := true
|
||||
for _, b := range pageBuf {
|
||||
if b != 0 {
|
||||
allZeros = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if allZeros {
|
||||
// 写入零页面
|
||||
_, err = output.Write(pageBuf)
|
||||
if err != nil {
|
||||
return errors.DecryptWriteOutputFailed(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 解密页面
|
||||
decryptedData, err := common.DecryptPage(pageBuf, encKey, macKey, curPage, d.hashFunc, d.hmacSize, d.reserve, d.pageSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 写入解密后的页面
|
||||
_, err = output.Write(decryptedData)
|
||||
if err != nil {
|
||||
return errors.DecryptWriteOutputFailed(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPageSize 返回页面大小
|
||||
func (d *V3Decryptor) GetPageSize() int {
|
||||
return d.pageSize
|
||||
}
|
||||
|
||||
// GetReserve 返回保留字节数
|
||||
func (d *V3Decryptor) GetReserve() int {
|
||||
return d.reserve
|
||||
}
|
||||
|
||||
// GetHMACSize 返回HMAC大小
|
||||
func (d *V3Decryptor) GetHMACSize() int {
|
||||
return d.hmacSize
|
||||
}
|
||||
|
||||
// GetVersion 返回解密器版本
|
||||
func (d *V3Decryptor) GetVersion() string {
|
||||
return d.version
|
||||
}
|
||||
194
internal/wechat/decrypt/darwin/v4.go
Normal file
194
internal/wechat/decrypt/darwin/v4.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package darwin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha512"
|
||||
"encoding/hex"
|
||||
"hash"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/errors"
|
||||
"github.com/sjzar/chatlog/internal/wechat/decrypt/common"
|
||||
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
)
|
||||
|
||||
// Darwin Version 4 same as WIndows Version 4
|
||||
|
||||
// V4 版本特定常量
|
||||
const (
|
||||
V4PageSize = 4096
|
||||
V4IterCount = 256000
|
||||
HmacSHA512Size = 64
|
||||
)
|
||||
|
||||
// V4Decryptor 实现Windows V4版本的解密器
|
||||
type V4Decryptor struct {
|
||||
// V4 特定参数
|
||||
iterCount int
|
||||
hmacSize int
|
||||
hashFunc func() hash.Hash
|
||||
reserve int
|
||||
pageSize int
|
||||
version string
|
||||
}
|
||||
|
||||
// NewV4Decryptor 创建Windows V4解密器
|
||||
func NewV4Decryptor() *V4Decryptor {
|
||||
hashFunc := sha512.New
|
||||
hmacSize := HmacSHA512Size
|
||||
reserve := common.IVSize + hmacSize
|
||||
if reserve%common.AESBlockSize != 0 {
|
||||
reserve = ((reserve / common.AESBlockSize) + 1) * common.AESBlockSize
|
||||
}
|
||||
|
||||
return &V4Decryptor{
|
||||
iterCount: V4IterCount,
|
||||
hmacSize: hmacSize,
|
||||
hashFunc: hashFunc,
|
||||
reserve: reserve,
|
||||
pageSize: V4PageSize,
|
||||
version: "macOS v4",
|
||||
}
|
||||
}
|
||||
|
||||
// deriveKeys 派生加密密钥和MAC密钥
|
||||
func (d *V4Decryptor) deriveKeys(key []byte, salt []byte) ([]byte, []byte) {
|
||||
// 生成加密密钥
|
||||
encKey := pbkdf2.Key(key, salt, d.iterCount, common.KeySize, d.hashFunc)
|
||||
|
||||
// 生成MAC密钥
|
||||
macSalt := common.XorBytes(salt, 0x3a)
|
||||
macKey := pbkdf2.Key(encKey, macSalt, 2, common.KeySize, d.hashFunc)
|
||||
|
||||
return encKey, macKey
|
||||
}
|
||||
|
||||
// Validate 验证密钥是否有效
|
||||
func (d *V4Decryptor) Validate(page1 []byte, key []byte) bool {
|
||||
if len(page1) < d.pageSize || len(key) != common.KeySize {
|
||||
return false
|
||||
}
|
||||
|
||||
salt := page1[:common.SaltSize]
|
||||
return common.ValidateKey(page1, key, salt, d.hashFunc, d.hmacSize, d.reserve, d.pageSize, d.deriveKeys)
|
||||
}
|
||||
|
||||
// Decrypt 解密数据库
|
||||
func (d *V4Decryptor) Decrypt(ctx context.Context, dbfile string, hexKey string, output io.Writer) error {
|
||||
// 解码密钥
|
||||
key, err := hex.DecodeString(hexKey)
|
||||
if err != nil {
|
||||
return errors.DecryptDecodeKeyFailed(err)
|
||||
}
|
||||
|
||||
// 打开数据库文件并读取基本信息
|
||||
dbInfo, err := common.OpenDBFile(dbfile, d.pageSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 验证密钥
|
||||
if !d.Validate(dbInfo.FirstPage, key) {
|
||||
return errors.ErrDecryptIncorrectKey
|
||||
}
|
||||
|
||||
// 计算密钥
|
||||
encKey, macKey := d.deriveKeys(key, dbInfo.Salt)
|
||||
|
||||
// 打开数据库文件
|
||||
dbFile, err := os.Open(dbfile)
|
||||
if err != nil {
|
||||
return errors.DecryptOpenFileFailed(dbfile, err)
|
||||
}
|
||||
defer dbFile.Close()
|
||||
|
||||
// 写入SQLite头
|
||||
_, err = output.Write([]byte(common.SQLiteHeader))
|
||||
if err != nil {
|
||||
return errors.DecryptWriteOutputFailed(err)
|
||||
}
|
||||
|
||||
// 处理每一页
|
||||
pageBuf := make([]byte, d.pageSize)
|
||||
|
||||
for curPage := int64(0); curPage < dbInfo.TotalPages; curPage++ {
|
||||
// 检查是否取消
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return errors.DecryptOperationCanceled()
|
||||
default:
|
||||
// 继续处理
|
||||
}
|
||||
|
||||
// 读取一页
|
||||
n, err := io.ReadFull(dbFile, pageBuf)
|
||||
if err != nil {
|
||||
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||||
// 处理最后一部分页面
|
||||
if n > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return errors.DecryptReadFileFailed(dbfile, err)
|
||||
}
|
||||
|
||||
// 检查页面是否全为零
|
||||
allZeros := true
|
||||
for _, b := range pageBuf {
|
||||
if b != 0 {
|
||||
allZeros = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if allZeros {
|
||||
// 写入零页面
|
||||
_, err = output.Write(pageBuf)
|
||||
if err != nil {
|
||||
return errors.DecryptWriteOutputFailed(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 解密页面
|
||||
decryptedData, err := common.DecryptPage(pageBuf, encKey, macKey, curPage, d.hashFunc, d.hmacSize, d.reserve, d.pageSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 写入解密后的页面
|
||||
_, err = output.Write(decryptedData)
|
||||
if err != nil {
|
||||
return errors.DecryptWriteOutputFailed(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPageSize 返回页面大小
|
||||
func (d *V4Decryptor) GetPageSize() int {
|
||||
return d.pageSize
|
||||
}
|
||||
|
||||
// GetReserve 返回保留字节数
|
||||
func (d *V4Decryptor) GetReserve() int {
|
||||
return d.reserve
|
||||
}
|
||||
|
||||
// GetHMACSize 返回HMAC大小
|
||||
func (d *V4Decryptor) GetHMACSize() int {
|
||||
return d.hmacSize
|
||||
}
|
||||
|
||||
// GetVersion 返回解密器版本
|
||||
func (d *V4Decryptor) GetVersion() string {
|
||||
return d.version
|
||||
}
|
||||
|
||||
// GetIterCount 返回迭代次数(Windows特有)
|
||||
func (d *V4Decryptor) GetIterCount() int {
|
||||
return d.iterCount
|
||||
}
|
||||
55
internal/wechat/decrypt/decryptor.go
Normal file
55
internal/wechat/decrypt/decryptor.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package decrypt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/errors"
|
||||
"github.com/sjzar/chatlog/internal/wechat/decrypt/darwin"
|
||||
"github.com/sjzar/chatlog/internal/wechat/decrypt/windows"
|
||||
)
|
||||
|
||||
// 错误定义
|
||||
var (
|
||||
ErrInvalidVersion = fmt.Errorf("invalid version, must be 3 or 4")
|
||||
ErrUnsupportedPlatform = fmt.Errorf("unsupported platform")
|
||||
)
|
||||
|
||||
// Decryptor 定义数据库解密的接口
|
||||
type Decryptor interface {
|
||||
// Decrypt 解密数据库
|
||||
Decrypt(ctx context.Context, dbfile string, key string, output io.Writer) error
|
||||
|
||||
// Validate 验证密钥是否有效
|
||||
Validate(page1 []byte, key []byte) bool
|
||||
|
||||
// GetPageSize 返回页面大小
|
||||
GetPageSize() int
|
||||
|
||||
// GetReserve 返回保留字节数
|
||||
GetReserve() int
|
||||
|
||||
// GetHMACSize 返回HMAC大小
|
||||
GetHMACSize() int
|
||||
|
||||
// GetVersion 返回解密器版本
|
||||
GetVersion() string
|
||||
}
|
||||
|
||||
// NewDecryptor 创建一个新的解密器
|
||||
func NewDecryptor(platform string, version int) (Decryptor, error) {
|
||||
// 根据平台返回对应的实现
|
||||
switch {
|
||||
case platform == "windows" && version == 3:
|
||||
return windows.NewV3Decryptor(), nil
|
||||
case platform == "windows" && version == 4:
|
||||
return windows.NewV4Decryptor(), nil
|
||||
case platform == "darwin" && version == 3:
|
||||
return darwin.NewV3Decryptor(), nil
|
||||
case platform == "darwin" && version == 4:
|
||||
return darwin.NewV4Decryptor(), nil
|
||||
default:
|
||||
return nil, errors.PlatformUnsupported(platform, version)
|
||||
}
|
||||
}
|
||||
56
internal/wechat/decrypt/validator.go
Normal file
56
internal/wechat/decrypt/validator.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package decrypt
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/wechat/decrypt/common"
|
||||
)
|
||||
|
||||
type Validator struct {
|
||||
platform string
|
||||
version int
|
||||
dbPath string
|
||||
decryptor Decryptor
|
||||
dbFile *common.DBFile
|
||||
}
|
||||
|
||||
// NewValidator 创建一个仅用于验证的验证器
|
||||
func NewValidator(dataDir string, platform string, version int) (*Validator, error) {
|
||||
decryptor, err := NewDecryptor(platform, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dbFile := GetSimpleDBFile(platform, version)
|
||||
dbPath := filepath.Join(dataDir + "/" + dbFile)
|
||||
d, err := common.OpenDBFile(dbPath, decryptor.GetPageSize())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Validator{
|
||||
platform: platform,
|
||||
version: version,
|
||||
dbPath: dbPath,
|
||||
decryptor: decryptor,
|
||||
dbFile: d,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (v *Validator) Validate(key []byte) bool {
|
||||
return v.decryptor.Validate(v.dbFile.FirstPage, key)
|
||||
}
|
||||
|
||||
func GetSimpleDBFile(platform string, version int) string {
|
||||
switch {
|
||||
case platform == "windows" && version == 3:
|
||||
return "Msg\\Misc.db"
|
||||
case platform == "windows" && version == 4:
|
||||
return "db_storage\\message\\message_0.db"
|
||||
case platform == "darwin" && version == 3:
|
||||
return "Message/msg_0.db"
|
||||
case platform == "darwin" && version == 4:
|
||||
return "db_storage/message/message_0.db"
|
||||
}
|
||||
return ""
|
||||
|
||||
}
|
||||
192
internal/wechat/decrypt/windows/v3.go
Normal file
192
internal/wechat/decrypt/windows/v3.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"hash"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/errors"
|
||||
"github.com/sjzar/chatlog/internal/wechat/decrypt/common"
|
||||
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
)
|
||||
|
||||
// V3 版本特定常量
|
||||
const (
|
||||
PageSize = 4096
|
||||
V3IterCount = 64000
|
||||
HmacSHA1Size = 20
|
||||
)
|
||||
|
||||
// V3Decryptor 实现Windows V3版本的解密器
|
||||
type V3Decryptor struct {
|
||||
// V3 特定参数
|
||||
iterCount int
|
||||
hmacSize int
|
||||
hashFunc func() hash.Hash
|
||||
reserve int
|
||||
pageSize int
|
||||
version string
|
||||
}
|
||||
|
||||
// NewV3Decryptor 创建Windows V3解密器
|
||||
func NewV3Decryptor() *V3Decryptor {
|
||||
hashFunc := sha1.New
|
||||
hmacSize := HmacSHA1Size
|
||||
reserve := common.IVSize + hmacSize
|
||||
if reserve%common.AESBlockSize != 0 {
|
||||
reserve = ((reserve / common.AESBlockSize) + 1) * common.AESBlockSize
|
||||
}
|
||||
|
||||
return &V3Decryptor{
|
||||
iterCount: V3IterCount,
|
||||
hmacSize: hmacSize,
|
||||
hashFunc: hashFunc,
|
||||
reserve: reserve,
|
||||
pageSize: PageSize,
|
||||
version: "Windows v3",
|
||||
}
|
||||
}
|
||||
|
||||
// deriveKeys 派生加密密钥和MAC密钥
|
||||
func (d *V3Decryptor) deriveKeys(key []byte, salt []byte) ([]byte, []byte) {
|
||||
// 生成加密密钥
|
||||
encKey := pbkdf2.Key(key, salt, d.iterCount, common.KeySize, d.hashFunc)
|
||||
|
||||
// 生成MAC密钥
|
||||
macSalt := common.XorBytes(salt, 0x3a)
|
||||
macKey := pbkdf2.Key(encKey, macSalt, 2, common.KeySize, d.hashFunc)
|
||||
|
||||
return encKey, macKey
|
||||
}
|
||||
|
||||
// Validate 验证密钥是否有效
|
||||
func (d *V3Decryptor) Validate(page1 []byte, key []byte) bool {
|
||||
if len(page1) < d.pageSize || len(key) != common.KeySize {
|
||||
return false
|
||||
}
|
||||
|
||||
salt := page1[:common.SaltSize]
|
||||
return common.ValidateKey(page1, key, salt, d.hashFunc, d.hmacSize, d.reserve, d.pageSize, d.deriveKeys)
|
||||
}
|
||||
|
||||
// Decrypt 解密数据库
|
||||
func (d *V3Decryptor) Decrypt(ctx context.Context, dbfile string, hexKey string, output io.Writer) error {
|
||||
// 解码密钥
|
||||
key, err := hex.DecodeString(hexKey)
|
||||
if err != nil {
|
||||
return errors.DecryptDecodeKeyFailed(err)
|
||||
}
|
||||
|
||||
// 打开数据库文件并读取基本信息
|
||||
dbInfo, err := common.OpenDBFile(dbfile, d.pageSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 验证密钥
|
||||
if !d.Validate(dbInfo.FirstPage, key) {
|
||||
return errors.ErrDecryptIncorrectKey
|
||||
}
|
||||
|
||||
// 计算密钥
|
||||
encKey, macKey := d.deriveKeys(key, dbInfo.Salt)
|
||||
|
||||
// 打开数据库文件
|
||||
dbFile, err := os.Open(dbfile)
|
||||
if err != nil {
|
||||
return errors.DecryptOpenFileFailed(dbfile, err)
|
||||
}
|
||||
defer dbFile.Close()
|
||||
|
||||
// 写入SQLite头
|
||||
_, err = output.Write([]byte(common.SQLiteHeader))
|
||||
if err != nil {
|
||||
return errors.DecryptWriteOutputFailed(err)
|
||||
}
|
||||
|
||||
// 处理每一页
|
||||
pageBuf := make([]byte, d.pageSize)
|
||||
|
||||
for curPage := int64(0); curPage < dbInfo.TotalPages; curPage++ {
|
||||
// 检查是否取消
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return errors.DecryptOperationCanceled()
|
||||
default:
|
||||
// 继续处理
|
||||
}
|
||||
|
||||
// 读取一页
|
||||
n, err := io.ReadFull(dbFile, pageBuf)
|
||||
if err != nil {
|
||||
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||||
// 处理最后一部分页面
|
||||
if n > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return errors.DecryptReadFileFailed(dbfile, err)
|
||||
}
|
||||
|
||||
// 检查页面是否全为零
|
||||
allZeros := true
|
||||
for _, b := range pageBuf {
|
||||
if b != 0 {
|
||||
allZeros = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if allZeros {
|
||||
// 写入零页面
|
||||
_, err = output.Write(pageBuf)
|
||||
if err != nil {
|
||||
return errors.DecryptWriteOutputFailed(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 解密页面
|
||||
decryptedData, err := common.DecryptPage(pageBuf, encKey, macKey, curPage, d.hashFunc, d.hmacSize, d.reserve, d.pageSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 写入解密后的页面
|
||||
_, err = output.Write(decryptedData)
|
||||
if err != nil {
|
||||
return errors.DecryptWriteOutputFailed(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPageSize 返回页面大小
|
||||
func (d *V3Decryptor) GetPageSize() int {
|
||||
return d.pageSize
|
||||
}
|
||||
|
||||
// GetReserve 返回保留字节数
|
||||
func (d *V3Decryptor) GetReserve() int {
|
||||
return d.reserve
|
||||
}
|
||||
|
||||
// GetHMACSize 返回HMAC大小
|
||||
func (d *V3Decryptor) GetHMACSize() int {
|
||||
return d.hmacSize
|
||||
}
|
||||
|
||||
// GetVersion 返回解密器版本
|
||||
func (d *V3Decryptor) GetVersion() string {
|
||||
return d.version
|
||||
}
|
||||
|
||||
// GetIterCount 返回迭代次数(Windows特有)
|
||||
func (d *V3Decryptor) GetIterCount() int {
|
||||
return d.iterCount
|
||||
}
|
||||
190
internal/wechat/decrypt/windows/v4.go
Normal file
190
internal/wechat/decrypt/windows/v4.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha512"
|
||||
"encoding/hex"
|
||||
"hash"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/errors"
|
||||
"github.com/sjzar/chatlog/internal/wechat/decrypt/common"
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
)
|
||||
|
||||
// V4 版本特定常量
|
||||
const (
|
||||
V4IterCount = 256000
|
||||
HmacSHA512Size = 64
|
||||
)
|
||||
|
||||
// V4Decryptor 实现Windows V4版本的解密器
|
||||
type V4Decryptor struct {
|
||||
// V4 特定参数
|
||||
iterCount int
|
||||
hmacSize int
|
||||
hashFunc func() hash.Hash
|
||||
reserve int
|
||||
pageSize int
|
||||
version string
|
||||
}
|
||||
|
||||
// NewV4Decryptor 创建Windows V4解密器
|
||||
func NewV4Decryptor() *V4Decryptor {
|
||||
hashFunc := sha512.New
|
||||
hmacSize := HmacSHA512Size
|
||||
reserve := common.IVSize + hmacSize
|
||||
if reserve%common.AESBlockSize != 0 {
|
||||
reserve = ((reserve / common.AESBlockSize) + 1) * common.AESBlockSize
|
||||
}
|
||||
|
||||
return &V4Decryptor{
|
||||
iterCount: V4IterCount,
|
||||
hmacSize: hmacSize,
|
||||
hashFunc: hashFunc,
|
||||
reserve: reserve,
|
||||
pageSize: PageSize,
|
||||
version: "Windows v4",
|
||||
}
|
||||
}
|
||||
|
||||
// deriveKeys 派生加密密钥和MAC密钥
|
||||
func (d *V4Decryptor) deriveKeys(key []byte, salt []byte) ([]byte, []byte) {
|
||||
// 生成加密密钥
|
||||
encKey := pbkdf2.Key(key, salt, d.iterCount, common.KeySize, d.hashFunc)
|
||||
|
||||
// 生成MAC密钥
|
||||
macSalt := common.XorBytes(salt, 0x3a)
|
||||
macKey := pbkdf2.Key(encKey, macSalt, 2, common.KeySize, d.hashFunc)
|
||||
|
||||
return encKey, macKey
|
||||
}
|
||||
|
||||
// Validate 验证密钥是否有效
|
||||
func (d *V4Decryptor) Validate(page1 []byte, key []byte) bool {
|
||||
if len(page1) < d.pageSize || len(key) != common.KeySize {
|
||||
return false
|
||||
}
|
||||
|
||||
salt := page1[:common.SaltSize]
|
||||
return common.ValidateKey(page1, key, salt, d.hashFunc, d.hmacSize, d.reserve, d.pageSize, d.deriveKeys)
|
||||
}
|
||||
|
||||
// Decrypt 解密数据库
|
||||
func (d *V4Decryptor) Decrypt(ctx context.Context, dbfile string, hexKey string, output io.Writer) error {
|
||||
// 解码密钥
|
||||
key, err := hex.DecodeString(hexKey)
|
||||
if err != nil {
|
||||
return errors.DecryptDecodeKeyFailed(err)
|
||||
}
|
||||
|
||||
// 打开数据库文件并读取基本信息
|
||||
dbInfo, err := common.OpenDBFile(dbfile, d.pageSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 验证密钥
|
||||
if !d.Validate(dbInfo.FirstPage, key) {
|
||||
return errors.ErrDecryptIncorrectKey
|
||||
}
|
||||
|
||||
// 计算密钥
|
||||
encKey, macKey := d.deriveKeys(key, dbInfo.Salt)
|
||||
|
||||
// 打开数据库文件
|
||||
dbFile, err := os.Open(dbfile)
|
||||
if err != nil {
|
||||
return errors.DecryptOpenFileFailed(dbfile, err)
|
||||
}
|
||||
defer dbFile.Close()
|
||||
|
||||
// 写入SQLite头
|
||||
_, err = output.Write([]byte(common.SQLiteHeader))
|
||||
if err != nil {
|
||||
return errors.DecryptWriteOutputFailed(err)
|
||||
}
|
||||
|
||||
// 处理每一页
|
||||
pageBuf := make([]byte, d.pageSize)
|
||||
|
||||
for curPage := int64(0); curPage < dbInfo.TotalPages; curPage++ {
|
||||
// 检查是否取消
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return errors.DecryptOperationCanceled()
|
||||
default:
|
||||
// 继续处理
|
||||
}
|
||||
|
||||
// 读取一页
|
||||
n, err := io.ReadFull(dbFile, pageBuf)
|
||||
if err != nil {
|
||||
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||||
// 处理最后一部分页面
|
||||
if n > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return errors.DecryptReadFileFailed(dbfile, err)
|
||||
}
|
||||
|
||||
// 检查页面是否全为零
|
||||
allZeros := true
|
||||
for _, b := range pageBuf {
|
||||
if b != 0 {
|
||||
allZeros = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if allZeros {
|
||||
// 写入零页面
|
||||
_, err = output.Write(pageBuf)
|
||||
if err != nil {
|
||||
return errors.DecryptWriteOutputFailed(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 解密页面
|
||||
decryptedData, err := common.DecryptPage(pageBuf, encKey, macKey, curPage, d.hashFunc, d.hmacSize, d.reserve, d.pageSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 写入解密后的页面
|
||||
_, err = output.Write(decryptedData)
|
||||
if err != nil {
|
||||
return errors.DecryptWriteOutputFailed(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPageSize 返回页面大小
|
||||
func (d *V4Decryptor) GetPageSize() int {
|
||||
return d.pageSize
|
||||
}
|
||||
|
||||
// GetReserve 返回保留字节数
|
||||
func (d *V4Decryptor) GetReserve() int {
|
||||
return d.reserve
|
||||
}
|
||||
|
||||
// GetHMACSize 返回HMAC大小
|
||||
func (d *V4Decryptor) GetHMACSize() int {
|
||||
return d.hmacSize
|
||||
}
|
||||
|
||||
// GetVersion 返回解密器版本
|
||||
func (d *V4Decryptor) GetVersion() string {
|
||||
return d.version
|
||||
}
|
||||
|
||||
// GetIterCount 返回迭代次数(Windows特有)
|
||||
func (d *V4Decryptor) GetIterCount() int {
|
||||
return d.iterCount
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
//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
|
||||
}
|
||||
@@ -1,507 +0,0 @@
|
||||
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
|
||||
}
|
||||
118
internal/wechat/key/darwin/glance/glance.go
Normal file
118
internal/wechat/key/darwin/glance/glance.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package glance
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FIXME 按照 region 读取效率较低,512MB 内存读取耗时约 18s
|
||||
|
||||
type Glance struct {
|
||||
PID uint32
|
||||
MemRegions []MemRegion
|
||||
pipePath string
|
||||
data []byte
|
||||
}
|
||||
|
||||
func NewGlance(pid uint32) *Glance {
|
||||
return &Glance{
|
||||
PID: pid,
|
||||
pipePath: filepath.Join(os.TempDir(), fmt.Sprintf("chatlog_pipe_%d", time.Now().UnixNano())),
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Glance) Read() ([]byte, error) {
|
||||
if g.data != nil {
|
||||
return g.data, nil
|
||||
}
|
||||
|
||||
regions, err := GetVmmap(g.PID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.MemRegions = MemRegionsFilter(regions)
|
||||
|
||||
if len(g.MemRegions) == 0 {
|
||||
return nil, fmt.Errorf("no memory regions found")
|
||||
}
|
||||
|
||||
region := g.MemRegions[0]
|
||||
|
||||
// 1. Create pipe file
|
||||
if err := exec.Command("mkfifo", g.pipePath).Run(); err != nil {
|
||||
return nil, fmt.Errorf("failed to create pipe file: %w", err)
|
||||
}
|
||||
defer os.Remove(g.pipePath)
|
||||
|
||||
// Start a goroutine to read from the pipe
|
||||
dataCh := make(chan []byte, 1)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
// Open pipe for reading
|
||||
file, err := os.OpenFile(g.pipePath, os.O_RDONLY, 0600)
|
||||
if err != nil {
|
||||
errCh <- fmt.Errorf("failed to open pipe for reading: %w", err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Read all data from pipe
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
errCh <- fmt.Errorf("failed to read from pipe: %w", err)
|
||||
return
|
||||
}
|
||||
dataCh <- data
|
||||
}()
|
||||
|
||||
// 2 & 3. Execute lldb command to read memory directly with all parameters
|
||||
size := region.End - region.Start
|
||||
lldbCmd := fmt.Sprintf("lldb -p %d -o \"memory read --binary --force --outfile %s --count %d 0x%x\" -o \"quit\"",
|
||||
g.PID, g.pipePath, size, region.Start)
|
||||
|
||||
cmd := exec.Command("bash", "-c", lldbCmd)
|
||||
|
||||
// Set up stdout pipe for monitoring (optional)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
// Start the command
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("failed to start lldb: %w", err)
|
||||
}
|
||||
|
||||
// Monitor lldb output (optional)
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
for scanner.Scan() {
|
||||
// Uncomment for debugging:
|
||||
// fmt.Println(scanner.Text())
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for data with timeout
|
||||
select {
|
||||
case data := <-dataCh:
|
||||
g.data = data
|
||||
case err := <-errCh:
|
||||
return nil, fmt.Errorf("failed to read memory: %w", err)
|
||||
case <-time.After(30 * time.Second):
|
||||
cmd.Process.Kill()
|
||||
return nil, fmt.Errorf("timeout waiting for memory data")
|
||||
}
|
||||
|
||||
// Wait for the command to finish
|
||||
if err := cmd.Wait(); err != nil {
|
||||
// We already have the data, so just log the error
|
||||
fmt.Printf("Warning: lldb process exited with error: %v\n", err)
|
||||
}
|
||||
|
||||
return g.data, nil
|
||||
}
|
||||
37
internal/wechat/key/darwin/glance/sip.go
Normal file
37
internal/wechat/key/darwin/glance/sip.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package glance
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IsSIPDisabled checks if System Integrity Protection (SIP) is disabled on macOS.
|
||||
// Returns true if SIP is disabled, false if it's enabled or if the status cannot be determined.
|
||||
func IsSIPDisabled() bool {
|
||||
// Run the csrutil status command to check SIP status
|
||||
cmd := exec.Command("csrutil", "status")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// If there's an error running the command, assume SIP is enabled
|
||||
return false
|
||||
}
|
||||
|
||||
// Convert output to string and check if SIP is disabled
|
||||
outputStr := strings.ToLower(string(output))
|
||||
|
||||
// $ csrutil status
|
||||
// System Integrity Protection status: disabled.
|
||||
|
||||
// If the output contains "disabled", SIP is disabled
|
||||
if strings.Contains(outputStr, "system integrity protection status: disabled") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for partial SIP disabling - some configurations might have specific protections disabled
|
||||
if strings.Contains(outputStr, "disabled") && strings.Contains(outputStr, "debugging") {
|
||||
return true
|
||||
}
|
||||
|
||||
// By default, assume SIP is enabled
|
||||
return false
|
||||
}
|
||||
158
internal/wechat/key/darwin/glance/vmmap.go
Normal file
158
internal/wechat/key/darwin/glance/vmmap.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package glance
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
FilterRegionType = "MALLOC_NANO"
|
||||
FilterSHRMOD = "SM=PRV"
|
||||
CommandVmmap = "vmmap"
|
||||
)
|
||||
|
||||
type MemRegion struct {
|
||||
RegionType string
|
||||
Start uint64
|
||||
End uint64
|
||||
VSize uint64 // Size in bytes
|
||||
RSDNT uint64 // Resident memory size in bytes (new field)
|
||||
SHRMOD string
|
||||
Permissions string
|
||||
RegionDetail string
|
||||
}
|
||||
|
||||
func GetVmmap(pid uint32) ([]MemRegion, error) {
|
||||
// Execute vmmap command
|
||||
cmd := exec.Command(CommandVmmap, "-wide", fmt.Sprintf("%d", pid))
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error executing vmmap command: %w", err)
|
||||
}
|
||||
|
||||
// Parse the output using the existing LoadVmmap function
|
||||
return LoadVmmap(string(output))
|
||||
}
|
||||
|
||||
func LoadVmmap(output string) ([]MemRegion, error) {
|
||||
var regions []MemRegion
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(output))
|
||||
|
||||
// Skip lines until we find the header
|
||||
foundHeader := false
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "==== Writable regions for") {
|
||||
foundHeader = true
|
||||
// Skip the column headers line
|
||||
scanner.Scan()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundHeader {
|
||||
return nil, nil // No vmmap data found
|
||||
}
|
||||
|
||||
// Regular expression to parse the vmmap output lines
|
||||
// Format: REGION TYPE START - END [ VSIZE RSDNT DIRTY SWAP] PRT/MAX SHRMOD PURGE REGION DETAIL
|
||||
// Updated regex to capture RSDNT value (second value in brackets)
|
||||
re := regexp.MustCompile(`^(\S+)\s+([0-9a-f]+)-([0-9a-f]+)\s+\[\s*(\S+)\s+(\S+)(?:\s+\S+){2}\]\s+(\S+)\s+(\S+)(?:\s+\S+)?\s+(.*)$`)
|
||||
|
||||
// Parse each line
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
matches := re.FindStringSubmatch(line)
|
||||
if len(matches) >= 9 { // Updated to check for at least 9 matches
|
||||
|
||||
// Parse start and end addresses
|
||||
start, _ := strconv.ParseUint(matches[2], 16, 64)
|
||||
end, _ := strconv.ParseUint(matches[3], 16, 64)
|
||||
|
||||
// Parse VSize as numeric value
|
||||
vsize := parseSize(matches[4])
|
||||
|
||||
// Parse RSDNT as numeric value (new)
|
||||
rsdnt := parseSize(matches[5])
|
||||
|
||||
region := MemRegion{
|
||||
RegionType: strings.TrimSpace(matches[1]),
|
||||
Start: start,
|
||||
End: end,
|
||||
VSize: vsize,
|
||||
RSDNT: rsdnt, // Add the new RSDNT field
|
||||
Permissions: matches[6], // Shifted index
|
||||
SHRMOD: matches[7], // Shifted index
|
||||
RegionDetail: strings.TrimSpace(matches[8]), // Shifted index
|
||||
}
|
||||
|
||||
regions = append(regions, region)
|
||||
}
|
||||
}
|
||||
|
||||
return regions, nil
|
||||
}
|
||||
|
||||
func MemRegionsFilter(regions []MemRegion) []MemRegion {
|
||||
var filteredRegions []MemRegion
|
||||
for _, region := range regions {
|
||||
if region.RegionType == FilterRegionType {
|
||||
filteredRegions = append(filteredRegions, region)
|
||||
}
|
||||
}
|
||||
return filteredRegions
|
||||
}
|
||||
|
||||
// parseSize converts size strings like "5616K" or "128.0M" to bytes (uint64)
|
||||
func parseSize(sizeStr string) uint64 {
|
||||
// Remove any whitespace
|
||||
sizeStr = strings.TrimSpace(sizeStr)
|
||||
|
||||
// Define multipliers for different units
|
||||
multipliers := map[string]uint64{
|
||||
"B": 1,
|
||||
"K": 1024,
|
||||
"KB": 1024,
|
||||
"M": 1024 * 1024,
|
||||
"MB": 1024 * 1024,
|
||||
"G": 1024 * 1024 * 1024,
|
||||
"GB": 1024 * 1024 * 1024,
|
||||
}
|
||||
|
||||
// Regular expression to match numbers with optional decimal point and unit
|
||||
// This will match formats like: "5616K", "128.0M", "1.5G", etc.
|
||||
re := regexp.MustCompile(`^(\d+(?:\.\d+)?)([KMGB]+)?$`)
|
||||
matches := re.FindStringSubmatch(sizeStr)
|
||||
|
||||
if len(matches) < 2 {
|
||||
return 0 // No match found
|
||||
}
|
||||
|
||||
// Parse the numeric part (which may include a decimal point)
|
||||
numStr := matches[1]
|
||||
numVal, err := strconv.ParseFloat(numStr, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Determine the multiplier based on the unit
|
||||
multiplier := uint64(1) // Default if no unit specified
|
||||
if len(matches) >= 3 && matches[2] != "" {
|
||||
unit := matches[2]
|
||||
if m, ok := multipliers[unit]; ok {
|
||||
multiplier = m
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate final size in bytes (rounding to nearest integer)
|
||||
return uint64(numVal*float64(multiplier) + 0.5)
|
||||
}
|
||||
187
internal/wechat/key/darwin/v3.go
Normal file
187
internal/wechat/key/darwin/v3.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package darwin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"sync"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/wechat/decrypt"
|
||||
"github.com/sjzar/chatlog/internal/wechat/key/darwin/glance"
|
||||
"github.com/sjzar/chatlog/internal/wechat/model"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxWorkersV3 = 8
|
||||
)
|
||||
|
||||
type V3Extractor struct {
|
||||
validator *decrypt.Validator
|
||||
}
|
||||
|
||||
func NewV3Extractor() *V3Extractor {
|
||||
return &V3Extractor{}
|
||||
}
|
||||
|
||||
func (e *V3Extractor) Extract(ctx context.Context, proc *model.Process) (string, error) {
|
||||
if proc.Status == model.StatusOffline {
|
||||
return "", fmt.Errorf("WeChat is offline")
|
||||
}
|
||||
|
||||
// Check if SIP is disabled, as it's required for memory reading on macOS
|
||||
if !glance.IsSIPDisabled() {
|
||||
return "", fmt.Errorf("System Integrity Protection (SIP) is enabled, cannot read process memory")
|
||||
}
|
||||
|
||||
if e.validator == nil {
|
||||
return "", fmt.Errorf("validator not set")
|
||||
}
|
||||
|
||||
// Create context to control all goroutines
|
||||
searchCtx, cancel := context.WithCancel(ctx)
|
||||
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 > MaxWorkersV3 {
|
||||
workerCount = MaxWorkersV3
|
||||
}
|
||||
logrus.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()
|
||||
e.worker(searchCtx, 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 := e.findMemory(searchCtx, uint32(proc.PID), memoryChannel)
|
||||
if err != nil {
|
||||
logrus.Error(err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for producer and consumers to complete
|
||||
go func() {
|
||||
producerWaitGroup.Wait()
|
||||
workerWaitGroup.Wait()
|
||||
close(resultChannel)
|
||||
}()
|
||||
|
||||
// Wait for result
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case result, ok := <-resultChannel:
|
||||
if ok && result != "" {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no valid key found")
|
||||
}
|
||||
|
||||
// findMemory searches for memory regions using Glance
|
||||
func (e *V3Extractor) findMemory(ctx context.Context, pid uint32, memoryChannel chan<- []byte) error {
|
||||
// Initialize a Glance instance to read process memory
|
||||
g := glance.NewGlance(pid)
|
||||
|
||||
// Read memory data
|
||||
memory, err := g.Read()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read process memory: %w", err)
|
||||
}
|
||||
|
||||
logrus.Debug("Read memory region, size: ", len(memory), " bytes")
|
||||
|
||||
// Send memory data to channel for processing
|
||||
select {
|
||||
case memoryChannel <- memory:
|
||||
logrus.Debug("Sent memory region for analysis")
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// worker processes memory regions to find V3 version key
|
||||
func (e *V3Extractor) worker(ctx context.Context, memoryChannel <-chan []byte, resultChannel chan<- string) {
|
||||
keyPattern := []byte{0x72, 0x74, 0x72, 0x65, 0x65, 0x5f, 0x69, 0x33, 0x32}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case memory, ok := <-memoryChannel:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
index := len(memory)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return // Exit if context cancelled
|
||||
default:
|
||||
}
|
||||
|
||||
logrus.Debugf("Searching for V3 key in memory region, size: %d bytes", len(memory))
|
||||
|
||||
// Find pattern from end to beginning
|
||||
index = bytes.LastIndex(memory[:index], keyPattern)
|
||||
if index == -1 {
|
||||
break // No more matches found
|
||||
}
|
||||
|
||||
logrus.Debugf("Found potential V3 key pattern in memory region, index: %d", index)
|
||||
|
||||
// For V3, the key is 32 bytes and starts right after the pattern
|
||||
if index+24+32 > len(memory) {
|
||||
index -= 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract the key data, which is right after the pattern and 32 bytes long
|
||||
keyOffset := index + 24
|
||||
keyData := memory[keyOffset : keyOffset+32]
|
||||
|
||||
// Validate key against database header
|
||||
if e.validator.Validate(keyData) {
|
||||
select {
|
||||
case resultChannel <- hex.EncodeToString(keyData):
|
||||
logrus.Debug("Valid key found for V3 database")
|
||||
return
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
index -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *V3Extractor) SetValidate(validator *decrypt.Validator) {
|
||||
e.validator = validator
|
||||
}
|
||||
184
internal/wechat/key/darwin/v4.go
Normal file
184
internal/wechat/key/darwin/v4.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package darwin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"sync"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/wechat/decrypt"
|
||||
"github.com/sjzar/chatlog/internal/wechat/key/darwin/glance"
|
||||
"github.com/sjzar/chatlog/internal/wechat/model"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxWorkers = 8
|
||||
)
|
||||
|
||||
type V4Extractor struct {
|
||||
validator *decrypt.Validator
|
||||
}
|
||||
|
||||
func NewV4Extractor() *V4Extractor {
|
||||
return &V4Extractor{}
|
||||
}
|
||||
|
||||
func (e *V4Extractor) Extract(ctx context.Context, proc *model.Process) (string, error) {
|
||||
if proc.Status == model.StatusOffline {
|
||||
return "", fmt.Errorf("WeChat is offline")
|
||||
}
|
||||
|
||||
// Check if SIP is disabled, as it's required for memory reading on macOS
|
||||
if !glance.IsSIPDisabled() {
|
||||
return "", fmt.Errorf("System Integrity Protection (SIP) is enabled, cannot read process memory")
|
||||
}
|
||||
|
||||
if e.validator == nil {
|
||||
return "", fmt.Errorf("validator not set")
|
||||
}
|
||||
|
||||
// Create context to control all goroutines
|
||||
searchCtx, cancel := context.WithCancel(ctx)
|
||||
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
|
||||
}
|
||||
logrus.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()
|
||||
e.worker(searchCtx, 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 := e.findMemory(searchCtx, uint32(proc.PID), memoryChannel)
|
||||
if err != nil {
|
||||
logrus.Error(err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for producer and consumers to complete
|
||||
go func() {
|
||||
producerWaitGroup.Wait()
|
||||
workerWaitGroup.Wait()
|
||||
close(resultChannel)
|
||||
}()
|
||||
|
||||
// Wait for result
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case result, ok := <-resultChannel:
|
||||
if ok && result != "" {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no valid key found")
|
||||
}
|
||||
|
||||
// findMemory searches for memory regions using Glance
|
||||
func (e *V4Extractor) findMemory(ctx context.Context, pid uint32, memoryChannel chan<- []byte) error {
|
||||
// Initialize a Glance instance to read process memory
|
||||
g := glance.NewGlance(pid)
|
||||
|
||||
// Read memory data
|
||||
memory, err := g.Read()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read process memory: %w", err)
|
||||
}
|
||||
|
||||
logrus.Debug("Read memory region, size: ", len(memory), " bytes")
|
||||
|
||||
// Send memory data to channel for processing
|
||||
select {
|
||||
case memoryChannel <- memory:
|
||||
logrus.Debug("Sent memory region for analysis")
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// worker processes memory regions to find V4 version key
|
||||
func (e *V4Extractor) worker(ctx context.Context, memoryChannel <-chan []byte, resultChannel chan<- string) {
|
||||
keyPattern := []byte{0x20, 0x66, 0x74, 0x73, 0x35, 0x28, 0x25, 0x00}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case memory, ok := <-memoryChannel:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
index := len(memory)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return // Exit if context cancelled
|
||||
default:
|
||||
}
|
||||
|
||||
// Find pattern from end to beginning
|
||||
index = bytes.LastIndex(memory[:index], keyPattern)
|
||||
if index == -1 {
|
||||
break // No more matches found
|
||||
}
|
||||
|
||||
// Check if we have enough space for the key
|
||||
if index+16+32 > len(memory) {
|
||||
index -= 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract the key data, which is 16 bytes after the pattern and 32 bytes long
|
||||
keyOffset := index + 16
|
||||
keyData := memory[keyOffset : keyOffset+32]
|
||||
|
||||
// Validate key against database header
|
||||
if e.validator.Validate(keyData) {
|
||||
select {
|
||||
case resultChannel <- hex.EncodeToString(keyData):
|
||||
logrus.Debug("Valid key found for V4 database")
|
||||
return
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
index -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *V4Extractor) SetValidate(validator *decrypt.Validator) {
|
||||
e.validator = validator
|
||||
}
|
||||
41
internal/wechat/key/extractor.go
Normal file
41
internal/wechat/key/extractor.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package key
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/wechat/decrypt"
|
||||
"github.com/sjzar/chatlog/internal/wechat/key/darwin"
|
||||
"github.com/sjzar/chatlog/internal/wechat/key/windows"
|
||||
"github.com/sjzar/chatlog/internal/wechat/model"
|
||||
)
|
||||
|
||||
// 错误定义
|
||||
var (
|
||||
ErrInvalidVersion = fmt.Errorf("invalid version, must be 3 or 4")
|
||||
ErrUnsupportedPlatform = fmt.Errorf("unsupported platform")
|
||||
)
|
||||
|
||||
// Extractor 定义密钥提取器接口
|
||||
type Extractor interface {
|
||||
// Extract 从进程中提取密钥
|
||||
Extract(ctx context.Context, proc *model.Process) (string, error)
|
||||
|
||||
SetValidate(validator *decrypt.Validator)
|
||||
}
|
||||
|
||||
// NewExtractor 创建适合当前平台的密钥提取器
|
||||
func NewExtractor(platform string, version int) (Extractor, error) {
|
||||
switch {
|
||||
case platform == "windows" && version == 3:
|
||||
return windows.NewV3Extractor(), nil
|
||||
case platform == "windows" && version == 4:
|
||||
return windows.NewV4Extractor(), nil
|
||||
case platform == "darwin" && version == 3:
|
||||
return darwin.NewV3Extractor(), nil
|
||||
case platform == "darwin" && version == 4:
|
||||
return darwin.NewV4Extractor(), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %s v%d", ErrUnsupportedPlatform, platform, version)
|
||||
}
|
||||
}
|
||||
28
internal/wechat/key/windows/v3.go
Normal file
28
internal/wechat/key/windows/v3.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/wechat/decrypt"
|
||||
)
|
||||
|
||||
// Common error definitions
|
||||
var (
|
||||
ErrWeChatOffline = errors.New("wechat is not logged in")
|
||||
ErrOpenProcess = errors.New("failed to open process")
|
||||
ErrCheckProcessBits = errors.New("failed to check process architecture")
|
||||
ErrFindWeChatDLL = errors.New("WeChatWin.dll module not found")
|
||||
ErrNoValidKey = errors.New("no valid key found")
|
||||
)
|
||||
|
||||
type V3Extractor struct {
|
||||
validator *decrypt.Validator
|
||||
}
|
||||
|
||||
func NewV3Extractor() *V3Extractor {
|
||||
return &V3Extractor{}
|
||||
}
|
||||
|
||||
func (e *V3Extractor) SetValidate(validator *decrypt.Validator) {
|
||||
e.validator = validator
|
||||
}
|
||||
13
internal/wechat/key/windows/v3_others.go
Normal file
13
internal/wechat/key/windows/v3_others.go
Normal file
@@ -0,0 +1,13 @@
|
||||
//go:build !windows
|
||||
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/wechat/model"
|
||||
)
|
||||
|
||||
func (e *V3Extractor) Extract(ctx context.Context, proc *model.Process) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
254
internal/wechat/key/windows/v3_windows.go
Normal file
254
internal/wechat/key/windows/v3_windows.go
Normal file
@@ -0,0 +1,254 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/wechat/model"
|
||||
"github.com/sjzar/chatlog/pkg/util"
|
||||
)
|
||||
|
||||
const (
|
||||
V3ModuleName = "WeChatWin.dll"
|
||||
MaxWorkers = 16
|
||||
)
|
||||
|
||||
func (e *V3Extractor) Extract(ctx context.Context, proc *model.Process) (string, error) {
|
||||
if proc.Status == model.StatusOffline {
|
||||
return "", ErrWeChatOffline
|
||||
}
|
||||
|
||||
// Open WeChat process
|
||||
handle, err := windows.OpenProcess(windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ, false, proc.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
|
||||
searchCtx, cancel := context.WithCancel(ctx)
|
||||
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
|
||||
}
|
||||
logrus.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()
|
||||
e.worker(searchCtx, handle, 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 := e.findMemory(searchCtx, handle, proc.PID, memoryChannel)
|
||||
if err != nil {
|
||||
logrus.Error(err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for producer and consumers to complete
|
||||
go func() {
|
||||
producerWaitGroup.Wait()
|
||||
workerWaitGroup.Wait()
|
||||
close(resultChannel)
|
||||
}()
|
||||
|
||||
// Wait for result
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case result, ok := <-resultChannel:
|
||||
if ok && result != "" {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", ErrNoValidKey
|
||||
}
|
||||
|
||||
// findMemoryV3 searches for writable memory regions in WeChatWin.dll for V3 version
|
||||
func (e *V3Extractor) findMemory(ctx context.Context, handle windows.Handle, pid uint32, memoryChannel chan<- []byte) error {
|
||||
// Find WeChatWin.dll module
|
||||
module, isFound := FindModule(pid, V3ModuleName)
|
||||
if !isFound {
|
||||
return ErrFindWeChatDLL
|
||||
}
|
||||
logrus.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:
|
||||
logrus.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 (e *V3Extractor) worker(ctx context.Context, handle windows.Handle, 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 context cancelled
|
||||
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 := e.validateKey(handle, ptrValue); key != "" {
|
||||
select {
|
||||
case resultChannel <- key:
|
||||
logrus.Debug("Valid key found for V3 database")
|
||||
return
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
index -= 1 // Continue searching from previous position
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validateKey validates a single key candidate
|
||||
func (e *V3Extractor) validateKey(handle windows.Handle, 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 e.validator.Validate(keyData) {
|
||||
return hex.EncodeToString(keyData)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// FindModule searches for a specified module in the process
|
||||
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 {
|
||||
logrus.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 {
|
||||
logrus.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
|
||||
}
|
||||
17
internal/wechat/key/windows/v4.go
Normal file
17
internal/wechat/key/windows/v4.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"github.com/sjzar/chatlog/internal/wechat/decrypt"
|
||||
)
|
||||
|
||||
type V4Extractor struct {
|
||||
validator *decrypt.Validator
|
||||
}
|
||||
|
||||
func NewV4Extractor() *V4Extractor {
|
||||
return &V4Extractor{}
|
||||
}
|
||||
|
||||
func (e *V4Extractor) SetValidate(validator *decrypt.Validator) {
|
||||
e.validator = validator
|
||||
}
|
||||
13
internal/wechat/key/windows/v4_others.go
Normal file
13
internal/wechat/key/windows/v4_others.go
Normal file
@@ -0,0 +1,13 @@
|
||||
//go:build !windows
|
||||
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/wechat/model"
|
||||
)
|
||||
|
||||
func (e *V4Extractor) Extract(ctx context.Context, proc *model.Process) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
213
internal/wechat/key/windows/v4_windows.go
Normal file
213
internal/wechat/key/windows/v4_windows.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/wechat/model"
|
||||
)
|
||||
|
||||
const (
|
||||
MEM_PRIVATE = 0x20000
|
||||
)
|
||||
|
||||
func (e *V4Extractor) Extract(ctx context.Context, proc *model.Process) (string, error) {
|
||||
if proc.Status == model.StatusOffline {
|
||||
return "", ErrWeChatOffline
|
||||
}
|
||||
|
||||
// Open process handle
|
||||
handle, err := windows.OpenProcess(windows.PROCESS_VM_READ|windows.PROCESS_QUERY_INFORMATION, false, proc.PID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrOpenProcess, err)
|
||||
}
|
||||
defer windows.CloseHandle(handle)
|
||||
|
||||
// Create context to control all goroutines
|
||||
searchCtx, cancel := context.WithCancel(ctx)
|
||||
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
|
||||
}
|
||||
logrus.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()
|
||||
e.worker(searchCtx, handle, 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 := e.findMemory(searchCtx, handle, memoryChannel)
|
||||
if err != nil {
|
||||
logrus.Error(err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for producer and consumers to complete
|
||||
go func() {
|
||||
producerWaitGroup.Wait()
|
||||
workerWaitGroup.Wait()
|
||||
close(resultChannel)
|
||||
}()
|
||||
|
||||
// Wait for result
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case result, ok := <-resultChannel:
|
||||
if ok && result != "" {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", ErrNoValidKey
|
||||
}
|
||||
|
||||
// findMemoryV4 searches for writable memory regions for V4 version
|
||||
func (e *V4Extractor) findMemory(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
|
||||
}
|
||||
logrus.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:
|
||||
logrus.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 (e *V4Extractor) worker(ctx context.Context, handle windows.Handle, 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 context cancelled
|
||||
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 := e.validateKey(handle, ptrValue); key != "" {
|
||||
select {
|
||||
case resultChannel <- key:
|
||||
logrus.Debug("Valid key found for V4 database")
|
||||
return
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
index -= 1 // Continue searching from previous position
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validateKey validates a single key candidate
|
||||
func (e *V4Extractor) validateKey(handle windows.Handle, 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 e.validator.Validate(keyData) {
|
||||
return hex.EncodeToString(keyData)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -1,57 +1,110 @@
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"github.com/shirou/gopsutil/v4/process"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/sjzar/chatlog/internal/wechat/model"
|
||||
"github.com/sjzar/chatlog/internal/wechat/process"
|
||||
)
|
||||
|
||||
const (
|
||||
V3ProcessName = "WeChat"
|
||||
V4ProcessName = "Weixin"
|
||||
)
|
||||
var DefaultManager *Manager
|
||||
|
||||
var (
|
||||
Items []*Info
|
||||
ItemMap map[string]*Info
|
||||
)
|
||||
func init() {
|
||||
DefaultManager = NewManager()
|
||||
DefaultManager.Load()
|
||||
}
|
||||
|
||||
func Load() {
|
||||
Items = make([]*Info, 0, 2)
|
||||
ItemMap = make(map[string]*Info)
|
||||
func Load() error {
|
||||
return DefaultManager.Load()
|
||||
}
|
||||
|
||||
processes, err := process.Processes()
|
||||
if err != nil {
|
||||
log.Println("获取进程列表失败:", err)
|
||||
return
|
||||
}
|
||||
func GetAccount(name string) (*Account, error) {
|
||||
return DefaultManager.GetAccount(name)
|
||||
}
|
||||
|
||||
for _, p := range processes {
|
||||
name, err := p.Name()
|
||||
name = strings.TrimSuffix(name, ".exe")
|
||||
if err != nil || name != V3ProcessName && name != V4ProcessName {
|
||||
continue
|
||||
}
|
||||
func GetProcess(name string) (*model.Process, error) {
|
||||
return DefaultManager.GetProcess(name)
|
||||
}
|
||||
|
||||
// v4 存在同名进程,需要继续判断 cmdline
|
||||
if name == V4ProcessName {
|
||||
cmdline, err := p.Cmdline()
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
continue
|
||||
}
|
||||
if strings.Contains(cmdline, "--") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
func GetAccounts() []*Account {
|
||||
return DefaultManager.GetAccounts()
|
||||
}
|
||||
|
||||
info, err := NewInfo(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// Manager 微信管理器
|
||||
type Manager struct {
|
||||
detector process.Detector
|
||||
accounts []*Account
|
||||
processMap map[string]*model.Process
|
||||
}
|
||||
|
||||
Items = append(Items, info)
|
||||
ItemMap[info.AccountName] = info
|
||||
// NewManager 创建新的微信管理器
|
||||
func NewManager() *Manager {
|
||||
return &Manager{
|
||||
detector: process.NewDetector(runtime.GOOS),
|
||||
accounts: make([]*Account, 0),
|
||||
processMap: make(map[string]*model.Process),
|
||||
}
|
||||
}
|
||||
|
||||
// Load 加载微信进程信息
|
||||
func (m *Manager) Load() error {
|
||||
// 查找微信进程
|
||||
processes, err := m.detector.FindProcesses()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 转换为账号信息
|
||||
accounts := make([]*Account, 0, len(processes))
|
||||
processMap := make(map[string]*model.Process, len(processes))
|
||||
|
||||
for _, p := range processes {
|
||||
account := NewAccount(p)
|
||||
|
||||
accounts = append(accounts, account)
|
||||
if account.Name != "" {
|
||||
processMap[account.Name] = p
|
||||
}
|
||||
}
|
||||
|
||||
m.accounts = accounts
|
||||
m.processMap = processMap
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAccount 获取指定名称的账号
|
||||
func (m *Manager) GetAccount(name string) (*Account, error) {
|
||||
p, err := m.GetProcess(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewAccount(p), nil
|
||||
}
|
||||
|
||||
func (m *Manager) GetProcess(name string) (*model.Process, error) {
|
||||
p, ok := m.processMap[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("account not found: %s", name)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// GetAccounts 获取所有账号
|
||||
func (m *Manager) GetAccounts() []*Account {
|
||||
return m.accounts
|
||||
}
|
||||
|
||||
// DecryptDatabase 便捷方法:通过账号名解密数据库
|
||||
func (m *Manager) DecryptDatabase(ctx context.Context, accountName, dbPath, outputPath string) error {
|
||||
// 获取账号
|
||||
account, err := m.GetAccount(accountName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 使用账号解密数据库
|
||||
return account.DecryptDatabase(ctx, dbPath, outputPath)
|
||||
}
|
||||
|
||||
24
internal/wechat/model/process.go
Normal file
24
internal/wechat/model/process.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package model
|
||||
|
||||
type Process struct {
|
||||
PID uint32
|
||||
ExePath string
|
||||
Platform string
|
||||
Version int
|
||||
FullVersion string
|
||||
Status string
|
||||
DataDir string
|
||||
AccountName string
|
||||
}
|
||||
|
||||
// 平台常量定义
|
||||
const (
|
||||
PlatformWindows = "windows"
|
||||
PlatformMacOS = "darwin"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusInit = ""
|
||||
StatusOffline = "offline"
|
||||
StatusOnline = "online"
|
||||
)
|
||||
164
internal/wechat/process/darwin/detector.go
Normal file
164
internal/wechat/process/darwin/detector.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package darwin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/shirou/gopsutil/v4/process"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/wechat/model"
|
||||
"github.com/sjzar/chatlog/pkg/appver"
|
||||
)
|
||||
|
||||
const (
|
||||
V3ProcessName = "WeChat"
|
||||
V4ProcessName = "Weixin"
|
||||
V3DBFile = "Message/msg_0.db"
|
||||
V4DBFile = "db_storage/message/message_0.db"
|
||||
)
|
||||
|
||||
// Detector 实现 macOS 平台的进程检测器
|
||||
type Detector struct{}
|
||||
|
||||
// NewDetector 创建一个新的 macOS 检测器
|
||||
func NewDetector() *Detector {
|
||||
return &Detector{}
|
||||
}
|
||||
|
||||
// FindProcesses 查找所有微信进程并返回它们的信息
|
||||
func (d *Detector) FindProcesses() ([]*model.Process, error) {
|
||||
processes, err := process.Processes()
|
||||
if err != nil {
|
||||
log.Errorf("获取进程列表失败: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []*model.Process
|
||||
for _, p := range processes {
|
||||
name, err := p.Name()
|
||||
if err != nil || (name != V3ProcessName && name != V4ProcessName) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 获取进程信息
|
||||
procInfo, err := d.getProcessInfo(p)
|
||||
if err != nil {
|
||||
log.Errorf("获取进程 %d 的信息失败: %v", p.Pid, err)
|
||||
continue
|
||||
}
|
||||
|
||||
result = append(result, procInfo)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// getProcessInfo 获取微信进程的详细信息
|
||||
func (d *Detector) getProcessInfo(p *process.Process) (*model.Process, error) {
|
||||
procInfo := &model.Process{
|
||||
PID: uint32(p.Pid),
|
||||
Status: model.StatusOffline,
|
||||
Platform: model.PlatformMacOS,
|
||||
}
|
||||
|
||||
// 获取可执行文件路径
|
||||
exePath, err := p.Exe()
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
return nil, err
|
||||
}
|
||||
procInfo.ExePath = exePath
|
||||
|
||||
// 获取版本信息
|
||||
// 注意:macOS 的版本获取方式可能与 Windows 不同
|
||||
versionInfo, err := appver.New(exePath)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
procInfo.Version = 3
|
||||
procInfo.FullVersion = "3.0.0"
|
||||
} else {
|
||||
procInfo.Version = versionInfo.Version
|
||||
procInfo.FullVersion = versionInfo.FullVersion
|
||||
}
|
||||
|
||||
// 初始化附加信息(数据目录、账户名)
|
||||
if err := d.initializeProcessInfo(p, procInfo); err != nil {
|
||||
log.Errorf("初始化进程信息失败: %v", err)
|
||||
// 即使初始化失败也返回部分信息
|
||||
}
|
||||
|
||||
return procInfo, nil
|
||||
}
|
||||
|
||||
// initializeProcessInfo 获取进程的数据目录和账户名
|
||||
func (d *Detector) initializeProcessInfo(p *process.Process, info *model.Process) error {
|
||||
// 使用 lsof 命令获取进程打开的文件
|
||||
files, err := d.getOpenFiles(int(p.Pid))
|
||||
if err != nil {
|
||||
log.Error("获取打开文件列表失败: ", err)
|
||||
return err
|
||||
}
|
||||
|
||||
dbPath := V3DBFile
|
||||
if info.Version == 4 {
|
||||
dbPath = V4DBFile
|
||||
}
|
||||
|
||||
for _, filePath := range files {
|
||||
if strings.Contains(filePath, dbPath) {
|
||||
parts := strings.Split(filePath, string(filepath.Separator))
|
||||
if len(parts) < 4 {
|
||||
log.Debug("无效的文件路径格式: " + filePath)
|
||||
continue
|
||||
}
|
||||
|
||||
// v3:
|
||||
// /Users/sarv/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/2.0b4.0.9/<id>/Message/msg_0.db
|
||||
// v4:
|
||||
// /Users/sarv/Library/Containers/com.tencent.xWeChat/Data/Documents/xwechat_files/<id>/db_storage/message/message_0.db
|
||||
|
||||
info.Status = model.StatusOnline
|
||||
if info.Version == 4 {
|
||||
info.DataDir = strings.Join(parts[:len(parts)-3], string(filepath.Separator))
|
||||
info.AccountName = parts[len(parts)-4]
|
||||
} else {
|
||||
info.DataDir = strings.Join(parts[:len(parts)-2], string(filepath.Separator))
|
||||
info.AccountName = parts[len(parts)-3]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getOpenFiles 使用 lsof 命令获取进程打开的文件列表
|
||||
func (d *Detector) getOpenFiles(pid int) ([]string, error) {
|
||||
// 执行 lsof -p <pid> 命令,使用 -F n 选项只输出文件名
|
||||
cmd := exec.Command("lsof", "-p", strconv.Itoa(pid), "-F", "n")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("执行 lsof 命令失败: %v", err)
|
||||
}
|
||||
|
||||
// 解析 lsof -F n 输出
|
||||
// 格式为: n/path/to/file
|
||||
lines := strings.Split(string(output), "\n")
|
||||
var files []string
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "n") {
|
||||
// 移除前缀 'n' 获取文件路径
|
||||
filePath := line[1:]
|
||||
if filePath != "" {
|
||||
files = append(files, filePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files, nil
|
||||
}
|
||||
36
internal/wechat/process/detector.go
Normal file
36
internal/wechat/process/detector.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"github.com/sjzar/chatlog/internal/wechat/model"
|
||||
"github.com/sjzar/chatlog/internal/wechat/process/darwin"
|
||||
"github.com/sjzar/chatlog/internal/wechat/process/windows"
|
||||
)
|
||||
|
||||
type Detector interface {
|
||||
FindProcesses() ([]*model.Process, error)
|
||||
}
|
||||
|
||||
// NewDetector 创建适合当前平台的检测器
|
||||
func NewDetector(platform string) Detector {
|
||||
// 根据平台返回对应的实现
|
||||
switch platform {
|
||||
case "windows":
|
||||
return windows.NewDetector()
|
||||
case "darwin":
|
||||
return darwin.NewDetector()
|
||||
default:
|
||||
// 默认返回一个空实现
|
||||
return &nullDetector{}
|
||||
}
|
||||
}
|
||||
|
||||
// nullDetector 空实现
|
||||
type nullDetector struct{}
|
||||
|
||||
func (d *nullDetector) FindProcesses() ([]*model.Process, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (d *nullDetector) GetProcessInfo(pid uint32) (*model.Process, error) {
|
||||
return nil, nil
|
||||
}
|
||||
101
internal/wechat/process/windows/detector.go
Normal file
101
internal/wechat/process/windows/detector.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/shirou/gopsutil/v4/process"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/wechat/model"
|
||||
"github.com/sjzar/chatlog/pkg/appver"
|
||||
)
|
||||
|
||||
const (
|
||||
V3ProcessName = "WeChat"
|
||||
V4ProcessName = "Weixin"
|
||||
V3DBFile = "Msg\\Misc.db"
|
||||
V4DBFile = "db_storage\\message\\message_0.db"
|
||||
)
|
||||
|
||||
// Detector 实现 Windows 平台的进程检测器
|
||||
type Detector struct{}
|
||||
|
||||
// NewDetector 创建一个新的 Windows 检测器
|
||||
func NewDetector() *Detector {
|
||||
return &Detector{}
|
||||
}
|
||||
|
||||
// FindProcesses 查找所有微信进程并返回它们的信息
|
||||
func (d *Detector) FindProcesses() ([]*model.Process, error) {
|
||||
processes, err := process.Processes()
|
||||
if err != nil {
|
||||
log.Errorf("获取进程列表失败: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []*model.Process
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 获取进程信息
|
||||
procInfo, err := d.getProcessInfo(p)
|
||||
if err != nil {
|
||||
log.Errorf("获取进程 %d 的信息失败: %v", p.Pid, err)
|
||||
continue
|
||||
}
|
||||
|
||||
result = append(result, procInfo)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// getProcessInfo 获取微信进程的详细信息
|
||||
func (d *Detector) getProcessInfo(p *process.Process) (*model.Process, error) {
|
||||
procInfo := &model.Process{
|
||||
PID: uint32(p.Pid),
|
||||
Status: model.StatusOffline,
|
||||
Platform: model.PlatformWindows,
|
||||
}
|
||||
|
||||
// 获取可执行文件路径
|
||||
exePath, err := p.Exe()
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
return nil, err
|
||||
}
|
||||
procInfo.ExePath = exePath
|
||||
|
||||
// 获取版本信息
|
||||
versionInfo, err := appver.New(exePath)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
return nil, err
|
||||
}
|
||||
procInfo.Version = versionInfo.Version
|
||||
procInfo.FullVersion = versionInfo.FullVersion
|
||||
|
||||
// 初始化附加信息(数据目录、账户名)
|
||||
if err := initializeProcessInfo(p, procInfo); err != nil {
|
||||
log.Errorf("初始化进程信息失败: %v", err)
|
||||
// 即使初始化失败也返回部分信息
|
||||
}
|
||||
|
||||
return procInfo, nil
|
||||
}
|
||||
12
internal/wechat/process/windows/detector_others.go
Normal file
12
internal/wechat/process/windows/detector_others.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build !windows
|
||||
|
||||
package windows
|
||||
|
||||
import (
|
||||
"github.com/shirou/gopsutil/v4/process"
|
||||
"github.com/sjzar/chatlog/internal/wechat/model"
|
||||
)
|
||||
|
||||
func initializeProcessInfo(p *process.Process, info *model.Process) error {
|
||||
return nil
|
||||
}
|
||||
48
internal/wechat/process/windows/detector_windows.go
Normal file
48
internal/wechat/process/windows/detector_windows.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/shirou/gopsutil/v4/process"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/wechat/model"
|
||||
)
|
||||
|
||||
// initializeProcessInfo 获取进程的数据目录和账户名
|
||||
func initializeProcessInfo(p *process.Process, info *model.Process) error {
|
||||
files, err := p.OpenFiles()
|
||||
if err != nil {
|
||||
log.Error("获取打开文件列表失败: ", err)
|
||||
return err
|
||||
}
|
||||
|
||||
dbPath := V3DBFile
|
||||
if info.Version == 4 {
|
||||
dbPath = V4DBFile
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
if strings.HasSuffix(f.Path, dbPath) {
|
||||
filePath := f.Path[4:] // 移除 "\\?\" 前缀
|
||||
parts := strings.Split(filePath, string(filepath.Separator))
|
||||
if len(parts) < 4 {
|
||||
log.Debug("无效的文件路径格式: " + filePath)
|
||||
continue
|
||||
}
|
||||
|
||||
info.Status = model.StatusOnline
|
||||
if info.Version == 4 {
|
||||
info.DataDir = strings.Join(parts[:len(parts)-3], string(filepath.Separator))
|
||||
info.AccountName = parts[len(parts)-4]
|
||||
} else {
|
||||
info.DataDir = strings.Join(parts[:len(parts)-2], string(filepath.Separator))
|
||||
info.AccountName = parts[len(parts)-3]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
134
internal/wechat/wechat.go
Normal file
134
internal/wechat/wechat.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/wechat/decrypt"
|
||||
"github.com/sjzar/chatlog/internal/wechat/key"
|
||||
"github.com/sjzar/chatlog/internal/wechat/model"
|
||||
)
|
||||
|
||||
// Account 表示一个微信账号
|
||||
type Account struct {
|
||||
Name string
|
||||
Platform string
|
||||
Version int
|
||||
FullVersion string
|
||||
DataDir string
|
||||
Key string
|
||||
PID uint32
|
||||
ExePath string
|
||||
Status string
|
||||
}
|
||||
|
||||
// NewAccount 创建新的账号对象
|
||||
func NewAccount(proc *model.Process) *Account {
|
||||
return &Account{
|
||||
Name: proc.AccountName,
|
||||
Platform: proc.Platform,
|
||||
Version: proc.Version,
|
||||
FullVersion: proc.FullVersion,
|
||||
DataDir: proc.DataDir,
|
||||
PID: proc.PID,
|
||||
ExePath: proc.ExePath,
|
||||
Status: proc.Status,
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshStatus 刷新账号的进程状态
|
||||
func (a *Account) RefreshStatus() error {
|
||||
// 查找所有微信进程
|
||||
Load()
|
||||
|
||||
process, err := GetProcess(a.Name)
|
||||
if err != nil {
|
||||
a.Status = model.StatusOffline
|
||||
return nil
|
||||
}
|
||||
|
||||
if process.AccountName == a.Name {
|
||||
// 更新进程信息
|
||||
a.PID = process.PID
|
||||
a.ExePath = process.ExePath
|
||||
a.Platform = process.Platform
|
||||
a.Version = process.Version
|
||||
a.FullVersion = process.FullVersion
|
||||
a.Status = process.Status
|
||||
a.DataDir = process.DataDir
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetKey 获取账号的密钥
|
||||
func (a *Account) GetKey(ctx context.Context) (string, error) {
|
||||
// 如果已经有密钥,直接返回
|
||||
if a.Key != "" {
|
||||
return a.Key, nil
|
||||
}
|
||||
|
||||
// 刷新进程状态
|
||||
if err := a.RefreshStatus(); err != nil {
|
||||
return "", fmt.Errorf("failed to refresh process status: %w", err)
|
||||
}
|
||||
|
||||
// 检查账号状态
|
||||
if a.Status != model.StatusOnline {
|
||||
return "", fmt.Errorf("account %s is not online", a.Name)
|
||||
}
|
||||
|
||||
// 创建密钥提取器 - 使用新的接口,传入平台和版本信息
|
||||
extractor, err := key.NewExtractor(a.Platform, a.Version)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create key extractor: %w", err)
|
||||
}
|
||||
|
||||
process, err := GetProcess(a.Name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get process: %w", err)
|
||||
}
|
||||
|
||||
validator, err := decrypt.NewValidator(process.DataDir, process.Platform, process.Version)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create validator: %w", err)
|
||||
}
|
||||
|
||||
extractor.SetValidate(validator)
|
||||
|
||||
// 提取密钥
|
||||
key, err := extractor.Extract(ctx, process)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 保存密钥
|
||||
a.Key = key
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// DecryptDatabase 解密数据库
|
||||
func (a *Account) DecryptDatabase(ctx context.Context, dbPath, outputPath string) error {
|
||||
// 获取密钥
|
||||
hexKey, err := a.GetKey(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 创建解密器 - 传入平台信息和版本
|
||||
decryptor, err := decrypt.NewDecryptor(a.Platform, a.Version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 创建输出文件
|
||||
output, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer output.Close()
|
||||
|
||||
// 解密数据库
|
||||
return decryptor.Decrypt(ctx, dbPath, hexKey, output)
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
510
internal/wechatdb/datasource/darwinv3/datasource.go
Normal file
510
internal/wechatdb/datasource/darwinv3/datasource.go
Normal file
@@ -0,0 +1,510 @@
|
||||
package darwinv3
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/model"
|
||||
"github.com/sjzar/chatlog/pkg/util"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
const (
|
||||
MessageFilePattern = "^msg_([0-9]?[0-9])?\\.db$"
|
||||
ContactFilePattern = "^wccontact_new2\\.db$"
|
||||
ChatRoomFilePattern = "^group_new\\.db$"
|
||||
SessionFilePattern = "^session_new\\.db$"
|
||||
)
|
||||
|
||||
type DataSource struct {
|
||||
path string
|
||||
messageDbs []*sql.DB
|
||||
contactDb *sql.DB
|
||||
chatRoomDb *sql.DB
|
||||
sessionDb *sql.DB
|
||||
|
||||
talkerDBMap map[string]*sql.DB
|
||||
user2DisplayName map[string]string
|
||||
}
|
||||
|
||||
func New(path string) (*DataSource, error) {
|
||||
ds := &DataSource{
|
||||
path: path,
|
||||
messageDbs: make([]*sql.DB, 0),
|
||||
talkerDBMap: make(map[string]*sql.DB),
|
||||
user2DisplayName: make(map[string]string),
|
||||
}
|
||||
|
||||
if err := ds.initMessageDbs(path); err != nil {
|
||||
return nil, fmt.Errorf("初始化消息数据库失败: %w", err)
|
||||
}
|
||||
if err := ds.initContactDb(path); err != nil {
|
||||
return nil, fmt.Errorf("初始化联系人数据库失败: %w", err)
|
||||
}
|
||||
if err := ds.initChatRoomDb(path); err != nil {
|
||||
return nil, fmt.Errorf("初始化群聊数据库失败: %w", err)
|
||||
}
|
||||
if err := ds.initSessionDb(path); err != nil {
|
||||
return nil, fmt.Errorf("初始化会话数据库失败: %w", err)
|
||||
}
|
||||
|
||||
return ds, nil
|
||||
}
|
||||
|
||||
func (ds *DataSource) initMessageDbs(path string) error {
|
||||
|
||||
files, err := util.FindFilesWithPatterns(path, MessageFilePattern, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查找消息数据库文件失败: %w", err)
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("未找到任何消息数据库文件: %s", path)
|
||||
}
|
||||
|
||||
// 处理每个数据库文件
|
||||
for _, filePath := range files {
|
||||
// 连接数据库
|
||||
db, err := sql.Open("sqlite3", filePath)
|
||||
if err != nil {
|
||||
log.Printf("警告: 连接数据库 %s 失败: %v", filePath, err)
|
||||
continue
|
||||
}
|
||||
ds.messageDbs = append(ds.messageDbs, db)
|
||||
|
||||
// 获取所有表名
|
||||
rows, err := db.Query("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Chat_%'")
|
||||
if err != nil {
|
||||
log.Printf("警告: 获取表名失败: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var tableName string
|
||||
if err := rows.Scan(&tableName); err != nil {
|
||||
log.Printf("警告: 扫描表名失败: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 从表名中提取可能的talker信息
|
||||
talkerMd5 := extractTalkerFromTableName(tableName)
|
||||
if talkerMd5 == "" {
|
||||
continue
|
||||
}
|
||||
ds.talkerDBMap[talkerMd5] = db
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ds *DataSource) initContactDb(path string) error {
|
||||
|
||||
files, err := util.FindFilesWithPatterns(path, ContactFilePattern, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查找联系人数据库文件失败: %w", err)
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("未找到联系人数据库文件: %s", path)
|
||||
}
|
||||
|
||||
ds.contactDb, err = sql.Open("sqlite3", files[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("连接联系人数据库失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ds *DataSource) initChatRoomDb(path string) error {
|
||||
files, err := util.FindFilesWithPatterns(path, ChatRoomFilePattern, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查找群聊数据库文件失败: %w", err)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("未找到群聊数据库文件: %s", path)
|
||||
}
|
||||
ds.chatRoomDb, err = sql.Open("sqlite3", files[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("连接群聊数据库失败: %w", err)
|
||||
}
|
||||
|
||||
rows, err := ds.chatRoomDb.Query("SELECT m_nsUsrName, nickname FROM GroupMember")
|
||||
if err != nil {
|
||||
log.Printf("警告: 获取群聊成员失败: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var user string
|
||||
var nickName string
|
||||
if err := rows.Scan(&user, &nickName); err != nil {
|
||||
log.Printf("警告: 扫描表名失败: %v", err)
|
||||
continue
|
||||
}
|
||||
ds.user2DisplayName[user] = nickName
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ds *DataSource) initSessionDb(path string) error {
|
||||
files, err := util.FindFilesWithPatterns(path, SessionFilePattern, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查找最近会话数据库文件失败: %w", err)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("未找到最近会话数据库文件: %s", path)
|
||||
}
|
||||
ds.sessionDb, err = sql.Open("sqlite3", files[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("连接最近会话数据库失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMessages 实现获取消息的方法
|
||||
func (ds *DataSource) GetMessages(ctx context.Context, startTime, endTime time.Time, talker string, limit, offset int) ([]*model.Message, error) {
|
||||
// 在 darwinv3 中,每个联系人/群聊的消息存储在单独的表中,表名为 Chat_md5(talker)
|
||||
// 首先需要找到对应的表名
|
||||
if talker == "" {
|
||||
return nil, fmt.Errorf("talker 不能为空")
|
||||
}
|
||||
|
||||
_talkerMd5Bytes := md5.Sum([]byte(talker))
|
||||
talkerMd5 := hex.EncodeToString(_talkerMd5Bytes[:])
|
||||
db, ok := ds.talkerDBMap[talkerMd5]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("未找到 talker %s 的消息数据库", talker)
|
||||
}
|
||||
tableName := fmt.Sprintf("Chat_%s", talkerMd5)
|
||||
|
||||
// 构建查询条件
|
||||
query := fmt.Sprintf(`
|
||||
SELECT msgCreateTime, msgContent, messageType, mesDes, msgSource, CompressContent, ConBlob
|
||||
FROM %s
|
||||
WHERE msgCreateTime >= ? AND msgCreateTime <= ?
|
||||
ORDER BY msgCreateTime ASC
|
||||
`, tableName)
|
||||
|
||||
if limit > 0 {
|
||||
query += fmt.Sprintf(" LIMIT %d", limit)
|
||||
|
||||
if offset > 0 {
|
||||
query += fmt.Sprintf(" OFFSET %d", offset)
|
||||
}
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
rows, err := db.QueryContext(ctx, query, startTime.Unix(), endTime.Unix())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询表 %s 失败: %w", tableName, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// 处理查询结果
|
||||
messages := []*model.Message{}
|
||||
for rows.Next() {
|
||||
var msg model.MessageDarwinV3
|
||||
var compressContent, conBlob []byte
|
||||
err := rows.Scan(
|
||||
&msg.MesCreateTime,
|
||||
&msg.MesContent,
|
||||
&msg.MesType,
|
||||
&msg.MesDes,
|
||||
&msg.MesSource,
|
||||
&compressContent,
|
||||
&conBlob,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("警告: 扫描消息行失败: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 将消息包装为通用模型
|
||||
message := msg.Wrap(talker)
|
||||
messages = append(messages, message)
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// 从表名中提取 talker
|
||||
func extractTalkerFromTableName(tableName string) string {
|
||||
|
||||
if !strings.HasPrefix(tableName, "Chat_") {
|
||||
return ""
|
||||
}
|
||||
|
||||
if strings.HasSuffix(tableName, "_dels") {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.TrimPrefix(tableName, "Chat_")
|
||||
}
|
||||
|
||||
// GetContacts 实现获取联系人信息的方法
|
||||
func (ds *DataSource) GetContacts(ctx context.Context, key string, limit, offset int) ([]*model.Contact, error) {
|
||||
var query string
|
||||
var args []interface{}
|
||||
|
||||
if key != "" {
|
||||
// 按照关键字查询
|
||||
query = `SELECT m_nsUsrName, nickname, IFNULL(m_nsRemark,""), m_uiSex, IFNULL(m_nsAliasName,"")
|
||||
FROM WCContact
|
||||
WHERE m_nsUsrName = ? OR nickname = ? OR m_nsRemark = ? OR m_nsAliasName = ?`
|
||||
args = []interface{}{key, key, key, key}
|
||||
} else {
|
||||
// 查询所有联系人
|
||||
query = `SELECT m_nsUsrName, nickname, IFNULL(m_nsRemark,""), m_uiSex, IFNULL(m_nsAliasName,"")
|
||||
FROM WCContact`
|
||||
}
|
||||
|
||||
// 添加排序、分页
|
||||
query += ` ORDER BY m_nsUsrName`
|
||||
if limit > 0 {
|
||||
query += fmt.Sprintf(" LIMIT %d", limit)
|
||||
if offset > 0 {
|
||||
query += fmt.Sprintf(" OFFSET %d", offset)
|
||||
}
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
rows, err := ds.contactDb.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询联系人失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
contacts := []*model.Contact{}
|
||||
for rows.Next() {
|
||||
var contactDarwinV3 model.ContactDarwinV3
|
||||
err := rows.Scan(
|
||||
&contactDarwinV3.M_nsUsrName,
|
||||
&contactDarwinV3.Nickname,
|
||||
&contactDarwinV3.M_nsRemark,
|
||||
&contactDarwinV3.M_uiSex,
|
||||
&contactDarwinV3.M_nsAliasName,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("扫描联系人行失败: %w", err)
|
||||
}
|
||||
|
||||
contacts = append(contacts, contactDarwinV3.Wrap())
|
||||
}
|
||||
|
||||
return contacts, nil
|
||||
}
|
||||
|
||||
// GetChatRooms 实现获取群聊信息的方法
|
||||
func (ds *DataSource) GetChatRooms(ctx context.Context, key string, limit, offset int) ([]*model.ChatRoom, error) {
|
||||
var query string
|
||||
var args []interface{}
|
||||
|
||||
if key != "" {
|
||||
// 按照关键字查询
|
||||
query = `SELECT m_nsUsrName, nickname, IFNULL(m_nsRemark,""), IFNULL(m_nsChatRoomMemList,""), IFNULL(m_nsChatRoomAdminList,"")
|
||||
FROM GroupContact
|
||||
WHERE m_nsUsrName = ? OR nickname = ? OR m_nsRemark = ?`
|
||||
args = []interface{}{key, key, key}
|
||||
} else {
|
||||
// 查询所有群聊
|
||||
query = `SELECT m_nsUsrName, nickname, IFNULL(m_nsRemark,""), IFNULL(m_nsChatRoomMemList,""), IFNULL(m_nsChatRoomAdminList,"")
|
||||
FROM GroupContact`
|
||||
}
|
||||
|
||||
// 添加排序、分页
|
||||
query += ` ORDER BY m_nsUsrName`
|
||||
if limit > 0 {
|
||||
query += fmt.Sprintf(" LIMIT %d", limit)
|
||||
if offset > 0 {
|
||||
query += fmt.Sprintf(" OFFSET %d", offset)
|
||||
}
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
rows, err := ds.chatRoomDb.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询群聊失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
chatRooms := []*model.ChatRoom{}
|
||||
for rows.Next() {
|
||||
var chatRoomDarwinV3 model.ChatRoomDarwinV3
|
||||
err := rows.Scan(
|
||||
&chatRoomDarwinV3.M_nsUsrName,
|
||||
&chatRoomDarwinV3.Nickname,
|
||||
&chatRoomDarwinV3.M_nsRemark,
|
||||
&chatRoomDarwinV3.M_nsChatRoomMemList,
|
||||
&chatRoomDarwinV3.M_nsChatRoomAdminList,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("扫描群聊行失败: %w", err)
|
||||
}
|
||||
|
||||
chatRooms = append(chatRooms, chatRoomDarwinV3.Wrap(ds.user2DisplayName))
|
||||
}
|
||||
|
||||
// 如果没有找到群聊,尝试通过联系人查找
|
||||
if len(chatRooms) == 0 && key != "" {
|
||||
contacts, err := ds.GetContacts(ctx, key, 1, 0)
|
||||
if err == nil && len(contacts) > 0 && strings.HasSuffix(contacts[0].UserName, "@chatroom") {
|
||||
// 再次尝试通过用户名查找群聊
|
||||
rows, err := ds.chatRoomDb.QueryContext(ctx,
|
||||
`SELECT m_nsUsrName, nickname, m_nsRemark, m_nsChatRoomMemList, m_nsChatRoomAdminList
|
||||
FROM GroupContact
|
||||
WHERE m_nsUsrName = ?`,
|
||||
contacts[0].UserName)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询群聊失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var chatRoomDarwinV3 model.ChatRoomDarwinV3
|
||||
err := rows.Scan(
|
||||
&chatRoomDarwinV3.M_nsUsrName,
|
||||
&chatRoomDarwinV3.Nickname,
|
||||
&chatRoomDarwinV3.M_nsRemark,
|
||||
&chatRoomDarwinV3.M_nsChatRoomMemList,
|
||||
&chatRoomDarwinV3.M_nsChatRoomAdminList,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("扫描群聊行失败: %w", err)
|
||||
}
|
||||
|
||||
chatRooms = append(chatRooms, chatRoomDarwinV3.Wrap(ds.user2DisplayName))
|
||||
}
|
||||
|
||||
// 如果群聊记录不存在,但联系人记录存在,创建一个模拟的群聊对象
|
||||
if len(chatRooms) == 0 {
|
||||
chatRooms = append(chatRooms, &model.ChatRoom{
|
||||
Name: contacts[0].UserName,
|
||||
Users: make([]model.ChatRoomUser, 0),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return chatRooms, nil
|
||||
}
|
||||
|
||||
// GetSessions 实现获取会话信息的方法
|
||||
func (ds *DataSource) GetSessions(ctx context.Context, key string, limit, offset int) ([]*model.Session, error) {
|
||||
var query string
|
||||
var args []interface{}
|
||||
|
||||
if key != "" {
|
||||
// 按照关键字查询
|
||||
query = `SELECT m_nsUserName, m_uLastTime
|
||||
FROM SessionAbstract
|
||||
WHERE m_nsUserName = ?`
|
||||
args = []interface{}{key}
|
||||
} else {
|
||||
// 查询所有会话
|
||||
query = `SELECT m_nsUserName, m_uLastTime
|
||||
FROM SessionAbstract`
|
||||
}
|
||||
|
||||
// 添加排序、分页
|
||||
query += ` ORDER BY m_uLastTime DESC`
|
||||
if limit > 0 {
|
||||
query += fmt.Sprintf(" LIMIT %d", limit)
|
||||
if offset > 0 {
|
||||
query += fmt.Sprintf(" OFFSET %d", offset)
|
||||
}
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
rows, err := ds.sessionDb.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询会话失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
sessions := []*model.Session{}
|
||||
for rows.Next() {
|
||||
var sessionDarwinV3 model.SessionDarwinV3
|
||||
err := rows.Scan(
|
||||
&sessionDarwinV3.M_nsUserName,
|
||||
&sessionDarwinV3.M_uLastTime,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("扫描会话行失败: %w", err)
|
||||
}
|
||||
|
||||
// 包装成通用模型
|
||||
session := sessionDarwinV3.Wrap()
|
||||
|
||||
// 尝试获取联系人信息以补充会话信息
|
||||
contacts, err := ds.GetContacts(ctx, session.UserName, 1, 0)
|
||||
if err == nil && len(contacts) > 0 {
|
||||
session.NickName = contacts[0].DisplayName()
|
||||
} else {
|
||||
// 尝试获取群聊信息
|
||||
chatRooms, err := ds.GetChatRooms(ctx, session.UserName, 1, 0)
|
||||
if err == nil && len(chatRooms) > 0 {
|
||||
session.NickName = chatRooms[0].DisplayName()
|
||||
}
|
||||
}
|
||||
|
||||
sessions = append(sessions, session)
|
||||
}
|
||||
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
// Close 实现关闭数据库连接的方法
|
||||
func (ds *DataSource) Close() error {
|
||||
var errs []error
|
||||
|
||||
// 关闭消息数据库连接
|
||||
for i, db := range ds.messageDbs {
|
||||
if err := db.Close(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("关闭消息数据库 %d 失败: %w", i, err))
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭联系人数据库连接
|
||||
if ds.contactDb != nil {
|
||||
if err := ds.contactDb.Close(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("关闭联系人数据库失败: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭群聊数据库连接
|
||||
if ds.chatRoomDb != nil {
|
||||
if err := ds.chatRoomDb.Close(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("关闭群聊数据库失败: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭会话数据库连接
|
||||
if ds.sessionDb != nil {
|
||||
if err := ds.sessionDb.Close(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("关闭会话数据库失败: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("关闭数据库连接时发生错误: %v", errs)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
49
internal/wechatdb/datasource/datasource.go
Normal file
49
internal/wechatdb/datasource/datasource.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package datasource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/model"
|
||||
"github.com/sjzar/chatlog/internal/wechatdb/datasource/darwinv3"
|
||||
v4 "github.com/sjzar/chatlog/internal/wechatdb/datasource/v4"
|
||||
"github.com/sjzar/chatlog/internal/wechatdb/datasource/windowsv3"
|
||||
)
|
||||
|
||||
// 错误定义
|
||||
var (
|
||||
ErrUnsupportedPlatform = fmt.Errorf("unsupported platform")
|
||||
)
|
||||
|
||||
type DataSource interface {
|
||||
|
||||
// 消息
|
||||
GetMessages(ctx context.Context, startTime, endTime time.Time, talker string, limit, offset int) ([]*model.Message, error)
|
||||
|
||||
// 联系人
|
||||
GetContacts(ctx context.Context, key string, limit, offset int) ([]*model.Contact, error)
|
||||
|
||||
// 群聊
|
||||
GetChatRooms(ctx context.Context, key string, limit, offset int) ([]*model.ChatRoom, error)
|
||||
|
||||
// 最近会话
|
||||
GetSessions(ctx context.Context, key string, limit, offset int) ([]*model.Session, error)
|
||||
|
||||
Close() error
|
||||
}
|
||||
|
||||
func NewDataSource(path string, platform string, version int) (DataSource, error) {
|
||||
switch {
|
||||
case platform == "windows" && version == 3:
|
||||
return windowsv3.New(path)
|
||||
case platform == "windows" && version == 4:
|
||||
return v4.New(path)
|
||||
case platform == "darwin" && version == 3:
|
||||
return darwinv3.New(path)
|
||||
case platform == "darwin" && version == 4:
|
||||
return v4.New(path)
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %s v%d", ErrUnsupportedPlatform, platform, version)
|
||||
}
|
||||
}
|
||||
634
internal/wechatdb/datasource/v4/datasource.go
Normal file
634
internal/wechatdb/datasource/v4/datasource.go
Normal file
@@ -0,0 +1,634 @@
|
||||
package v4
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/model"
|
||||
"github.com/sjzar/chatlog/pkg/util"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
const (
|
||||
MessageFilePattern = "^message_([0-9]?[0-9])?\\.db$"
|
||||
ContactFilePattern = "^contact\\.db$"
|
||||
SessionFilePattern = "^session\\.db$"
|
||||
)
|
||||
|
||||
// MessageDBInfo 存储消息数据库的信息
|
||||
type MessageDBInfo struct {
|
||||
FilePath string
|
||||
StartTime time.Time
|
||||
EndTime time.Time
|
||||
ID2Name map[int]string
|
||||
}
|
||||
|
||||
type DataSource struct {
|
||||
path string
|
||||
messageDbs map[string]*sql.DB
|
||||
contactDb *sql.DB
|
||||
sessionDb *sql.DB
|
||||
|
||||
// 消息数据库信息
|
||||
messageFiles []MessageDBInfo
|
||||
}
|
||||
|
||||
func New(path string) (*DataSource, error) {
|
||||
ds := &DataSource{
|
||||
path: path,
|
||||
messageDbs: make(map[string]*sql.DB),
|
||||
messageFiles: make([]MessageDBInfo, 0),
|
||||
}
|
||||
|
||||
if err := ds.initMessageDbs(path); err != nil {
|
||||
return nil, fmt.Errorf("初始化消息数据库失败: %w", err)
|
||||
}
|
||||
if err := ds.initContactDb(path); err != nil {
|
||||
return nil, fmt.Errorf("初始化联系人数据库失败: %w", err)
|
||||
}
|
||||
if err := ds.initSessionDb(path); err != nil {
|
||||
return nil, fmt.Errorf("初始化会话数据库失败: %w", err)
|
||||
}
|
||||
|
||||
return ds, nil
|
||||
}
|
||||
|
||||
func (ds *DataSource) initMessageDbs(path string) error {
|
||||
// 查找所有消息数据库文件
|
||||
files, err := util.FindFilesWithPatterns(path, MessageFilePattern, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查找消息数据库文件失败: %w", err)
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("未找到任何消息数据库文件: %s", path)
|
||||
}
|
||||
|
||||
// 处理每个数据库文件
|
||||
for _, filePath := range files {
|
||||
// 连接数据库
|
||||
db, err := sql.Open("sqlite3", filePath)
|
||||
if err != nil {
|
||||
log.Printf("警告: 连接数据库 %s 失败: %v", filePath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 获取 Timestamp 表中的开始时间
|
||||
var startTime time.Time
|
||||
var timestamp int64
|
||||
|
||||
row := db.QueryRow("SELECT timestamp FROM Timestamp LIMIT 1")
|
||||
if err := row.Scan(×tamp); err != nil {
|
||||
log.Printf("警告: 获取数据库 %s 的时间戳失败: %v", filePath, err)
|
||||
db.Close()
|
||||
continue
|
||||
}
|
||||
startTime = time.Unix(timestamp, 0)
|
||||
|
||||
// 获取 ID2Name 映射
|
||||
id2Name := make(map[int]string)
|
||||
rows, err := db.Query("SELECT user_name FROM Name2Id")
|
||||
if err != nil {
|
||||
log.Printf("警告: 获取数据库 %s 的 Name2Id 表失败: %v", filePath, err)
|
||||
db.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
i := 1
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
log.Printf("警告: 扫描 Name2Id 行失败: %v", err)
|
||||
continue
|
||||
}
|
||||
id2Name[i] = name
|
||||
i++
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// 保存数据库信息
|
||||
ds.messageFiles = append(ds.messageFiles, MessageDBInfo{
|
||||
FilePath: filePath,
|
||||
StartTime: startTime,
|
||||
ID2Name: id2Name,
|
||||
})
|
||||
|
||||
// 保存数据库连接
|
||||
ds.messageDbs[filePath] = db
|
||||
}
|
||||
|
||||
// 按照 StartTime 排序数据库文件
|
||||
sort.Slice(ds.messageFiles, func(i, j int) bool {
|
||||
return ds.messageFiles[i].StartTime.Before(ds.messageFiles[j].StartTime)
|
||||
})
|
||||
|
||||
// 设置结束时间
|
||||
for i := range ds.messageFiles {
|
||||
if i == len(ds.messageFiles)-1 {
|
||||
ds.messageFiles[i].EndTime = time.Now()
|
||||
} else {
|
||||
ds.messageFiles[i].EndTime = ds.messageFiles[i+1].StartTime
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ds *DataSource) initContactDb(path string) error {
|
||||
files, err := util.FindFilesWithPatterns(path, ContactFilePattern, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查找联系人数据库文件失败: %w", err)
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("未找到联系人数据库文件: %s", path)
|
||||
}
|
||||
|
||||
ds.contactDb, err = sql.Open("sqlite3", files[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("连接联系人数据库失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ds *DataSource) initSessionDb(path string) error {
|
||||
files, err := util.FindFilesWithPatterns(path, SessionFilePattern, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查找最近会话数据库文件失败: %w", err)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("未找到最近会话数据库文件: %s", path)
|
||||
}
|
||||
ds.sessionDb, err = sql.Open("sqlite3", files[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("连接最近会话数据库失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getDBInfosForTimeRange 获取时间范围内的数据库信息
|
||||
func (ds *DataSource) getDBInfosForTimeRange(startTime, endTime time.Time) []MessageDBInfo {
|
||||
var dbs []MessageDBInfo
|
||||
for _, info := range ds.messageFiles {
|
||||
if info.StartTime.Before(endTime) && info.EndTime.After(startTime) {
|
||||
dbs = append(dbs, info)
|
||||
}
|
||||
}
|
||||
return dbs
|
||||
}
|
||||
|
||||
func (ds *DataSource) GetMessages(ctx context.Context, startTime, endTime time.Time, talker string, limit, offset int) ([]*model.Message, error) {
|
||||
if talker == "" {
|
||||
return nil, fmt.Errorf("必须指定 talker 参数")
|
||||
}
|
||||
|
||||
// 找到时间范围内的数据库文件
|
||||
dbInfos := ds.getDBInfosForTimeRange(startTime, endTime)
|
||||
if len(dbInfos) == 0 {
|
||||
return nil, fmt.Errorf("未找到时间范围 %v 到 %v 内的数据库文件", startTime, endTime)
|
||||
}
|
||||
|
||||
if len(dbInfos) == 1 {
|
||||
// LIMIT 和 OFFSET 逻辑在单文件情况下可以直接在 SQL 里处理
|
||||
return ds.getMessagesSingleFile(ctx, dbInfos[0], startTime, endTime, talker, limit, offset)
|
||||
}
|
||||
|
||||
// 从每个相关数据库中查询消息
|
||||
totalMessages := []*model.Message{}
|
||||
|
||||
for _, dbInfo := range dbInfos {
|
||||
// 检查上下文是否已取消
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db, ok := ds.messageDbs[dbInfo.FilePath]
|
||||
if !ok {
|
||||
log.Printf("警告: 数据库 %s 未打开", dbInfo.FilePath)
|
||||
continue
|
||||
}
|
||||
|
||||
messages, err := ds.getMessagesFromDB(ctx, db, dbInfo, startTime, endTime, talker)
|
||||
if err != nil {
|
||||
log.Printf("警告: 从数据库 %s 获取消息失败: %v", dbInfo.FilePath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
totalMessages = append(totalMessages, messages...)
|
||||
|
||||
if limit+offset > 0 && len(totalMessages) >= limit+offset {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 对所有消息按时间排序
|
||||
sort.Slice(totalMessages, func(i, j int) bool {
|
||||
return totalMessages[i].Sequence < totalMessages[j].Sequence
|
||||
})
|
||||
|
||||
// 处理分页
|
||||
if limit > 0 {
|
||||
if offset >= len(totalMessages) {
|
||||
return []*model.Message{}, nil
|
||||
}
|
||||
end := offset + limit
|
||||
if end > len(totalMessages) {
|
||||
end = len(totalMessages)
|
||||
}
|
||||
return totalMessages[offset:end], nil
|
||||
}
|
||||
|
||||
return totalMessages, nil
|
||||
}
|
||||
|
||||
// getMessagesSingleFile 从单个数据库文件获取消息
|
||||
func (ds *DataSource) getMessagesSingleFile(ctx context.Context, dbInfo MessageDBInfo, startTime, endTime time.Time, talker string, limit, offset int) ([]*model.Message, error) {
|
||||
db, ok := ds.messageDbs[dbInfo.FilePath]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("数据库 %s 未打开", dbInfo.FilePath)
|
||||
}
|
||||
|
||||
// 构建表名
|
||||
_talkerMd5Bytes := md5.Sum([]byte(talker))
|
||||
talkerMd5 := hex.EncodeToString(_talkerMd5Bytes[:])
|
||||
tableName := "Msg_" + talkerMd5
|
||||
|
||||
// 构建查询条件
|
||||
conditions := []string{"create_time >= ? AND create_time <= ?"}
|
||||
args := []interface{}{startTime.Unix(), endTime.Unix()}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT sort_seq, local_type, real_sender_id, create_time, message_content, packed_info_data, status
|
||||
FROM %s
|
||||
WHERE %s
|
||||
ORDER BY sort_seq ASC
|
||||
`, tableName, strings.Join(conditions, " AND "))
|
||||
|
||||
if limit > 0 {
|
||||
query += fmt.Sprintf(" LIMIT %d", limit)
|
||||
if offset > 0 {
|
||||
query += fmt.Sprintf(" OFFSET %d", offset)
|
||||
}
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
rows, err := db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询数据库 %s 失败: %w", dbInfo.FilePath, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// 处理查询结果
|
||||
messages := []*model.Message{}
|
||||
isChatRoom := strings.HasSuffix(talker, "@chatroom")
|
||||
|
||||
for rows.Next() {
|
||||
var msg model.MessageV4
|
||||
err := rows.Scan(
|
||||
&msg.SortSeq,
|
||||
&msg.LocalType,
|
||||
&msg.RealSenderID,
|
||||
&msg.CreateTime,
|
||||
&msg.MessageContent,
|
||||
&msg.PackedInfoData,
|
||||
&msg.Status,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("扫描消息行失败: %w", err)
|
||||
}
|
||||
|
||||
messages = append(messages, msg.Wrap(dbInfo.ID2Name, isChatRoom))
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// getMessagesFromDB 从数据库获取消息
|
||||
func (ds *DataSource) getMessagesFromDB(ctx context.Context, db *sql.DB, dbInfo MessageDBInfo, startTime, endTime time.Time, talker string) ([]*model.Message, error) {
|
||||
// 构建表名
|
||||
_talkerMd5Bytes := md5.Sum([]byte(talker))
|
||||
talkerMd5 := hex.EncodeToString(_talkerMd5Bytes[:])
|
||||
tableName := "Msg_" + talkerMd5
|
||||
|
||||
// 检查表是否存在
|
||||
var exists bool
|
||||
err := db.QueryRowContext(ctx,
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
|
||||
tableName).Scan(&exists)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
// 表不存在,返回空结果
|
||||
return []*model.Message{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("检查表 %s 是否存在失败: %w", tableName, err)
|
||||
}
|
||||
|
||||
// 构建查询条件
|
||||
conditions := []string{"create_time >= ? AND create_time <= ?"}
|
||||
args := []interface{}{startTime.Unix(), endTime.Unix()}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT sort_seq, local_type, real_sender_id, create_time, message_content, packed_info_data, status
|
||||
FROM %s
|
||||
WHERE %s
|
||||
ORDER BY sort_seq ASC
|
||||
`, tableName, strings.Join(conditions, " AND "))
|
||||
|
||||
// 执行查询
|
||||
rows, err := db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
// 如果表不存在,SQLite 会返回错误
|
||||
if strings.Contains(err.Error(), "no such table") {
|
||||
return []*model.Message{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("查询数据库失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// 处理查询结果
|
||||
messages := []*model.Message{}
|
||||
isChatRoom := strings.HasSuffix(talker, "@chatroom")
|
||||
|
||||
for rows.Next() {
|
||||
var msg model.MessageV4
|
||||
err := rows.Scan(
|
||||
&msg.SortSeq,
|
||||
&msg.LocalType,
|
||||
&msg.RealSenderID,
|
||||
&msg.CreateTime,
|
||||
&msg.MessageContent,
|
||||
&msg.PackedInfoData,
|
||||
&msg.Status,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("扫描消息行失败: %w", err)
|
||||
}
|
||||
|
||||
messages = append(messages, msg.Wrap(dbInfo.ID2Name, isChatRoom))
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// 联系人
|
||||
func (ds *DataSource) GetContacts(ctx context.Context, key string, limit, offset int) ([]*model.Contact, error) {
|
||||
var query string
|
||||
var args []interface{}
|
||||
|
||||
if key != "" {
|
||||
// 按照关键字查询
|
||||
query = `SELECT username, local_type, alias, remark, nick_name
|
||||
FROM contact
|
||||
WHERE username = ? OR alias = ? OR remark = ? OR nick_name = ?`
|
||||
args = []interface{}{key, key, key, key}
|
||||
} else {
|
||||
// 查询所有联系人
|
||||
query = `SELECT username, local_type, alias, remark, nick_name FROM contact`
|
||||
}
|
||||
|
||||
// 添加排序、分页
|
||||
query += ` ORDER BY username`
|
||||
if limit > 0 {
|
||||
query += fmt.Sprintf(" LIMIT %d", limit)
|
||||
if offset > 0 {
|
||||
query += fmt.Sprintf(" OFFSET %d", offset)
|
||||
}
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
rows, err := ds.contactDb.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询联系人失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
contacts := []*model.Contact{}
|
||||
for rows.Next() {
|
||||
var contactV4 model.ContactV4
|
||||
err := rows.Scan(
|
||||
&contactV4.UserName,
|
||||
&contactV4.LocalType,
|
||||
&contactV4.Alias,
|
||||
&contactV4.Remark,
|
||||
&contactV4.NickName,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("扫描联系人行失败: %w", err)
|
||||
}
|
||||
|
||||
contacts = append(contacts, contactV4.Wrap())
|
||||
}
|
||||
|
||||
return contacts, nil
|
||||
}
|
||||
|
||||
// 群聊
|
||||
func (ds *DataSource) GetChatRooms(ctx context.Context, key string, limit, offset int) ([]*model.ChatRoom, error) {
|
||||
var query string
|
||||
var args []interface{}
|
||||
|
||||
if key != "" {
|
||||
// 按照关键字查询
|
||||
query = `SELECT username, owner, ext_buffer FROM chat_room WHERE username = ?`
|
||||
args = []interface{}{key}
|
||||
|
||||
// 执行查询
|
||||
rows, err := ds.contactDb.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询群聊失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
chatRooms := []*model.ChatRoom{}
|
||||
for rows.Next() {
|
||||
var chatRoomV4 model.ChatRoomV4
|
||||
err := rows.Scan(
|
||||
&chatRoomV4.UserName,
|
||||
&chatRoomV4.Owner,
|
||||
&chatRoomV4.ExtBuffer,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("扫描群聊行失败: %w", err)
|
||||
}
|
||||
|
||||
chatRooms = append(chatRooms, chatRoomV4.Wrap())
|
||||
}
|
||||
|
||||
// 如果没有找到群聊,尝试通过联系人查找
|
||||
if len(chatRooms) == 0 {
|
||||
contacts, err := ds.GetContacts(ctx, key, 1, 0)
|
||||
if err == nil && len(contacts) > 0 && strings.HasSuffix(contacts[0].UserName, "@chatroom") {
|
||||
// 再次尝试通过用户名查找群聊
|
||||
rows, err := ds.contactDb.QueryContext(ctx,
|
||||
`SELECT username, owner, ext_buffer FROM chat_room WHERE username = ?`,
|
||||
contacts[0].UserName)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询群聊失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var chatRoomV4 model.ChatRoomV4
|
||||
err := rows.Scan(
|
||||
&chatRoomV4.UserName,
|
||||
&chatRoomV4.Owner,
|
||||
&chatRoomV4.ExtBuffer,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("扫描群聊行失败: %w", err)
|
||||
}
|
||||
|
||||
chatRooms = append(chatRooms, chatRoomV4.Wrap())
|
||||
}
|
||||
|
||||
// 如果群聊记录不存在,但联系人记录存在,创建一个模拟的群聊对象
|
||||
if len(chatRooms) == 0 {
|
||||
chatRooms = append(chatRooms, &model.ChatRoom{
|
||||
Name: contacts[0].UserName,
|
||||
Users: make([]model.ChatRoomUser, 0),
|
||||
User2DisplayName: make(map[string]string),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return chatRooms, nil
|
||||
} else {
|
||||
// 查询所有群聊
|
||||
query = `SELECT username, owner, ext_buffer FROM chat_room`
|
||||
|
||||
// 添加排序、分页
|
||||
query += ` ORDER BY username`
|
||||
if limit > 0 {
|
||||
query += fmt.Sprintf(" LIMIT %d", limit)
|
||||
if offset > 0 {
|
||||
query += fmt.Sprintf(" OFFSET %d", offset)
|
||||
}
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
rows, err := ds.contactDb.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询群聊失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
chatRooms := []*model.ChatRoom{}
|
||||
for rows.Next() {
|
||||
var chatRoomV4 model.ChatRoomV4
|
||||
err := rows.Scan(
|
||||
&chatRoomV4.UserName,
|
||||
&chatRoomV4.Owner,
|
||||
&chatRoomV4.ExtBuffer,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("扫描群聊行失败: %w", err)
|
||||
}
|
||||
|
||||
chatRooms = append(chatRooms, chatRoomV4.Wrap())
|
||||
}
|
||||
|
||||
return chatRooms, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 最近会话
|
||||
func (ds *DataSource) GetSessions(ctx context.Context, key string, limit, offset int) ([]*model.Session, error) {
|
||||
var query string
|
||||
var args []interface{}
|
||||
|
||||
if key != "" {
|
||||
// 按照关键字查询
|
||||
query = `SELECT username, summary, last_timestamp, last_msg_sender, last_sender_display_name
|
||||
FROM SessionTable
|
||||
WHERE username = ? OR last_sender_display_name = ?
|
||||
ORDER BY sort_timestamp DESC`
|
||||
args = []interface{}{key, key}
|
||||
} else {
|
||||
// 查询所有会话
|
||||
query = `SELECT username, summary, last_timestamp, last_msg_sender, last_sender_display_name
|
||||
FROM SessionTable
|
||||
ORDER BY sort_timestamp DESC`
|
||||
}
|
||||
|
||||
// 添加分页
|
||||
if limit > 0 {
|
||||
query += fmt.Sprintf(" LIMIT %d", limit)
|
||||
if offset > 0 {
|
||||
query += fmt.Sprintf(" OFFSET %d", offset)
|
||||
}
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
rows, err := ds.sessionDb.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询会话失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
sessions := []*model.Session{}
|
||||
for rows.Next() {
|
||||
var sessionV4 model.SessionV4
|
||||
err := rows.Scan(
|
||||
&sessionV4.Username,
|
||||
&sessionV4.Summary,
|
||||
&sessionV4.LastTimestamp,
|
||||
&sessionV4.LastMsgSender,
|
||||
&sessionV4.LastSenderDisplayName,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("扫描会话行失败: %w", err)
|
||||
}
|
||||
|
||||
sessions = append(sessions, sessionV4.Wrap())
|
||||
}
|
||||
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
func (ds *DataSource) Close() error {
|
||||
var errs []error
|
||||
|
||||
// 关闭消息数据库连接
|
||||
for path, db := range ds.messageDbs {
|
||||
if err := db.Close(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("关闭消息数据库 %s 失败: %w", path, err))
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭联系人数据库连接
|
||||
if ds.contactDb != nil {
|
||||
if err := ds.contactDb.Close(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("关闭联系人数据库失败: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭会话数据库连接
|
||||
if ds.sessionDb != nil {
|
||||
if err := ds.sessionDb.Close(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("关闭会话数据库失败: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("关闭数据库连接时发生错误: %v", errs)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
615
internal/wechatdb/datasource/windowsv3/datasource.go
Normal file
615
internal/wechatdb/datasource/windowsv3/datasource.go
Normal file
@@ -0,0 +1,615 @@
|
||||
package windowsv3
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/model"
|
||||
"github.com/sjzar/chatlog/pkg/util"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
const (
|
||||
MessageFilePattern = "^MSG([0-9]?[0-9])?\\.db$"
|
||||
ContactFilePattern = "^MicroMsg.db$"
|
||||
)
|
||||
|
||||
// MessageDBInfo 保存消息数据库的信息
|
||||
type MessageDBInfo struct {
|
||||
FilePath string
|
||||
StartTime time.Time
|
||||
EndTime time.Time
|
||||
TalkerMap map[string]int
|
||||
}
|
||||
|
||||
// DataSource 实现了 DataSource 接口
|
||||
type DataSource struct {
|
||||
// 消息数据库
|
||||
messageFiles []MessageDBInfo
|
||||
messageDbs map[string]*sql.DB
|
||||
|
||||
// 联系人数据库
|
||||
contactDbFile string
|
||||
contactDb *sql.DB
|
||||
}
|
||||
|
||||
// New 创建一个新的 WindowsV3DataSource
|
||||
func New(path string) (*DataSource, error) {
|
||||
ds := &DataSource{
|
||||
messageFiles: make([]MessageDBInfo, 0),
|
||||
messageDbs: make(map[string]*sql.DB),
|
||||
}
|
||||
|
||||
// 初始化消息数据库
|
||||
if err := ds.initMessageDbs(path); err != nil {
|
||||
return nil, fmt.Errorf("初始化消息数据库失败: %w", err)
|
||||
}
|
||||
|
||||
// 初始化联系人数据库
|
||||
if err := ds.initContactDb(path); err != nil {
|
||||
return nil, fmt.Errorf("初始化联系人数据库失败: %w", err)
|
||||
}
|
||||
|
||||
return ds, nil
|
||||
}
|
||||
|
||||
// initMessageDbs 初始化消息数据库
|
||||
func (ds *DataSource) initMessageDbs(path string) error {
|
||||
// 查找所有消息数据库文件
|
||||
files, err := util.FindFilesWithPatterns(path, MessageFilePattern, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查找消息数据库文件失败: %w", err)
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
return 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
|
||||
|
||||
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++
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// 保存数据库信息
|
||||
ds.messageFiles = append(ds.messageFiles, MessageDBInfo{
|
||||
FilePath: filePath,
|
||||
StartTime: startTime,
|
||||
TalkerMap: talkerMap,
|
||||
})
|
||||
|
||||
// 保存数据库连接
|
||||
ds.messageDbs[filePath] = db
|
||||
}
|
||||
|
||||
// 按照 StartTime 排序数据库文件
|
||||
sort.Slice(ds.messageFiles, func(i, j int) bool {
|
||||
return ds.messageFiles[i].StartTime.Before(ds.messageFiles[j].StartTime)
|
||||
})
|
||||
|
||||
// 设置结束时间
|
||||
for i := range ds.messageFiles {
|
||||
if i == len(ds.messageFiles)-1 {
|
||||
ds.messageFiles[i].EndTime = time.Now()
|
||||
} else {
|
||||
ds.messageFiles[i].EndTime = ds.messageFiles[i+1].StartTime
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// initContactDb 初始化联系人数据库
|
||||
func (ds *DataSource) initContactDb(path string) error {
|
||||
files, err := util.FindFilesWithPatterns(path, ContactFilePattern, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查找联系人数据库文件失败: %w", err)
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("未找到联系人数据库文件: %s", path)
|
||||
}
|
||||
|
||||
ds.contactDbFile = files[0]
|
||||
|
||||
ds.contactDb, err = sql.Open("sqlite3", ds.contactDbFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("连接联系人数据库失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getDBInfosForTimeRange 获取时间范围内的数据库信息
|
||||
func (ds *DataSource) getDBInfosForTimeRange(startTime, endTime time.Time) []MessageDBInfo {
|
||||
var dbs []MessageDBInfo
|
||||
for _, info := range ds.messageFiles {
|
||||
if info.StartTime.Before(endTime) && info.EndTime.After(startTime) {
|
||||
dbs = append(dbs, info)
|
||||
}
|
||||
}
|
||||
return dbs
|
||||
}
|
||||
|
||||
// GetMessages 实现 DataSource 接口的 GetMessages 方法
|
||||
func (ds *DataSource) GetMessages(ctx context.Context, startTime, endTime time.Time, talker string, limit, offset int) ([]*model.Message, error) {
|
||||
// 找到时间范围内的数据库文件
|
||||
dbInfos := ds.getDBInfosForTimeRange(startTime, endTime)
|
||||
if len(dbInfos) == 0 {
|
||||
return nil, fmt.Errorf("未找到时间范围 %v 到 %v 内的数据库文件", startTime, endTime)
|
||||
}
|
||||
|
||||
if len(dbInfos) == 1 {
|
||||
// LIMIT 和 OFFSET 逻辑在单文件情况下可以直接在 SQL 里处理
|
||||
return ds.getMessagesSingleFile(ctx, dbInfos[0], startTime, endTime, talker, limit, offset)
|
||||
}
|
||||
|
||||
// 从每个相关数据库中查询消息
|
||||
totalMessages := []*model.Message{}
|
||||
|
||||
for _, dbInfo := range dbInfos {
|
||||
// 检查上下文是否已取消
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db, ok := ds.messageDbs[dbInfo.FilePath]
|
||||
if !ok {
|
||||
log.Printf("警告: 数据库 %s 未打开", dbInfo.FilePath)
|
||||
continue
|
||||
}
|
||||
|
||||
// 构建查询条件
|
||||
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.QueryContext(ctx, 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
|
||||
})
|
||||
|
||||
// 处理分页
|
||||
if limit > 0 {
|
||||
if offset >= len(totalMessages) {
|
||||
return []*model.Message{}, nil
|
||||
}
|
||||
end := offset + limit
|
||||
if end > len(totalMessages) {
|
||||
end = len(totalMessages)
|
||||
}
|
||||
return totalMessages[offset:end], nil
|
||||
}
|
||||
|
||||
return totalMessages, nil
|
||||
}
|
||||
|
||||
// getMessagesSingleFile 从单个数据库文件获取消息
|
||||
func (ds *DataSource) getMessagesSingleFile(ctx context.Context, dbInfo MessageDBInfo, startTime, endTime time.Time, talker string, limit, offset int) ([]*model.Message, error) {
|
||||
// 构建查询条件
|
||||
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 := ds.messageDbs[dbInfo.FilePath].QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询数据库 %s 失败: %w", 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("扫描消息行失败: %w", err)
|
||||
}
|
||||
msg.CompressContent = compressContent
|
||||
msg.BytesExtra = bytesExtra
|
||||
totalMessages = append(totalMessages, msg.Wrap())
|
||||
}
|
||||
return totalMessages, nil
|
||||
}
|
||||
|
||||
// GetContacts 实现获取联系人信息的方法
|
||||
func (ds *DataSource) GetContacts(ctx context.Context, key string, limit, offset int) ([]*model.Contact, error) {
|
||||
var query string
|
||||
var args []interface{}
|
||||
|
||||
if key != "" {
|
||||
// 按照关键字查询
|
||||
query = `SELECT UserName, Alias, Remark, NickName, Reserved1 FROM Contact
|
||||
WHERE UserName = ? OR Alias = ? OR Remark = ? OR NickName = ?`
|
||||
args = []interface{}{key, key, key, key}
|
||||
} else {
|
||||
// 查询所有联系人
|
||||
query = `SELECT UserName, Alias, Remark, NickName, Reserved1 FROM Contact`
|
||||
}
|
||||
|
||||
// 添加排序、分页
|
||||
query += ` ORDER BY UserName`
|
||||
if limit > 0 {
|
||||
query += fmt.Sprintf(" LIMIT %d", limit)
|
||||
if offset > 0 {
|
||||
query += fmt.Sprintf(" OFFSET %d", offset)
|
||||
}
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
rows, err := ds.contactDb.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询联系人失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
contacts := []*model.Contact{}
|
||||
for rows.Next() {
|
||||
var contactV3 model.ContactV3
|
||||
err := rows.Scan(
|
||||
&contactV3.UserName,
|
||||
&contactV3.Alias,
|
||||
&contactV3.Remark,
|
||||
&contactV3.NickName,
|
||||
&contactV3.Reserved1,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("扫描联系人行失败: %w", err)
|
||||
}
|
||||
|
||||
contacts = append(contacts, contactV3.Wrap())
|
||||
}
|
||||
|
||||
return contacts, nil
|
||||
}
|
||||
|
||||
// GetChatRooms 实现获取群聊信息的方法
|
||||
func (ds *DataSource) GetChatRooms(ctx context.Context, key string, limit, offset int) ([]*model.ChatRoom, error) {
|
||||
var query string
|
||||
var args []interface{}
|
||||
|
||||
if key != "" {
|
||||
// 按照关键字查询
|
||||
query = `SELECT ChatRoomName, Reserved2, RoomData FROM ChatRoom WHERE ChatRoomName = ?`
|
||||
args = []interface{}{key}
|
||||
|
||||
// 执行查询
|
||||
rows, err := ds.contactDb.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询群聊失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
chatRooms := []*model.ChatRoom{}
|
||||
for rows.Next() {
|
||||
var chatRoomV3 model.ChatRoomV3
|
||||
err := rows.Scan(
|
||||
&chatRoomV3.ChatRoomName,
|
||||
&chatRoomV3.Reserved2,
|
||||
&chatRoomV3.RoomData,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("扫描群聊行失败: %w", err)
|
||||
}
|
||||
|
||||
chatRooms = append(chatRooms, chatRoomV3.Wrap())
|
||||
}
|
||||
|
||||
// 如果没有找到群聊,尝试通过联系人查找
|
||||
if len(chatRooms) == 0 {
|
||||
contacts, err := ds.GetContacts(ctx, key, 1, 0)
|
||||
if err == nil && len(contacts) > 0 && strings.HasSuffix(contacts[0].UserName, "@chatroom") {
|
||||
// 再次尝试通过用户名查找群聊
|
||||
rows, err := ds.contactDb.QueryContext(ctx,
|
||||
`SELECT ChatRoomName, Reserved2, RoomData FROM ChatRoom WHERE ChatRoomName = ?`,
|
||||
contacts[0].UserName)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询群聊失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var chatRoomV3 model.ChatRoomV3
|
||||
err := rows.Scan(
|
||||
&chatRoomV3.ChatRoomName,
|
||||
&chatRoomV3.Reserved2,
|
||||
&chatRoomV3.RoomData,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("扫描群聊行失败: %w", err)
|
||||
}
|
||||
|
||||
chatRooms = append(chatRooms, chatRoomV3.Wrap())
|
||||
}
|
||||
|
||||
// 如果群聊记录不存在,但联系人记录存在,创建一个模拟的群聊对象
|
||||
if len(chatRooms) == 0 {
|
||||
chatRooms = append(chatRooms, &model.ChatRoom{
|
||||
Name: contacts[0].UserName,
|
||||
Users: make([]model.ChatRoomUser, 0),
|
||||
User2DisplayName: make(map[string]string),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return chatRooms, nil
|
||||
} else {
|
||||
// 查询所有群聊
|
||||
query = `SELECT ChatRoomName, Reserved2, RoomData FROM ChatRoom`
|
||||
|
||||
// 添加排序、分页
|
||||
query += ` ORDER BY ChatRoomName`
|
||||
if limit > 0 {
|
||||
query += fmt.Sprintf(" LIMIT %d", limit)
|
||||
if offset > 0 {
|
||||
query += fmt.Sprintf(" OFFSET %d", offset)
|
||||
}
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
rows, err := ds.contactDb.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询群聊失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
chatRooms := []*model.ChatRoom{}
|
||||
for rows.Next() {
|
||||
var chatRoomV3 model.ChatRoomV3
|
||||
err := rows.Scan(
|
||||
&chatRoomV3.ChatRoomName,
|
||||
&chatRoomV3.Reserved2,
|
||||
&chatRoomV3.RoomData,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("扫描群聊行失败: %w", err)
|
||||
}
|
||||
|
||||
chatRooms = append(chatRooms, chatRoomV3.Wrap())
|
||||
}
|
||||
|
||||
return chatRooms, nil
|
||||
}
|
||||
}
|
||||
|
||||
// GetSessions 实现获取会话信息的方法
|
||||
func (ds *DataSource) GetSessions(ctx context.Context, key string, limit, offset int) ([]*model.Session, error) {
|
||||
var query string
|
||||
var args []interface{}
|
||||
|
||||
if key != "" {
|
||||
// 按照关键字查询
|
||||
query = `SELECT strUsrName, nOrder, strNickName, strContent, nTime
|
||||
FROM Session
|
||||
WHERE strUsrName = ? OR strNickName = ?
|
||||
ORDER BY nOrder DESC`
|
||||
args = []interface{}{key, key}
|
||||
} else {
|
||||
// 查询所有会话
|
||||
query = `SELECT strUsrName, nOrder, strNickName, strContent, nTime
|
||||
FROM Session
|
||||
ORDER BY nOrder DESC`
|
||||
}
|
||||
|
||||
// 添加分页
|
||||
if limit > 0 {
|
||||
query += fmt.Sprintf(" LIMIT %d", limit)
|
||||
if offset > 0 {
|
||||
query += fmt.Sprintf(" OFFSET %d", offset)
|
||||
}
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
rows, err := ds.contactDb.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询会话失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
sessions := []*model.Session{}
|
||||
for rows.Next() {
|
||||
var sessionV3 model.SessionV3
|
||||
err := rows.Scan(
|
||||
&sessionV3.StrUsrName,
|
||||
&sessionV3.NOrder,
|
||||
&sessionV3.StrNickName,
|
||||
&sessionV3.StrContent,
|
||||
&sessionV3.NTime,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("扫描会话行失败: %w", err)
|
||||
}
|
||||
|
||||
sessions = append(sessions, sessionV3.Wrap())
|
||||
}
|
||||
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
// Close 实现 DataSource 接口的 Close 方法
|
||||
func (ds *DataSource) Close() error {
|
||||
var errs []error
|
||||
|
||||
// 关闭消息数据库连接
|
||||
for path, db := range ds.messageDbs {
|
||||
if err := db.Close(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("关闭消息数据库 %s 失败: %w", path, err))
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭联系人数据库连接
|
||||
if ds.contactDb != nil {
|
||||
if err := ds.contactDb.Close(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("关闭联系人数据库失败: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("关闭数据库连接时发生错误: %v", errs)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
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
|
||||
}
|
||||
184
internal/wechatdb/repository/chatroom.go
Normal file
184
internal/wechatdb/repository/chatroom.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/model"
|
||||
)
|
||||
|
||||
// initChatRoomCache 初始化群聊缓存
|
||||
func (r *Repository) initChatRoomCache(ctx context.Context) error {
|
||||
// 加载所有群聊到缓存
|
||||
chatRooms, err := r.ds.GetChatRooms(ctx, "", 0, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("加载群聊失败: %w", err)
|
||||
}
|
||||
|
||||
chatRoomMap := make(map[string]*model.ChatRoom)
|
||||
remarkToChatRoom := make(map[string]*model.ChatRoom)
|
||||
nickNameToChatRoom := make(map[string]*model.ChatRoom)
|
||||
chatRoomList := make([]string, 0)
|
||||
chatRoomRemark := make([]string, 0)
|
||||
chatRoomNickName := make([]string, 0)
|
||||
|
||||
for _, chatRoom := range chatRooms {
|
||||
// 补充群聊信息(从联系人中获取 Remark 和 NickName)
|
||||
r.enrichChatRoom(chatRoom)
|
||||
chatRoomMap[chatRoom.Name] = chatRoom
|
||||
chatRoomList = append(chatRoomList, chatRoom.Name)
|
||||
if chatRoom.Remark != "" {
|
||||
remarkToChatRoom[chatRoom.Remark] = chatRoom
|
||||
chatRoomRemark = append(chatRoomRemark, chatRoom.Remark)
|
||||
}
|
||||
if chatRoom.NickName != "" {
|
||||
nickNameToChatRoom[chatRoom.NickName] = chatRoom
|
||||
chatRoomNickName = append(chatRoomNickName, chatRoom.NickName)
|
||||
}
|
||||
}
|
||||
|
||||
for _, contact := range r.chatRoomInContact {
|
||||
if _, ok := chatRoomMap[contact.UserName]; !ok {
|
||||
chatRoom := &model.ChatRoom{
|
||||
Name: contact.UserName,
|
||||
Remark: contact.Remark,
|
||||
NickName: contact.NickName,
|
||||
}
|
||||
chatRoomMap[contact.UserName] = chatRoom
|
||||
chatRoomList = append(chatRoomList, contact.UserName)
|
||||
if contact.Remark != "" {
|
||||
remarkToChatRoom[contact.Remark] = chatRoom
|
||||
chatRoomRemark = append(chatRoomRemark, contact.Remark)
|
||||
}
|
||||
if contact.NickName != "" {
|
||||
nickNameToChatRoom[contact.NickName] = chatRoom
|
||||
chatRoomNickName = append(chatRoomNickName, contact.NickName)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(chatRoomList)
|
||||
sort.Strings(chatRoomRemark)
|
||||
sort.Strings(chatRoomNickName)
|
||||
|
||||
r.chatRoomCache = chatRoomMap
|
||||
r.chatRoomList = chatRoomList
|
||||
r.remarkToChatRoom = remarkToChatRoom
|
||||
r.nickNameToChatRoom = nickNameToChatRoom
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetChatRooms(ctx context.Context, key string, limit, offset int) ([]*model.ChatRoom, error) {
|
||||
|
||||
ret := make([]*model.ChatRoom, 0)
|
||||
if key != "" {
|
||||
ret = r.findChatRooms(key)
|
||||
if len(ret) == 0 {
|
||||
return nil, fmt.Errorf("未找到群聊: %s", key)
|
||||
}
|
||||
|
||||
if limit > 0 {
|
||||
end := offset + limit
|
||||
if end > len(ret) {
|
||||
end = len(ret)
|
||||
}
|
||||
if offset >= len(ret) {
|
||||
return []*model.ChatRoom{}, nil
|
||||
}
|
||||
return ret[offset:end], nil
|
||||
}
|
||||
} else {
|
||||
list := r.chatRoomList
|
||||
if limit > 0 {
|
||||
end := offset + limit
|
||||
if end > len(list) {
|
||||
end = len(list)
|
||||
}
|
||||
if offset >= len(list) {
|
||||
return []*model.ChatRoom{}, nil
|
||||
}
|
||||
list = list[offset:end]
|
||||
}
|
||||
for _, name := range list {
|
||||
ret = append(ret, r.chatRoomCache[name])
|
||||
}
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetChatRoom(ctx context.Context, key string) (*model.ChatRoom, error) {
|
||||
chatRoom := r.findChatRoom(key)
|
||||
if chatRoom == nil {
|
||||
return nil, fmt.Errorf("未找到群聊: %s", key)
|
||||
}
|
||||
return chatRoom, nil
|
||||
}
|
||||
|
||||
// enrichChatRoom 从联系人信息中补充群聊信息
|
||||
func (r *Repository) enrichChatRoom(chatRoom *model.ChatRoom) {
|
||||
if contact, ok := r.contactCache[chatRoom.Name]; ok {
|
||||
chatRoom.Remark = contact.Remark
|
||||
chatRoom.NickName = contact.NickName
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Repository) findChatRoom(key string) *model.ChatRoom {
|
||||
if chatRoom, ok := r.chatRoomCache[key]; ok {
|
||||
return chatRoom
|
||||
}
|
||||
if chatRoom, ok := r.remarkToChatRoom[key]; ok {
|
||||
return chatRoom
|
||||
}
|
||||
if chatRoom, ok := r.nickNameToChatRoom[key]; ok {
|
||||
return chatRoom
|
||||
}
|
||||
|
||||
// Contain
|
||||
for _, remark := range r.chatRoomRemark {
|
||||
if strings.Contains(remark, key) {
|
||||
return r.remarkToChatRoom[remark]
|
||||
}
|
||||
}
|
||||
for _, nickName := range r.chatRoomNickName {
|
||||
if strings.Contains(nickName, key) {
|
||||
return r.nickNameToChatRoom[nickName]
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) findChatRooms(key string) []*model.ChatRoom {
|
||||
ret := make([]*model.ChatRoom, 0)
|
||||
distinct := make(map[string]bool)
|
||||
if chatRoom, ok := r.chatRoomCache[key]; ok {
|
||||
ret = append(ret, chatRoom)
|
||||
distinct[chatRoom.Name] = true
|
||||
}
|
||||
if chatRoom, ok := r.remarkToChatRoom[key]; ok && !distinct[chatRoom.Name] {
|
||||
ret = append(ret, chatRoom)
|
||||
distinct[chatRoom.Name] = true
|
||||
}
|
||||
if chatRoom, ok := r.nickNameToChatRoom[key]; ok && !distinct[chatRoom.Name] {
|
||||
ret = append(ret, chatRoom)
|
||||
distinct[chatRoom.Name] = true
|
||||
}
|
||||
|
||||
// Contain
|
||||
for _, remark := range r.chatRoomRemark {
|
||||
if strings.Contains(remark, key) && !distinct[r.remarkToChatRoom[remark].Name] {
|
||||
ret = append(ret, r.remarkToChatRoom[remark])
|
||||
distinct[r.remarkToChatRoom[remark].Name] = true
|
||||
}
|
||||
}
|
||||
for _, nickName := range r.chatRoomNickName {
|
||||
if strings.Contains(nickName, key) && !distinct[r.nickNameToChatRoom[nickName].Name] {
|
||||
ret = append(ret, r.nickNameToChatRoom[nickName])
|
||||
distinct[r.nickNameToChatRoom[nickName].Name] = true
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
211
internal/wechatdb/repository/contact.go
Normal file
211
internal/wechatdb/repository/contact.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/model"
|
||||
)
|
||||
|
||||
// initContactCache 初始化联系人缓存
|
||||
func (r *Repository) initContactCache(ctx context.Context) error {
|
||||
// 加载所有联系人到缓存
|
||||
contacts, err := r.ds.GetContacts(ctx, "", 0, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("加载联系人失败: %w", err)
|
||||
}
|
||||
|
||||
contactMap := make(map[string]*model.Contact)
|
||||
aliasMap := make(map[string]*model.Contact)
|
||||
remarkMap := make(map[string]*model.Contact)
|
||||
nickNameMap := make(map[string]*model.Contact)
|
||||
chatRoomUserMap := make(map[string]*model.Contact)
|
||||
chatRoomInContactMap := make(map[string]*model.Contact)
|
||||
contactList := make([]string, 0)
|
||||
aliasList := make([]string, 0)
|
||||
remarkList := make([]string, 0)
|
||||
nickNameList := make([]string, 0)
|
||||
|
||||
for _, contact := range contacts {
|
||||
contactMap[contact.UserName] = contact
|
||||
contactList = append(contactList, contact.UserName)
|
||||
|
||||
// 建立快速查找索引
|
||||
if contact.Alias != "" {
|
||||
aliasMap[contact.Alias] = contact
|
||||
aliasList = append(aliasList, contact.Alias)
|
||||
}
|
||||
if contact.Remark != "" {
|
||||
remarkMap[contact.Remark] = contact
|
||||
remarkList = append(remarkList, contact.Remark)
|
||||
}
|
||||
if contact.NickName != "" {
|
||||
nickNameMap[contact.NickName] = contact
|
||||
nickNameList = append(nickNameList, contact.NickName)
|
||||
}
|
||||
|
||||
// 如果是群聊成员(非好友),添加到群聊成员索引
|
||||
if !contact.IsFriend {
|
||||
chatRoomUserMap[contact.UserName] = contact
|
||||
}
|
||||
|
||||
if strings.HasSuffix(contact.UserName, "@chatroom") {
|
||||
chatRoomInContactMap[contact.UserName] = contact
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(contactList)
|
||||
sort.Strings(aliasList)
|
||||
sort.Strings(remarkList)
|
||||
sort.Strings(nickNameList)
|
||||
|
||||
r.contactCache = contactMap
|
||||
r.aliasToContact = aliasMap
|
||||
r.remarkToContact = remarkMap
|
||||
r.nickNameToContact = nickNameMap
|
||||
r.chatRoomUserToInfo = chatRoomUserMap
|
||||
r.chatRoomInContact = chatRoomInContactMap
|
||||
r.contactList = contactList
|
||||
r.aliasList = aliasList
|
||||
r.remarkList = remarkList
|
||||
r.nickNameList = nickNameList
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetContact(ctx context.Context, key string) (*model.Contact, error) {
|
||||
// 先尝试从缓存中获取
|
||||
contact := r.findContact(key)
|
||||
if contact == nil {
|
||||
return nil, fmt.Errorf("未找到联系人: %s", key)
|
||||
}
|
||||
return contact, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetContacts(ctx context.Context, key string, limit, offset int) ([]*model.Contact, error) {
|
||||
ret := make([]*model.Contact, 0)
|
||||
if key != "" {
|
||||
ret = r.findContacts(key)
|
||||
if len(ret) == 0 {
|
||||
return nil, fmt.Errorf("未找到联系人: %s", key)
|
||||
}
|
||||
if limit > 0 {
|
||||
end := offset + limit
|
||||
if end > len(ret) {
|
||||
end = len(ret)
|
||||
}
|
||||
if offset >= len(ret) {
|
||||
return []*model.Contact{}, nil
|
||||
}
|
||||
return ret[offset:end], nil
|
||||
}
|
||||
} else {
|
||||
list := r.contactList
|
||||
if limit > 0 {
|
||||
end := offset + limit
|
||||
if end > len(list) {
|
||||
end = len(list)
|
||||
}
|
||||
if offset >= len(list) {
|
||||
return []*model.Contact{}, nil
|
||||
}
|
||||
list = list[offset:end]
|
||||
}
|
||||
for _, name := range list {
|
||||
ret = append(ret, r.contactCache[name])
|
||||
}
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *Repository) findContact(key string) *model.Contact {
|
||||
if contact, ok := r.contactCache[key]; ok {
|
||||
return contact
|
||||
}
|
||||
if contact, ok := r.aliasToContact[key]; ok {
|
||||
return contact
|
||||
}
|
||||
if contact, ok := r.remarkToContact[key]; ok {
|
||||
return contact
|
||||
}
|
||||
if contact, ok := r.nickNameToContact[key]; ok {
|
||||
return contact
|
||||
}
|
||||
|
||||
// Contain
|
||||
for _, alias := range r.aliasList {
|
||||
if strings.Contains(alias, key) {
|
||||
return r.aliasToContact[alias]
|
||||
}
|
||||
}
|
||||
for _, remark := range r.remarkList {
|
||||
if strings.Contains(remark, key) {
|
||||
return r.remarkToContact[remark]
|
||||
}
|
||||
}
|
||||
for _, nickName := range r.nickNameList {
|
||||
if strings.Contains(nickName, key) {
|
||||
return r.nickNameToContact[nickName]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) findContacts(key string) []*model.Contact {
|
||||
ret := make([]*model.Contact, 0)
|
||||
distinct := make(map[string]bool)
|
||||
if contact, ok := r.contactCache[key]; ok {
|
||||
ret = append(ret, contact)
|
||||
distinct[contact.UserName] = true
|
||||
}
|
||||
if contact, ok := r.aliasToContact[key]; ok && !distinct[contact.UserName] {
|
||||
ret = append(ret, contact)
|
||||
distinct[contact.UserName] = true
|
||||
}
|
||||
if contact, ok := r.remarkToContact[key]; ok && !distinct[contact.UserName] {
|
||||
ret = append(ret, contact)
|
||||
distinct[contact.UserName] = true
|
||||
}
|
||||
if contact, ok := r.nickNameToContact[key]; ok && !distinct[contact.UserName] {
|
||||
ret = append(ret, contact)
|
||||
distinct[contact.UserName] = true
|
||||
}
|
||||
// Contain
|
||||
for _, alias := range r.aliasList {
|
||||
if strings.Contains(alias, key) && !distinct[r.aliasToContact[alias].UserName] {
|
||||
ret = append(ret, r.aliasToContact[alias])
|
||||
distinct[r.aliasToContact[alias].UserName] = true
|
||||
}
|
||||
}
|
||||
for _, remark := range r.remarkList {
|
||||
if strings.Contains(remark, key) && !distinct[r.remarkToContact[remark].UserName] {
|
||||
ret = append(ret, r.remarkToContact[remark])
|
||||
distinct[r.remarkToContact[remark].UserName] = true
|
||||
}
|
||||
}
|
||||
for _, nickName := range r.nickNameList {
|
||||
if strings.Contains(nickName, key) && !distinct[r.nickNameToContact[nickName].UserName] {
|
||||
ret = append(ret, r.nickNameToContact[nickName])
|
||||
distinct[r.nickNameToContact[nickName].UserName] = true
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// getFullContact 获取联系人信息,包括群聊成员
|
||||
func (r *Repository) getFullContact(userName string) *model.Contact {
|
||||
// 先查找联系人缓存
|
||||
if contact, ok := r.contactCache[userName]; ok {
|
||||
return contact
|
||||
}
|
||||
|
||||
// 再查找群聊成员缓存
|
||||
contact, ok := r.chatRoomUserToInfo[userName]
|
||||
|
||||
if ok {
|
||||
return contact
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
68
internal/wechatdb/repository/message.go
Normal file
68
internal/wechatdb/repository/message.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/model"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// GetMessages 实现 Repository 接口的 GetMessages 方法
|
||||
func (r *Repository) GetMessages(ctx context.Context, startTime, endTime time.Time, talker string, limit, offset int) ([]*model.Message, error) {
|
||||
|
||||
if contact, _ := r.GetContact(ctx, talker); contact != nil {
|
||||
talker = contact.UserName
|
||||
} else if chatRoom, _ := r.GetChatRoom(ctx, talker); chatRoom != nil {
|
||||
talker = chatRoom.Name
|
||||
}
|
||||
|
||||
messages, err := r.ds.GetMessages(ctx, startTime, endTime, talker, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 补充消息信息
|
||||
if err := r.EnrichMessages(ctx, messages); err != nil {
|
||||
log.Debugf("EnrichMessages failed: %v", err)
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// EnrichMessages 补充消息的额外信息
|
||||
func (r *Repository) EnrichMessages(ctx context.Context, messages []*model.Message) error {
|
||||
for _, msg := range messages {
|
||||
r.enrichMessage(msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// enrichMessage 补充单条消息的额外信息
|
||||
func (r *Repository) enrichMessage(msg *model.Message) {
|
||||
talker := msg.Talker
|
||||
|
||||
// 处理群聊消息
|
||||
if msg.IsChatRoom {
|
||||
talker = msg.ChatRoomSender
|
||||
|
||||
// 补充群聊名称
|
||||
if chatRoom, ok := r.chatRoomCache[msg.Talker]; ok {
|
||||
msg.ChatRoomName = chatRoom.DisplayName()
|
||||
|
||||
// 补充发送者在群里的显示名称
|
||||
if displayName, ok := chatRoom.User2DisplayName[talker]; ok {
|
||||
msg.DisplayName = displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果不是自己发送的消息且还没有显示名称,尝试补充发送者信息
|
||||
if msg.DisplayName == "" && msg.IsSender != 1 {
|
||||
contact := r.getFullContact(talker)
|
||||
if contact != nil {
|
||||
msg.DisplayName = contact.DisplayName()
|
||||
}
|
||||
}
|
||||
}
|
||||
85
internal/wechatdb/repository/repository.go
Normal file
85
internal/wechatdb/repository/repository.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/model"
|
||||
"github.com/sjzar/chatlog/internal/wechatdb/datasource"
|
||||
)
|
||||
|
||||
// Repository 实现了 repository.Repository 接口
|
||||
type Repository struct {
|
||||
ds datasource.DataSource
|
||||
|
||||
// Cache for contact
|
||||
contactCache map[string]*model.Contact
|
||||
aliasToContact map[string]*model.Contact
|
||||
remarkToContact map[string]*model.Contact
|
||||
nickNameToContact map[string]*model.Contact
|
||||
chatRoomInContact map[string]*model.Contact
|
||||
contactList []string
|
||||
aliasList []string
|
||||
remarkList []string
|
||||
nickNameList []string
|
||||
|
||||
// Cache for chat room
|
||||
chatRoomCache map[string]*model.ChatRoom
|
||||
remarkToChatRoom map[string]*model.ChatRoom
|
||||
nickNameToChatRoom map[string]*model.ChatRoom
|
||||
chatRoomList []string
|
||||
chatRoomRemark []string
|
||||
chatRoomNickName []string
|
||||
|
||||
// 快速查找索引
|
||||
chatRoomUserToInfo map[string]*model.Contact
|
||||
}
|
||||
|
||||
// New 创建一个新的 Repository
|
||||
func New(ds datasource.DataSource) (*Repository, error) {
|
||||
r := &Repository{
|
||||
ds: ds,
|
||||
contactCache: make(map[string]*model.Contact),
|
||||
aliasToContact: make(map[string]*model.Contact),
|
||||
remarkToContact: make(map[string]*model.Contact),
|
||||
nickNameToContact: make(map[string]*model.Contact),
|
||||
chatRoomUserToInfo: make(map[string]*model.Contact),
|
||||
contactList: make([]string, 0),
|
||||
aliasList: make([]string, 0),
|
||||
remarkList: make([]string, 0),
|
||||
nickNameList: make([]string, 0),
|
||||
chatRoomCache: make(map[string]*model.ChatRoom),
|
||||
remarkToChatRoom: make(map[string]*model.ChatRoom),
|
||||
nickNameToChatRoom: make(map[string]*model.ChatRoom),
|
||||
chatRoomList: make([]string, 0),
|
||||
chatRoomRemark: make([]string, 0),
|
||||
chatRoomNickName: make([]string, 0),
|
||||
}
|
||||
|
||||
// 初始化缓存
|
||||
if err := r.initCache(context.Background()); err != nil {
|
||||
return nil, fmt.Errorf("初始化缓存失败: %w", err)
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// initCache 初始化缓存
|
||||
func (r *Repository) initCache(ctx context.Context) error {
|
||||
// 初始化联系人缓存
|
||||
if err := r.initContactCache(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 初始化群聊缓存
|
||||
if err := r.initChatRoomCache(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close 实现 Repository 接口的 Close 方法
|
||||
func (r *Repository) Close() error {
|
||||
return r.ds.Close()
|
||||
}
|
||||
11
internal/wechatdb/repository/session.go
Normal file
11
internal/wechatdb/repository/session.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/sjzar/chatlog/internal/model"
|
||||
)
|
||||
|
||||
func (r *Repository) GetSessions(ctx context.Context, key string, limit, offset int) ([]*model.Session, error) {
|
||||
return r.ds.GetSessions(ctx, key, limit, offset)
|
||||
}
|
||||
@@ -1,25 +1,31 @@
|
||||
package wechatdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/sjzar/chatlog/pkg/model"
|
||||
"github.com/sjzar/chatlog/internal/model"
|
||||
"github.com/sjzar/chatlog/internal/wechatdb/datasource"
|
||||
"github.com/sjzar/chatlog/internal/wechatdb/repository"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
BasePath string
|
||||
Version int
|
||||
|
||||
contact *Contact
|
||||
message *Message
|
||||
path string
|
||||
platform string
|
||||
version int
|
||||
ds datasource.DataSource
|
||||
repo *repository.Repository
|
||||
}
|
||||
|
||||
func New(path string, version int) (*DB, error) {
|
||||
func New(path string, platform string, version int) (*DB, error) {
|
||||
|
||||
w := &DB{
|
||||
BasePath: path,
|
||||
Version: version,
|
||||
path: path,
|
||||
platform: platform,
|
||||
version: version,
|
||||
}
|
||||
|
||||
// 初始化,加载数据库文件信息
|
||||
@@ -31,87 +37,87 @@ func New(path string, version int) (*DB, error) {
|
||||
}
|
||||
|
||||
func (w *DB) Close() error {
|
||||
if w.repo != nil {
|
||||
return w.repo.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *DB) Initialize() error {
|
||||
|
||||
var err error
|
||||
w.message, err = NewMessage(w.BasePath, w.Version)
|
||||
w.ds, err = datasource.NewDataSource(w.path, w.platform, w.version)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("初始化数据源失败: %w", err)
|
||||
}
|
||||
|
||||
w.contact, err = NewContact(w.BasePath, w.Version)
|
||||
w.repo, err = repository.New(w.ds)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("初始化仓库失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *DB) GetMessages(start, end time.Time, talker string, limit, offset int) ([]*model.Message, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
if talker != "" {
|
||||
if contact := w.contact.GetContact(talker); contact != nil {
|
||||
talker = contact.UserName
|
||||
}
|
||||
}
|
||||
|
||||
messages, err := w.message.GetMessages(start, end, talker, limit, offset)
|
||||
// 使用 repository 获取消息
|
||||
messages, err := w.repo.GetMessages(ctx, start, end, talker, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range messages {
|
||||
w.contact.MessageFillInfo(messages[i])
|
||||
return nil, fmt.Errorf("获取消息失败: %w", err)
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
type ListContactResp struct {
|
||||
type GetContactsResp struct {
|
||||
Items []*model.Contact `json:"items"`
|
||||
}
|
||||
|
||||
func (w *DB) ListContact() (*ListContactResp, error) {
|
||||
list, err := w.contact.ListContact()
|
||||
func (w *DB) GetContacts(key string, limit, offset int) (*GetContactsResp, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
contacts, err := w.repo.GetContacts(ctx, key, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ListContactResp{
|
||||
Items: list,
|
||||
|
||||
return &GetContactsResp{
|
||||
Items: contacts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *DB) GetContact(userName string) *model.Contact {
|
||||
return w.contact.GetContact(userName)
|
||||
}
|
||||
|
||||
type ListChatRoomResp struct {
|
||||
type GetChatRoomsResp struct {
|
||||
Items []*model.ChatRoom `json:"items"`
|
||||
}
|
||||
|
||||
func (w *DB) ListChatRoom() (*ListChatRoomResp, error) {
|
||||
list, err := w.contact.ListChatRoom()
|
||||
func (w *DB) GetChatRooms(key string, limit, offset int) (*GetChatRoomsResp, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
chatRooms, err := w.repo.GetChatRooms(ctx, key, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ListChatRoomResp{
|
||||
Items: list,
|
||||
|
||||
return &GetChatRoomsResp{
|
||||
Items: chatRooms,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *DB) GetChatRoom(userName string) *model.ChatRoom {
|
||||
return w.contact.GetChatRoom(userName)
|
||||
}
|
||||
|
||||
type GetSessionResp struct {
|
||||
type GetSessionsResp struct {
|
||||
Items []*model.Session `json:"items"`
|
||||
}
|
||||
|
||||
func (w *DB) GetSession(limit int) (*GetSessionResp, error) {
|
||||
sessions := w.contact.GetSession(limit)
|
||||
return &GetSessionResp{
|
||||
func (w *DB) GetSessions(key string, limit, offset int) (*GetSessionsResp, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
// 使用 repository 获取会话列表
|
||||
sessions, err := w.repo.GetSessions(ctx, key, limit, offset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取会话列表失败: %w", err)
|
||||
}
|
||||
|
||||
return &GetSessionsResp{
|
||||
Items: sessions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user