Files
chatlog_alpha/internal/chatlog/http/route.go
lx1056758714-glitch 428dabe05a Add MCP media tools, real-time subscriptions, and UI
Major MCP extension: adds tools for media content retrieval, OCR, real-time message subscription with webhook push, chat activity analysis, user profile, and shared file search. Implements persistent subscription management, new prompt templates, and real-time resource endpoints. Updates UI to display active MCP subscriptions, enhances CSV export with MessageID, and documents all new features in the changelog. Removes Dockerfile and docker-compose.yml.
2025-12-18 21:37:55 +08:00

575 lines
15 KiB
Go

package http
import (
"embed"
"encoding/csv"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
"github.com/sjzar/chatlog/internal/errors"
"github.com/sjzar/chatlog/pkg/util"
"github.com/sjzar/chatlog/pkg/util/dat2img"
"github.com/sjzar/chatlog/pkg/util/silk"
)
// EFS holds embedded file system data for static assets.
//
//go:embed static
var EFS embed.FS
func (s *Service) initRouter() {
s.initBaseRouter()
s.initMediaRouter()
s.initAPIRouter()
s.initMCPRouter()
}
func (s *Service) initBaseRouter() {
staticDir, _ := fs.Sub(EFS, "static")
s.router.StaticFS("/static", http.FS(staticDir))
s.router.StaticFileFS("/favicon.ico", "./favicon.ico", http.FS(staticDir))
s.router.StaticFileFS("/", "./index.htm", http.FS(staticDir))
s.router.GET("/health", func(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"status": "ok"})
})
s.router.NoRoute(s.NoRoute)
}
func (s *Service) initMediaRouter() {
s.router.GET("/image/*key", func(c *gin.Context) { s.handleMedia(c, "image") })
s.router.GET("/video/*key", func(c *gin.Context) { s.handleMedia(c, "video") })
s.router.GET("/file/*key", func(c *gin.Context) { s.handleMedia(c, "file") })
s.router.GET("/voice/*key", func(c *gin.Context) { s.handleMedia(c, "voice") })
s.router.GET("/data/*path", s.handleMediaData)
}
func (s *Service) initAPIRouter() {
api := s.router.Group("/api/v1", s.checkDBStateMiddleware())
{
api.GET("/chatlog", s.handleChatlog)
api.GET("/contact", s.handleContacts)
api.GET("/chatroom", s.handleChatRooms)
api.GET("/session", s.handleSessions)
api.POST("/cache/clear", s.handleClearCache)
}
}
func (s *Service) initMCPRouter() {
s.router.Any("/mcp", func(c *gin.Context) {
s.mcpStreamableServer.ServeHTTP(c.Writer, c.Request)
})
s.router.Any("/sse", func(c *gin.Context) {
s.mcpSSEServer.ServeHTTP(c.Writer, c.Request)
})
s.router.Any("/message", func(c *gin.Context) {
s.mcpSSEServer.ServeHTTP(c.Writer, c.Request)
})
}
// NoRoute handles 404 Not Found errors. If the request URL starts with "/api"
// or "/static", it responds with a JSON error. Otherwise, it redirects to the root path.
func (s *Service) NoRoute(c *gin.Context) {
path := c.Request.URL.Path
switch {
case strings.HasPrefix(path, "/api"), strings.HasPrefix(path, "/static"):
c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
default:
c.Header("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate, value")
c.Redirect(http.StatusFound, "/")
}
}
func (s *Service) handleChatlog(c *gin.Context) {
q := struct {
Time string `form:"time"`
Talker string `form:"talker"`
Sender string `form:"sender"`
Keyword string `form:"keyword"`
Limit int `form:"limit"`
Offset int `form:"offset"`
Format string `form:"format"`
}{}
if err := c.BindQuery(&q); err != nil {
errors.Err(c, err)
return
}
var err error
start, end, ok := util.TimeRangeOf(q.Time)
if !ok {
errors.Err(c, errors.InvalidArg("time"))
}
if q.Limit < 0 {
q.Limit = 0
}
if q.Offset < 0 {
q.Offset = 0
}
messages, err := s.db.GetMessages(start, end, q.Talker, q.Sender, q.Keyword, q.Limit, q.Offset)
if err != nil {
errors.Err(c, err)
return
}
switch strings.ToLower(q.Format) {
case "csv":
c.Writer.Header().Set("Content-Type", "text/csv; charset=utf-8")
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s_%s_%s.csv", q.Talker, start.Format("2006-01-02"), end.Format("2006-01-02")))
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Flush()
csvWriter := csv.NewWriter(c.Writer)
csvWriter.Write([]string{"MessageID", "Time", "SenderName", "Sender", "TalkerName", "Talker", "Content"})
for _, m := range messages {
csvWriter.Write(m.CSV(c.Request.Host))
}
csvWriter.Flush()
case "json":
// json
c.JSON(http.StatusOK, messages)
default:
// plain text
c.Writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Flush()
for _, m := range messages {
c.Writer.WriteString(m.PlainText(strings.Contains(q.Talker, ","), util.PerfectTimeFormat(start, end), c.Request.Host))
c.Writer.WriteString("\n")
c.Writer.Flush()
}
}
}
func (s *Service) handleContacts(c *gin.Context) {
q := struct {
Keyword string `form:"keyword"`
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.Keyword, q.Limit, q.Offset)
if err != nil {
errors.Err(c, err)
return
}
format := strings.ToLower(q.Format)
switch format {
case "json":
// json
c.JSON(http.StatusOK, list)
default:
// csv
if format == "csv" {
// 浏览器访问时,会下载文件
c.Writer.Header().Set("Content-Type", "text/csv; charset=utf-8")
} else {
c.Writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
}
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Flush()
c.Writer.WriteString("UserName,Alias,Remark,NickName\n")
for _, contact := range list.Items {
c.Writer.WriteString(fmt.Sprintf("%s,%s,%s,%s\n", contact.UserName, contact.Alias, contact.Remark, contact.NickName))
}
c.Writer.Flush()
}
}
func (s *Service) handleChatRooms(c *gin.Context) {
q := struct {
Keyword string `form:"keyword"`
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.GetChatRooms(q.Keyword, q.Limit, q.Offset)
if err != nil {
errors.Err(c, err)
return
}
format := strings.ToLower(q.Format)
switch format {
case "json":
// json
c.JSON(http.StatusOK, list)
default:
// csv
if format == "csv" {
// 浏览器访问时,会下载文件
c.Writer.Header().Set("Content-Type", "text/csv; charset=utf-8")
} else {
c.Writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
}
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Flush()
c.Writer.WriteString("Name,Remark,NickName,Owner,UserCount\n")
for _, chatRoom := range list.Items {
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) handleSessions(c *gin.Context) {
q := struct {
Keyword string `form:"keyword"`
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.GetSessions(q.Keyword, q.Limit, q.Offset)
if err != nil {
errors.Err(c, err)
return
}
format := strings.ToLower(q.Format)
switch format {
case "csv":
c.Writer.Header().Set("Content-Type", "text/csv; charset=utf-8")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Flush()
c.Writer.WriteString("UserName,NOrder,NickName,Content,NTime\n")
for _, session := range sessions.Items {
c.Writer.WriteString(fmt.Sprintf("%s,%d,%s,%s,%s\n", session.UserName, session.NOrder, session.NickName, strings.ReplaceAll(session.Content, "\n", "\\n"), session.NTime))
}
c.Writer.Flush()
case "json":
// json
c.JSON(http.StatusOK, sessions)
default:
c.Writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Flush()
for _, session := range sessions.Items {
c.Writer.WriteString(session.PlainText(120))
c.Writer.WriteString("\n")
}
c.Writer.Flush()
}
}
func (s *Service) handleMedia(c *gin.Context, _type string) {
key := strings.TrimPrefix(c.Param("key"), "/")
if key == "" {
errors.Err(c, errors.InvalidArg(key))
return
}
keys := util.Str2List(key, ",")
if len(keys) == 0 {
errors.Err(c, errors.InvalidArg(key))
return
}
var _err error
for _, k := range keys {
if strings.Contains(k, "/") {
if absolutePath, err := s.findPath(_type, k); err == nil {
c.Redirect(http.StatusFound, "/data/"+absolutePath)
return
}
}
media, err := s.db.GetMedia(_type, k)
if err != nil {
_err = err
continue
}
if c.Query("info") != "" {
c.JSON(http.StatusOK, media)
return
}
switch media.Type {
case "voice":
s.HandleVoice(c, media.Data)
return
case "image":
// If it's not a .dat file, redirect to the data handler as before.
if !strings.HasSuffix(strings.ToLower(media.Path), ".dat") {
c.Redirect(http.StatusFound, "/data/"+media.Path)
return
}
// It is a .dat file. Decrypt, save, and redirect to the new file.
absolutePath := filepath.Join(s.conf.GetDataDir(), media.Path)
// Build the potential output path to check if it exists
var newRelativePath string
outputPath := strings.TrimSuffix(absolutePath, filepath.Ext(absolutePath))
relativePathBase := strings.TrimSuffix(media.Path, filepath.Ext(media.Path))
// Check if a converted file already exists
for _, ext := range []string{".jpg", ".png", ".gif", ".jpeg", ".bmp", ".mp4"} {
if _, err := os.Stat(outputPath + ext); err == nil {
newRelativePath = relativePathBase + ext
break
}
}
// If a converted file is found, redirect to it immediately
if newRelativePath != "" {
c.Redirect(http.StatusFound, "/data/"+newRelativePath)
return
}
// If not found, decrypt and save it
b, err := os.ReadFile(absolutePath)
if err != nil {
errors.Err(c, err)
return
}
out, ext, err := dat2img.Dat2Image(b)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to parse .dat file",
"reason": err.Error(),
"path": absolutePath,
})
return
}
// Save the decrypted file. s.saveDecryptedFile handles the existence check.
s.saveDecryptedFile(absolutePath, out, ext)
// Build the new relative path and redirect
newRelativePath = relativePathBase + "." + ext
c.Redirect(http.StatusFound, "/data/"+newRelativePath)
return
default:
// For other types, keep the old redirect logic
c.Redirect(http.StatusFound, "/data/"+media.Path)
return
}
}
if _err != nil {
errors.Err(c, _err)
return
}
}
func (s *Service) findPath(_type string, key string) (string, error) {
absolutePath := filepath.Join(s.conf.GetDataDir(), key)
if _, err := os.Stat(absolutePath); err == nil {
return key, nil
}
switch _type {
case "image":
for _, suffix := range []string{"_h.dat", ".dat", "_t.dat"} {
if _, err := os.Stat(absolutePath + suffix); err == nil {
return key + suffix, nil
}
}
case "video":
for _, suffix := range []string{".mp4", "_thumb.jpg"} {
if _, err := os.Stat(absolutePath + suffix); err == nil {
return key + suffix, nil
}
}
}
return "", errors.ErrMediaNotFound
}
func (s *Service) handleMediaData(c *gin.Context) {
relativePath := filepath.Clean(c.Param("path"))
absolutePath := filepath.Join(s.conf.GetDataDir(), relativePath)
if _, err := os.Stat(absolutePath); os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{
"error": "File not found",
})
return
}
ext := strings.ToLower(filepath.Ext(absolutePath))
switch {
case ext == ".dat":
s.HandleDatFile(c, absolutePath)
default:
// 直接返回文件
c.File(absolutePath)
}
}
func (s *Service) HandleDatFile(c *gin.Context, path string) {
b, err := os.ReadFile(path)
if err != nil {
errors.Err(c, err)
return
}
out, ext, err := dat2img.Dat2Image(b)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to parse .dat file",
"reason": err.Error(),
"path": path,
})
return
}
// Save decrypted file to local disk
if s.conf.GetSaveDecryptedMedia() {
s.saveDecryptedFile(path, out, ext)
}
switch ext {
case "jpg", "jpeg":
c.Data(http.StatusOK, "image/jpeg", out)
case "png":
c.Data(http.StatusOK, "image/png", out)
case "gif":
c.Data(http.StatusOK, "image/gif", out)
case "bmp":
c.Data(http.StatusOK, "image/bmp", out)
case "mp4":
c.Data(http.StatusOK, "video/mp4", out)
default:
c.Data(http.StatusOK, "image/jpg", out)
// c.File(path)
}
}
func (s *Service) HandleVoice(c *gin.Context, data []byte) {
out, err := silk.Silk2MP3(data)
if err != nil {
c.Data(http.StatusOK, "audio/silk", data)
return
}
c.Data(http.StatusOK, "audio/mp3", out)
}
// saveDecryptedFile saves the decrypted media file to local disk
func (s *Service) saveDecryptedFile(datPath string, data []byte, ext string) {
// Generate target file path: replace .dat with actual extension
outputPath := strings.TrimSuffix(datPath, filepath.Ext(datPath)) + "." + ext
// Check if file already exists to avoid duplicate writes
if _, err := os.Stat(outputPath); err == nil {
return
}
// Write file
if err := os.WriteFile(outputPath, data, 0644); err != nil {
log.Error().
Err(err).
Str("dat_path", datPath).
Str("output_path", outputPath).
Msg("Failed to save decrypted file")
return
}
log.Debug().
Str("dat_path", datPath).
Str("output_path", outputPath).
Str("format", ext).
Int("size", len(data)).
Msg("Decrypted file saved successfully")
}
func (s *Service) handleClearCache(c *gin.Context) {
dataDir := s.conf.GetDataDir()
if dataDir == "" {
errors.Err(c, fmt.Errorf("data directory not configured"))
return
}
deletedCount := 0
err := filepath.Walk(dataDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil // Skip directories
}
ext := strings.ToLower(filepath.Ext(path))
// List of generated extensions
generatedExts := map[string]struct{}{
".jpg": {}, ".jpeg": {}, ".png": {}, ".gif": {}, ".bmp": {}, ".mp4": {},
}
if _, isGenerated := generatedExts[ext]; isGenerated {
baseName := strings.TrimSuffix(path, ext)
// Check for corresponding .dat file. WeChat can use various suffixes.
datSuffixes := []string{".dat", "_h.dat", "_t.dat"}
for _, datSuffix := range datSuffixes {
datPath := baseName + datSuffix
if _, statErr := os.Stat(datPath); statErr == nil {
// Found a corresponding .dat file, so this is a cached file.
if removeErr := os.Remove(path); removeErr == nil {
deletedCount++
} else {
log.Warn().Err(removeErr).Str("path", path).Msg("Failed to remove cached file")
}
// Once we find a .dat pair and delete, no need to check other suffixes
return nil
}
}
}
return nil
})
if err != nil {
errors.Err(c, fmt.Errorf("failed to walk data directory: %w", err))
return
}
log.Info().Int("count", deletedCount).Msg("Cleared decrypted file cache")
c.JSON(http.StatusOK, gin.H{
"message": "Cache cleared successfully",
"deletedCount": deletedCount,
})
}