├── .dockerignore ├── .deploy ├── local │ ├── secrets │ │ ├── redis_password.txt │ │ ├── mysql_root_password.txt │ │ └── mysql_user_password.txt │ ├── reset.sh │ ├── reset.bat │ ├── redis.conf │ ├── nginx.conf │ └── my.cnf └── server │ ├── secrets │ ├── redis_password.txt │ ├── mysql_root_password.txt │ └── mysql_user_password.txt │ ├── redis.conf │ ├── nginx.conf │ └── my.cnf ├── pkg ├── robot │ ├── embed.go │ ├── xml │ │ ├── welcome_new.xml │ │ ├── music.xml │ │ └── app_tail.xml │ ├── heartbeat.go │ ├── share_link.go │ └── common.go ├── good_morning │ ├── .DS_Store │ ├── embed.go │ ├── assets │ │ ├── simkai.ttf │ │ ├── background.png │ │ ├── background2.png │ │ └── background3.png │ └── good_morning_test.go ├── gtool │ └── gtool.go ├── shutdown │ ├── redis_connection.go │ ├── db_connection.go │ └── manage.go ├── gormx │ └── gormx.go ├── utils │ └── cron.go ├── appx │ ├── req.go │ └── resp.go └── mcp │ ├── types.go │ ├── errors.go │ └── client.go ├── .gitignore ├── dto ├── wx_app.go ├── system_message.go ├── attach_download.go ├── friend_settings.go ├── word_cloud.go ├── chat_history.go ├── ai_voice.callback.go ├── login.go ├── contact.go ├── moments.go ├── chat_room.go └── message.go ├── controller ├── probe.go ├── wx_app.go ├── ai_callback.go ├── chat_history.go ├── oss_settings.go ├── system_message.go ├── system_settings.go ├── friend_settings.go ├── wechat_server_callback.go ├── chat_room_settings.go ├── pprof_proxy.go ├── contact.go ├── mcp_server.go └── attach_download.go ├── plugin ├── plugin.go ├── plugins │ ├── auto_join_group.go │ ├── friend_chat.go │ ├── image_auto_upload.go │ ├── pat.go │ ├── ai_image_upload.go │ ├── douyin_video_parse.go │ └── chatroom_chat.go └── pkg │ └── doubao_tts.go ├── service ├── wx_app.go ├── admin.go ├── message_sender_adapter.go ├── chat_history.go ├── system_message.go ├── ai_task.go ├── global_settings.go ├── word_cloud.go ├── system_settings.go ├── attach_download.go ├── ai_drawing.go ├── ai_callback.go └── ai_chat.go ├── .vscode └── launch.json ├── startup ├── mcp_server.go ├── plugin.go ├── vars.go ├── config.go └── robot.go ├── dev-wechat-ipad-example.yml ├── middleware └── err_recovery.go ├── interface ├── ai │ ├── ai.go │ └── mcp.go ├── settings │ └── settings.go └── plugin │ └── message.go ├── Dockerfile ├── model ├── moment_comment.go ├── moment_settings.go ├── moment.go ├── system_message.go ├── chat_room_member.go ├── ai_task.go ├── system_settings.go ├── friend_settings.go ├── robot_admin.go ├── contact.go └── oss_settings.go ├── vars ├── settings.go ├── cron.go └── vars.go ├── LICENSE ├── utils ├── ai.go └── ai_test.go ├── repository ├── moment.go ├── oss_settings.go ├── moment_settings.go ├── system_settings.go ├── friend_settings.go ├── admin.go ├── global_settings.go ├── moment_comment.go ├── ai_task.go ├── chat_room_settings.go └── system_message.go ├── common_cron ├── sync-contact.go ├── chat_room_summary.go ├── chat_room_ranking_daily.go ├── chat_room_ranking_month.go ├── chat_room_ranking_weekly.go ├── good_morning.go ├── news.go └── word_cloud_daily.go ├── .env.example ├── .github └── workflows │ └── ci-cd.yml ├── main.go ├── CHANGELOG.md └── go.mod /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .vscode 3 | .github -------------------------------------------------------------------------------- /.deploy/local/secrets/redis_password.txt: -------------------------------------------------------------------------------- 1 | r12345678 2 | -------------------------------------------------------------------------------- /.deploy/server/secrets/redis_password.txt: -------------------------------------------------------------------------------- 1 | r12345678 2 | -------------------------------------------------------------------------------- /.deploy/local/secrets/mysql_root_password.txt: -------------------------------------------------------------------------------- 1 | mroot12345678 2 | -------------------------------------------------------------------------------- /.deploy/local/secrets/mysql_user_password.txt: -------------------------------------------------------------------------------- 1 | mwechat12345678 2 | -------------------------------------------------------------------------------- /.deploy/server/secrets/mysql_root_password.txt: -------------------------------------------------------------------------------- 1 | mroot12345678 2 | -------------------------------------------------------------------------------- /.deploy/server/secrets/mysql_user_password.txt: -------------------------------------------------------------------------------- 1 | mwechat12345678 2 | -------------------------------------------------------------------------------- /pkg/robot/embed.go: -------------------------------------------------------------------------------- 1 | package robot 2 | 3 | import "embed" 4 | 5 | //go:embed xml 6 | var XmlFolder embed.FS 7 | -------------------------------------------------------------------------------- /pkg/good_morning/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hp0912/wechat-robot-client/HEAD/pkg/good_morning/.DS_Store -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | template 3 | dev-wechat-ipad.yml 4 | wechat-robot-client 5 | pkg/good_morning/test_output 6 | __debug* -------------------------------------------------------------------------------- /pkg/good_morning/embed.go: -------------------------------------------------------------------------------- 1 | package good_morning 2 | 3 | import "embed" 4 | 5 | //go:embed assets 6 | var Assets embed.FS 7 | -------------------------------------------------------------------------------- /pkg/good_morning/assets/simkai.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hp0912/wechat-robot-client/HEAD/pkg/good_morning/assets/simkai.ttf -------------------------------------------------------------------------------- /pkg/good_morning/assets/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hp0912/wechat-robot-client/HEAD/pkg/good_morning/assets/background.png -------------------------------------------------------------------------------- /pkg/good_morning/assets/background2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hp0912/wechat-robot-client/HEAD/pkg/good_morning/assets/background2.png -------------------------------------------------------------------------------- /pkg/good_morning/assets/background3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hp0912/wechat-robot-client/HEAD/pkg/good_morning/assets/background3.png -------------------------------------------------------------------------------- /dto/wx_app.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type WxappQrcodeAuthLoginRequest struct { 4 | URL string `form:"url" json:"url" binding:"required"` 5 | } 6 | -------------------------------------------------------------------------------- /dto/system_message.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type MarkAsReadBatchRequest struct { 4 | IDs []int64 `form:"ids" json:"ids" binding:"required"` 5 | } 6 | -------------------------------------------------------------------------------- /dto/attach_download.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type AttachDownloadRequest struct { 4 | MessageID int64 `form:"message_id" json:"message_id" binding:"required"` 5 | } 6 | -------------------------------------------------------------------------------- /dto/friend_settings.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type FriendSettingsRequest struct { 4 | ContactID string `form:"contact_id" json:"contact_id" binding:"required"` 5 | } 6 | -------------------------------------------------------------------------------- /dto/word_cloud.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type WordCloudRequest struct { 4 | ChatRoomID string `json:"chat_room_id"` 5 | Content string `json:"content"` 6 | Mode string `json:"mode"` 7 | } 8 | -------------------------------------------------------------------------------- /dto/chat_history.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type ChatHistoryRequest struct { 4 | ContactID string `form:"contact_id" json:"contact_id" binding:"required"` 5 | Keyword string `form:"keyword" json:"keyword"` 6 | } 7 | -------------------------------------------------------------------------------- /pkg/robot/xml/welcome_new.xml: -------------------------------------------------------------------------------- 1 | 2 | {{ .Title }} 3 | {{ .Desc }} 4 | 5 5 | {{ .Url }} 6 | {{ .ThumbUrl }} 7 | 8 | -------------------------------------------------------------------------------- /pkg/gtool/gtool.go: -------------------------------------------------------------------------------- 1 | package gtool 2 | 3 | import ( 4 | "context" 5 | 6 | "gorm.io/gorm" 7 | ) 8 | 9 | func WithOrmContext(ctx context.Context, db *gorm.DB) *gorm.DB { 10 | if db == nil { 11 | return nil 12 | } 13 | 14 | return db.WithContext(ctx) 15 | } 16 | -------------------------------------------------------------------------------- /pkg/robot/heartbeat.go: -------------------------------------------------------------------------------- 1 | package robot 2 | 3 | type AutoHeartbeatStatusResponse struct { 4 | Success bool `json:"Success"` 5 | Code int `json:"Code"` 6 | Message string `json:"Message"` 7 | Data any `json:"Data"` 8 | Running bool `json:"Running"` 9 | } 10 | -------------------------------------------------------------------------------- /controller/probe.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | type Probe struct { 10 | } 11 | 12 | func NewProbeController() *Probe { 13 | return &Probe{} 14 | } 15 | 16 | func (p *Probe) Probe(c *gin.Context) { 17 | c.JSON(http.StatusOK, gin.H{ 18 | "success": true, 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /plugin/plugin.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import "wechat-robot-client/interface/plugin" 4 | 5 | type MessagePlugin struct { 6 | Plugins []plugin.MessageHandler 7 | } 8 | 9 | func NewMessagePlugin() *MessagePlugin { 10 | return &MessagePlugin{} 11 | } 12 | 13 | func (mp *MessagePlugin) Register(handler plugin.MessageHandler) { 14 | mp.Plugins = append(mp.Plugins, handler) 15 | } 16 | -------------------------------------------------------------------------------- /service/wx_app.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "wechat-robot-client/vars" 6 | ) 7 | 8 | type WXAppService struct { 9 | ctx context.Context 10 | } 11 | 12 | func NewWXAppService(ctx context.Context) *WXAppService { 13 | return &WXAppService{ 14 | ctx: ctx, 15 | } 16 | } 17 | 18 | func (s *WXAppService) WxappQrcodeAuthLogin(URL string) error { 19 | return vars.RobotRuntime.WxappQrcodeAuthLogin(URL) 20 | } 21 | -------------------------------------------------------------------------------- /pkg/shutdown/redis_connection.go: -------------------------------------------------------------------------------- 1 | package shutdown 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/redis/go-redis/v9" 7 | ) 8 | 9 | type RedisConnection struct { 10 | Client *redis.Client 11 | } 12 | 13 | func (r *RedisConnection) Name() string { 14 | return "redis 数据库连接" 15 | } 16 | 17 | func (r *RedisConnection) Shutdown(ctx context.Context) error { 18 | if r.Client != nil { 19 | return r.Client.Close() 20 | } 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "debug", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "debug", 12 | "program": "${workspaceFolder}/main.go", 13 | "env": { 14 | "GO_ENV": "dev" 15 | }, 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /startup/mcp_server.go: -------------------------------------------------------------------------------- 1 | package startup 2 | 3 | import ( 4 | "context" 5 | 6 | "wechat-robot-client/service" 7 | "wechat-robot-client/vars" 8 | ) 9 | 10 | func InitMCPService() error { 11 | ctx := context.Background() 12 | 13 | messageService := service.NewMessageService(ctx) 14 | messageSender := service.NewMessageSenderAdapter(messageService) 15 | vars.MCPService = service.NewMCPService(ctx, vars.DB, messageSender) 16 | 17 | err := vars.MCPService.Initialize() 18 | if err != nil { 19 | return err 20 | } 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /dev-wechat-ipad-example.yml: -------------------------------------------------------------------------------- 1 | services: 2 | ipad-test: 3 | image: registry.cn-shenzhen.aliyuncs.com/houhou/wechat-ipad:latest 4 | container_name: ipad-test 5 | restart: always 6 | networks: 7 | - wechat-robot 8 | ports: 9 | - '3010:9000' 10 | environment: 11 | WECHAT_PORT: 9000 12 | REDIS_HOST: wechat-admin-redis 13 | REDIS_PORT: 6379 14 | REDIS_PASSWORD: 123456 15 | REDIS_DB: 0 16 | WECHAT_CLIENT_HOST: 127.0.0.1:9001 17 | 18 | networks: 19 | wechat-robot: 20 | external: true -------------------------------------------------------------------------------- /middleware/err_recovery.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | ) 9 | 10 | func ErrorRecover(c *gin.Context) { 11 | defer func() { 12 | if err := recover(); err != nil { 13 | log.Printf("服务器内部错误: %v", err) 14 | message := "服务器内部错误" 15 | if err, ok := err.(error); ok { 16 | message = err.Error() 17 | } 18 | response := gin.H{"code": 500, "message": message, "data": nil} 19 | c.JSON(http.StatusOK, response) 20 | c.Abort() 21 | } 22 | }() 23 | c.Next() 24 | } 25 | -------------------------------------------------------------------------------- /.deploy/local/reset.sh: -------------------------------------------------------------------------------- 1 | docker compose down 2 | docker compose -f docker-compose2.yml down 3 | 4 | rm -rf wechat_admin_mysql_data wechat_admin_redis_data wechat-server 5 | 6 | docker pull registry.cn-shenzhen.aliyuncs.com/houhou/wechat-robot-admin-frontend:latest 7 | 8 | docker pull registry.cn-shenzhen.aliyuncs.com/houhou/wechat-robot-admin-backend:latest 9 | 10 | docker pull registry.cn-shenzhen.aliyuncs.com/houhou/wechat-robot-client:latest 11 | 12 | docker pull registry.cn-shenzhen.aliyuncs.com/houhou/wechat-ipad:latest 13 | 14 | docker compose -f docker-compose2.yml up -d -------------------------------------------------------------------------------- /interface/ai/ai.go: -------------------------------------------------------------------------------- 1 | package ai 2 | 3 | import "wechat-robot-client/model" 4 | 5 | type AIService interface { 6 | GetSessionID(message *model.Message) string 7 | SetAISession(message *model.Message) error 8 | RenewAISession(message *model.Message) error 9 | ExpireAISession(message *model.Message) error 10 | ExpireAllAISessionByChatRoomID(chatRoomID string) error 11 | IsInAISession(message *model.Message) (bool, error) 12 | IsAISessionStart(message *model.Message) bool 13 | GetAISessionStartTips() string 14 | IsAISessionEnd(message *model.Message) bool 15 | GetAISessionEndTips() string 16 | } 17 | -------------------------------------------------------------------------------- /service/admin.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "wechat-robot-client/model" 6 | "wechat-robot-client/repository" 7 | "wechat-robot-client/vars" 8 | ) 9 | 10 | type AdminService struct { 11 | ctx context.Context 12 | robotAdminRepo *repository.RobotAdmin 13 | } 14 | 15 | func NewAdminService(ctx context.Context) *AdminService { 16 | return &AdminService{ 17 | ctx: ctx, 18 | robotAdminRepo: repository.NewRobotAdminRepo(ctx, vars.AdminDB), 19 | } 20 | } 21 | 22 | func (s *AdminService) GetRobotByID(robotID int64) (*model.RobotAdmin, error) { 23 | return s.robotAdminRepo.GetByRobotID(robotID) 24 | } 25 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24.3 AS builder 2 | 3 | # 定义版本号参数 4 | ARG VERSION=unknown 5 | 6 | ENV GO111MODULE=on \ 7 | CGO_ENABLED=0 \ 8 | GIN_MODE=release \ 9 | GOPROXY=https://goproxy.cn,direct 10 | 11 | WORKDIR /app 12 | ADD go.mod go.sum ./ 13 | RUN go mod download 14 | COPY . . 15 | RUN go build -ldflags="-s -w -X main.Version=${VERSION}" -o wechat-robot-client 16 | 17 | 18 | FROM registry.cn-shenzhen.aliyuncs.com/houhou/silk-base:latest 19 | 20 | ENV GIN_MODE=release \ 21 | TZ=Asia/Shanghai 22 | 23 | WORKDIR /app 24 | 25 | COPY --from=builder /app/wechat-robot-client ./ 26 | 27 | EXPOSE 9000 28 | 29 | ENTRYPOINT [] 30 | CMD ["/app/wechat-robot-client"] -------------------------------------------------------------------------------- /pkg/gormx/gormx.go: -------------------------------------------------------------------------------- 1 | package gormx 2 | 3 | import ( 4 | "gorm.io/gorm" 5 | ) 6 | 7 | type GormUnitOfWorkIface interface { 8 | BeginTran(*gorm.DB) (*gorm.DB, error) 9 | Rollback(*gorm.DB) error 10 | Commit(*gorm.DB) error 11 | } 12 | 13 | type GormUnitOfWork struct{} 14 | 15 | func (g *GormUnitOfWork) BeginTran(db *gorm.DB) (*gorm.DB, error) { 16 | newDb := db.Begin() 17 | if newDb.Error != nil { 18 | return nil, newDb.Error 19 | } 20 | return newDb, nil 21 | } 22 | 23 | func (g *GormUnitOfWork) Rollback(db *gorm.DB) error { 24 | return db.Rollback().Error 25 | } 26 | 27 | func (g *GormUnitOfWork) Commit(db *gorm.DB) error { 28 | return db.Commit().Error 29 | } 30 | -------------------------------------------------------------------------------- /model/moment_comment.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type MomentComment struct { 4 | ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` 5 | WechatID string `gorm:"column:wechat_id;type:varchar(64);index:idx_wechat_id" json:"wechat_id"` 6 | MomentID uint64 `gorm:"column:moment_id;not null;uniqueIndex:uniq_moment_id" json:"moment_id"` 7 | Comment string `gorm:"column:comment;type:text" json:"comment"` 8 | CreatedAt int64 `gorm:"column:created_at;not null;index:idx_created_at" json:"created_at"` 9 | UpdatedAt int64 `gorm:"column:updated_at;not null" json:"updated_at"` 10 | } 11 | 12 | func (MomentComment) TableName() string { 13 | return "moment_comments" 14 | } 15 | -------------------------------------------------------------------------------- /.deploy/local/reset.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | REM 停止并移除容器 4 | docker compose down 5 | docker compose -f docker-compose2.yml down 6 | 7 | REM 删除数据文件夹和文件 8 | rmdir /s /q wechat_admin_mysql_data 9 | rmdir /s /q wechat_admin_redis_data 10 | rmdir /s /q wechat-server 11 | 12 | REM 拉取最新镜像 13 | docker pull registry.cn-shenzhen.aliyuncs.com/houhou/wechat-robot-admin-frontend:latest 14 | docker pull registry.cn-shenzhen.aliyuncs.com/houhou/wechat-robot-admin-backend:latest 15 | docker pull registry.cn-shenzhen.aliyuncs.com/houhou/wechat-robot-client:latest 16 | docker pull registry.cn-shenzhen.aliyuncs.com/houhou/wechat-ipad:latest 17 | 18 | REM 重启服务 19 | docker compose -f docker-compose2.yml up -d 20 | 21 | pause -------------------------------------------------------------------------------- /pkg/utils/cron.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "regexp" 4 | 5 | // 校验每天几点几分 6 | var dailyCron = regexp.MustCompile(`^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+\*$`) 7 | 8 | func IsDailyAtHourMinute(expr string) bool { 9 | return dailyCron.MatchString(expr) 10 | } 11 | 12 | // 校验每周一几点几分 13 | var weeklyMonCron = regexp.MustCompile(`^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+1$`) 14 | 15 | func IsWeeklyMondayAtHourMinute(expr string) bool { 16 | return weeklyMonCron.MatchString(expr) 17 | } 18 | 19 | // 校验每月1号几点几分 20 | var monthly1stCron = regexp.MustCompile(`^(\d{1,2})\s+(\d{1,2})\s+1\s+\*\s+\*$`) 21 | 22 | func IsMonthly1stAtHourMinute(expr string) bool { 23 | return monthly1stCron.MatchString(expr) 24 | } 25 | -------------------------------------------------------------------------------- /.deploy/local/redis.conf: -------------------------------------------------------------------------------- 1 | bind 0.0.0.0 2 | protected-mode yes 3 | port 6379 4 | tcp-backlog 511 5 | timeout 0 6 | tcp-keepalive 60 7 | 8 | # 基本设置 9 | loglevel notice 10 | logfile "" 11 | databases 1000 12 | 13 | # 持久化设置 14 | save 900 1 15 | save 300 10 16 | save 60 10000 17 | stop-writes-on-bgsave-error yes 18 | rdbcompression yes 19 | rdbchecksum yes 20 | dbfilename dump.rdb 21 | dir /data 22 | 23 | # AOF设置 24 | appendonly yes 25 | appendfilename "appendonly.aof" 26 | appendfsync everysec 27 | no-appendfsync-on-rewrite no 28 | auto-aof-rewrite-percentage 100 29 | auto-aof-rewrite-min-size 64mb 30 | aof-load-truncated yes 31 | 32 | # 其他优化设置 33 | activerehashing yes 34 | hz 10 35 | aof-rewrite-incremental-fsync yes 36 | -------------------------------------------------------------------------------- /.deploy/server/redis.conf: -------------------------------------------------------------------------------- 1 | bind 0.0.0.0 2 | protected-mode yes 3 | port 6379 4 | tcp-backlog 511 5 | timeout 0 6 | tcp-keepalive 60 7 | 8 | # 基本设置 9 | loglevel notice 10 | logfile "" 11 | databases 1000 12 | 13 | # 持久化设置 14 | save 900 1 15 | save 300 10 16 | save 60 10000 17 | stop-writes-on-bgsave-error yes 18 | rdbcompression yes 19 | rdbchecksum yes 20 | dbfilename dump.rdb 21 | dir /data 22 | 23 | # AOF设置 24 | appendonly yes 25 | appendfilename "appendonly.aof" 26 | appendfsync everysec 27 | no-appendfsync-on-rewrite no 28 | auto-aof-rewrite-percentage 100 29 | auto-aof-rewrite-min-size 64mb 30 | aof-load-truncated yes 31 | 32 | # 其他优化设置 33 | activerehashing yes 34 | hz 10 35 | aof-rewrite-incremental-fsync yes 36 | -------------------------------------------------------------------------------- /vars/settings.go: -------------------------------------------------------------------------------- 1 | package vars 2 | 3 | type MysqlSettingS struct { 4 | Driver string // 使用的数据库驱动,支持 mysql、postgres 5 | Host string 6 | Port string 7 | User string 8 | Password string 9 | Db string 10 | AdminDb string // 管理后台数据库 11 | Schema string // postgres 专用 12 | } 13 | 14 | type RedisSettingS struct { 15 | Host string 16 | Port string 17 | Password string 18 | Db int 19 | } 20 | 21 | type RabbitmqSettingS struct { 22 | Host string 23 | Port string 24 | User string 25 | Password string 26 | Vhost string 27 | } 28 | 29 | var MysqlSettings = &MysqlSettingS{} 30 | var RedisSettings = &RedisSettingS{} 31 | var RabbitmqSettings = &RabbitmqSettingS{} 32 | -------------------------------------------------------------------------------- /service/message_sender_adapter.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import "wechat-robot-client/pkg/mcp" 4 | 5 | type MessageSenderAdapter struct { 6 | messageService *MessageService 7 | } 8 | 9 | func NewMessageSenderAdapter(messageService *MessageService) mcp.MessageSender { 10 | return &MessageSenderAdapter{ 11 | messageService: messageService, 12 | } 13 | } 14 | 15 | func (a *MessageSenderAdapter) SendTextMessage(toWxID, content string, at ...string) error { 16 | return a.messageService.SendTextMessage(toWxID, content, at...) 17 | } 18 | 19 | func (a *MessageSenderAdapter) SendAppMessage(toWxID string, appMsgType int, appMsgXml string) error { 20 | return a.messageService.SendAppMessage(toWxID, appMsgType, appMsgXml) 21 | } 22 | -------------------------------------------------------------------------------- /service/chat_history.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "wechat-robot-client/dto" 6 | "wechat-robot-client/model" 7 | "wechat-robot-client/pkg/appx" 8 | "wechat-robot-client/repository" 9 | "wechat-robot-client/vars" 10 | ) 11 | 12 | type ChatHistoryService struct { 13 | ctx context.Context 14 | msgRepo *repository.Message 15 | } 16 | 17 | func NewChatHistoryService(ctx context.Context) *ChatHistoryService { 18 | return &ChatHistoryService{ 19 | ctx: ctx, 20 | msgRepo: repository.NewMessageRepo(ctx, vars.DB), 21 | } 22 | } 23 | 24 | func (s *ChatHistoryService) GetChatHistory(req dto.ChatHistoryRequest, pager appx.Pager) ([]*model.Message, int64, error) { 25 | return s.msgRepo.GetByContactID(req, pager) 26 | } 27 | -------------------------------------------------------------------------------- /pkg/shutdown/db_connection.go: -------------------------------------------------------------------------------- 1 | package shutdown 2 | 3 | import ( 4 | "context" 5 | 6 | "gorm.io/gorm" 7 | ) 8 | 9 | type DBConnection struct { 10 | DB *gorm.DB 11 | AdminDB *gorm.DB 12 | } 13 | 14 | func (d *DBConnection) Name() string { 15 | return "mysql 数据库连接" 16 | } 17 | 18 | func (d *DBConnection) Shutdown(ctx context.Context) error { 19 | if d.DB != nil { 20 | sqlDB, err := d.DB.DB() 21 | if err != nil { 22 | return err 23 | } 24 | if err := sqlDB.Close(); err != nil { 25 | return err 26 | } 27 | } 28 | 29 | if d.AdminDB != nil { 30 | adminSqlDB, err := d.AdminDB.DB() 31 | if err != nil { 32 | return err 33 | } 34 | if err := adminSqlDB.Close(); err != nil { 35 | return err 36 | } 37 | } 38 | return nil 39 | } 40 | -------------------------------------------------------------------------------- /controller/wx_app.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "errors" 5 | "wechat-robot-client/dto" 6 | "wechat-robot-client/pkg/appx" 7 | "wechat-robot-client/service" 8 | 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | type WXApp struct{} 13 | 14 | func NewWXAppController() *WXApp { 15 | return &WXApp{} 16 | } 17 | 18 | func (m *WXApp) WxappQrcodeAuthLogin(c *gin.Context) { 19 | var req dto.WxappQrcodeAuthLoginRequest 20 | resp := appx.NewResponse(c) 21 | if ok, err := appx.BindAndValid(c, &req); !ok || err != nil { 22 | resp.ToErrorResponse(errors.New("参数错误")) 23 | return 24 | } 25 | err := service.NewWXAppService(c).WxappQrcodeAuthLogin(req.URL) 26 | if err != nil { 27 | resp.ToErrorResponse(err) 28 | return 29 | } 30 | resp.ToResponse(nil) 31 | } 32 | -------------------------------------------------------------------------------- /controller/ai_callback.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "errors" 5 | "wechat-robot-client/dto" 6 | "wechat-robot-client/pkg/appx" 7 | "wechat-robot-client/service" 8 | 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | type AICallback struct { 13 | } 14 | 15 | func NewAICallbackController() *AICallback { 16 | return &AICallback{} 17 | } 18 | 19 | func (a *AICallback) DoubaoTTS(c *gin.Context) { 20 | var req dto.DoubaoTTSCallbackRequest 21 | resp := appx.NewResponse(c) 22 | if ok, err := appx.BindAndValid(c, &req); !ok || err != nil { 23 | resp.ToErrorResponse(errors.New("参数错误")) 24 | return 25 | } 26 | err := service.NewAICallbackService(c).DoubaoTTS(req) 27 | if err != nil { 28 | resp.ToErrorResponse(err) 29 | return 30 | } 31 | resp.ToResponse(nil) 32 | } 33 | -------------------------------------------------------------------------------- /service/system_message.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "wechat-robot-client/model" 6 | "wechat-robot-client/repository" 7 | "wechat-robot-client/vars" 8 | ) 9 | 10 | type SystemMessageService struct { 11 | ctx context.Context 12 | sysmsgRepo *repository.SystemMessage 13 | } 14 | 15 | func NewSystemMessageService(ctx context.Context) *SystemMessageService { 16 | return &SystemMessageService{ 17 | ctx: ctx, 18 | sysmsgRepo: repository.NewSystemMessageRepo(ctx, vars.DB), 19 | } 20 | } 21 | 22 | func (s *SystemMessageService) GetRecentMonthMessages() ([]*model.SystemMessage, error) { 23 | return s.sysmsgRepo.GetRecentMonthMessages() 24 | } 25 | 26 | func (s *SystemMessageService) MarkAsReadBatch(ids []int64) error { 27 | return s.sysmsgRepo.MarkAsReadBatch(ids) 28 | } 29 | -------------------------------------------------------------------------------- /startup/plugin.go: -------------------------------------------------------------------------------- 1 | package startup 2 | 3 | import ( 4 | "wechat-robot-client/plugin" 5 | "wechat-robot-client/plugin/plugins" 6 | "wechat-robot-client/vars" 7 | ) 8 | 9 | func RegisterMessagePlugin() { 10 | vars.MessagePlugin = plugin.NewMessagePlugin() 11 | // 群聊聊天插件 12 | vars.MessagePlugin.Register(plugins.NewChatRoomAIChatSessionStartPlugin()) 13 | vars.MessagePlugin.Register(plugins.NewChatRoomAIChatSessionEndPlugin()) 14 | vars.MessagePlugin.Register(plugins.NewChatRoomAIChatPlugin()) 15 | // 朋友聊天插件 16 | vars.MessagePlugin.Register(plugins.NewFriendAIChatPlugin()) 17 | // 群聊拍一拍交互插件 18 | vars.MessagePlugin.Register(plugins.NewPatPlugin()) 19 | // 抖音解析插件 20 | vars.MessagePlugin.Register(plugins.NewDouyinVideoParsePlugin()) 21 | // 图片自动上传插件 22 | vars.MessagePlugin.Register(plugins.NewImageAutoUploadPlugin()) 23 | } 24 | -------------------------------------------------------------------------------- /controller/chat_history.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "errors" 5 | "wechat-robot-client/dto" 6 | "wechat-robot-client/pkg/appx" 7 | "wechat-robot-client/service" 8 | 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | type ChatHistory struct { 13 | } 14 | 15 | func NewChatHistoryController() *ChatHistory { 16 | return &ChatHistory{} 17 | } 18 | 19 | func (ch *ChatHistory) GetChatHistory(c *gin.Context) { 20 | var req dto.ChatHistoryRequest 21 | resp := appx.NewResponse(c) 22 | if ok, err := appx.BindAndValid(c, &req); !ok || err != nil { 23 | resp.ToErrorResponse(errors.New("参数错误")) 24 | return 25 | } 26 | pager := appx.InitPager(c) 27 | list, total, err := service.NewChatHistoryService(c).GetChatHistory(req, pager) 28 | if err != nil { 29 | resp.ToErrorResponse(err) 30 | return 31 | } 32 | resp.ToResponseList(list, total) 33 | } 34 | -------------------------------------------------------------------------------- /pkg/robot/xml/music.xml: -------------------------------------------------------------------------------- 1 | {{ .Title }}{{ .Singer }}view30{{ .Url }}{{ .MusicUrl }}{{ .Url }}{{ .MusicUrl }}{{ .CoverUrl }}{{ .Lyric }}000{{ .CoverUrl }}{{ .FromUsername }}01 -------------------------------------------------------------------------------- /pkg/robot/share_link.go: -------------------------------------------------------------------------------- 1 | package robot 2 | 3 | import "encoding/xml" 4 | 5 | type CDATAString string 6 | 7 | func (c CDATAString) MarshalXML(e *xml.Encoder, start xml.StartElement) error { 8 | return e.EncodeElement(struct { 9 | Data string `xml:",cdata"` 10 | }{string(c)}, start) 11 | } 12 | 13 | type ShareLinkMessage struct { 14 | XMLName xml.Name `xml:"appmsg"` 15 | AppID string `xml:"appid,attr"` 16 | SDKVer string `xml:"sdkver,attr"` 17 | Title string `xml:"title"` 18 | Des string `xml:"des"` 19 | Type int `xml:"type"` 20 | ShowType int `xml:"showtype"` 21 | SoundType int `xml:"soundtype"` 22 | ContentAttr int `xml:"contentattr"` 23 | DirectShare int `xml:"directshare"` 24 | Url string `xml:"url"` 25 | ThumbUrl CDATAString `xml:"thumburl"` 26 | AppAttach string `xml:"appattach"` 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Dual License 2 | 3 | This software is available under two licenses: 4 | 5 | 1. GNU Affero General Public License v3.0 (AGPL-3.0) for non-commercial use 6 | 2. Commercial License for commercial use 7 | 8 | NON-COMMERCIAL USE (AGPL-3.0): 9 | You may use, modify, and distribute this software under the terms of the GNU Affero General Public License v3.0 for non-commercial purposes only. 10 | 11 | COMMERCIAL USE: 12 | For any commercial use, you must obtain a separate commercial license. 13 | Contact: [809211365@qq.com] 14 | 15 | Copyright (c) 2025 [houhou] 16 | 17 | For non-commercial use, see the full AGPL-3.0 license text at: 18 | https://www.gnu.org/licenses/agpl-3.0.html 19 | 20 | COMMERCIAL USE DEFINITION: 21 | Commercial use includes but is not limited to: 22 | - Use in commercial products or services 23 | - Use by commercial entities 24 | - Use that generates revenue or commercial benefit 25 | - Use in production environments for business purposes -------------------------------------------------------------------------------- /utils/ai.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | "wechat-robot-client/vars" 7 | ) 8 | 9 | func TrimAt(content string) string { 10 | // 去除@开头的触发词 11 | re := regexp.MustCompile(vars.TrimAtRegexp) 12 | return re.ReplaceAllString(content, "") 13 | } 14 | 15 | func TrimAITriggerWord(content, aiTriggerWord string) string { 16 | // 去除固定AI触发词 17 | re := regexp.MustCompile("^" + regexp.QuoteMeta(aiTriggerWord) + `[\s,,::]*`) 18 | return re.ReplaceAllString(content, "") 19 | } 20 | 21 | func TrimAITriggerAll(content, aiTriggerWord string) string { 22 | return TrimAITriggerWord(TrimAt(content), aiTriggerWord) 23 | } 24 | 25 | // NormalizeAIBaseURL 规范化AI BaseURL,确保以/v+数字结尾,如果没有则添加/v1 26 | func NormalizeAIBaseURL(baseURL string) string { 27 | baseURL = strings.TrimRight(baseURL, "/") 28 | versionRegex := regexp.MustCompile(`/v\d+$`) 29 | if !versionRegex.MatchString(baseURL) { 30 | baseURL += "/v1" 31 | } 32 | return baseURL 33 | } 34 | -------------------------------------------------------------------------------- /repository/moment.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "wechat-robot-client/model" 6 | 7 | "gorm.io/gorm" 8 | ) 9 | 10 | type Moment struct { 11 | Ctx context.Context 12 | DB *gorm.DB 13 | } 14 | 15 | func NewMomentRepo(ctx context.Context, db *gorm.DB) *Moment { 16 | return &Moment{ 17 | Ctx: ctx, 18 | DB: db, 19 | } 20 | } 21 | 22 | func (respo *Moment) GetMomentByID(momentID int64) (*model.Moment, error) { 23 | var moment model.Moment 24 | err := respo.DB.WithContext(respo.Ctx).Where("moment_id = ?", momentID).First(&moment).Error 25 | if err == gorm.ErrRecordNotFound { 26 | return nil, nil 27 | } 28 | if err != nil { 29 | return nil, err 30 | } 31 | return &moment, nil 32 | } 33 | 34 | func (respo *Moment) Create(data *model.Moment) error { 35 | return respo.DB.WithContext(respo.Ctx).Create(data).Error 36 | } 37 | 38 | func (respo *Moment) Update(data *model.Moment) error { 39 | return respo.DB.WithContext(respo.Ctx).Updates(data).Error 40 | } 41 | -------------------------------------------------------------------------------- /service/ai_task.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "wechat-robot-client/model" 6 | "wechat-robot-client/repository" 7 | "wechat-robot-client/vars" 8 | ) 9 | 10 | type AITaskService struct { 11 | ctx context.Context 12 | aiTaskRepo *repository.AITask 13 | } 14 | 15 | func NewAITaskService(ctx context.Context) *AITaskService { 16 | return &AITaskService{ 17 | ctx: ctx, 18 | aiTaskRepo: repository.NewAITaskRepo(ctx, vars.DB), 19 | } 20 | } 21 | 22 | func (s *AITaskService) CreateAITask(aiTask *model.AITask) error { 23 | return s.aiTaskRepo.Create(aiTask) 24 | } 25 | 26 | func (s *AITaskService) UpdateAITask(aiTask *model.AITask) error { 27 | return s.aiTaskRepo.Update(aiTask) 28 | } 29 | 30 | func (s *AITaskService) GetByID(id int64) (*model.AITask, error) { 31 | return s.aiTaskRepo.GetByID(id) 32 | } 33 | 34 | // 获取进行中的ai任务 35 | func (s *AITaskService) GetOngoingByWeChatID(wxID string) ([]*model.AITask, error) { 36 | return s.aiTaskRepo.GetOngoingByWeChatID(wxID) 37 | } 38 | -------------------------------------------------------------------------------- /repository/oss_settings.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "wechat-robot-client/model" 6 | 7 | "gorm.io/gorm" 8 | ) 9 | 10 | type OSSSettings struct { 11 | Ctx context.Context 12 | DB *gorm.DB 13 | } 14 | 15 | func NewOSSSettingsRepo(ctx context.Context, db *gorm.DB) *OSSSettings { 16 | return &OSSSettings{ 17 | Ctx: ctx, 18 | DB: db, 19 | } 20 | } 21 | 22 | func (respo *OSSSettings) GetOSSSettings() (*model.OSSSettings, error) { 23 | var ossSettings model.OSSSettings 24 | err := respo.DB.WithContext(respo.Ctx).First(&ossSettings).Error 25 | if err == gorm.ErrRecordNotFound { 26 | return nil, nil 27 | } 28 | if err != nil { 29 | return nil, err 30 | } 31 | return &ossSettings, nil 32 | } 33 | 34 | func (respo *OSSSettings) Create(data *model.OSSSettings) error { 35 | return respo.DB.WithContext(respo.Ctx).Create(data).Error 36 | } 37 | 38 | func (respo *OSSSettings) Update(data *model.OSSSettings) error { 39 | return respo.DB.WithContext(respo.Ctx).Updates(data).Error 40 | } 41 | -------------------------------------------------------------------------------- /interface/settings/settings.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "wechat-robot-client/model" 5 | 6 | "gorm.io/datatypes" 7 | ) 8 | 9 | type AIConfig struct { 10 | BaseURL string 11 | APIKey string 12 | Model string 13 | WorkflowModel string 14 | ImageRecognitionModel string 15 | Prompt string 16 | MaxCompletionTokens int 17 | ImageModel model.ImageModel 18 | ImageAISettings datatypes.JSON 19 | TTSSettings datatypes.JSON 20 | LTTSSettings datatypes.JSON 21 | } 22 | 23 | type PatConfig struct { 24 | PatEnabled bool 25 | PatType model.PatType 26 | PatText string 27 | PatVoiceTimbre string 28 | } 29 | 30 | type Settings interface { 31 | InitByMessage(message *model.Message) error 32 | GetAIConfig() AIConfig 33 | IsAIChatEnabled() bool 34 | IsAIDrawingEnabled() bool 35 | IsTTSEnabled() bool 36 | IsAITrigger() bool 37 | GetAITriggerWord() string 38 | GetPatConfig() PatConfig 39 | } 40 | -------------------------------------------------------------------------------- /repository/moment_settings.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "wechat-robot-client/model" 6 | 7 | "gorm.io/gorm" 8 | ) 9 | 10 | type MomentSettings struct { 11 | Ctx context.Context 12 | DB *gorm.DB 13 | } 14 | 15 | func NewMomentSettingsRepo(ctx context.Context, db *gorm.DB) *MomentSettings { 16 | return &MomentSettings{ 17 | Ctx: ctx, 18 | DB: db, 19 | } 20 | } 21 | 22 | func (respo *MomentSettings) GetMomentSettings() (*model.MomentSettings, error) { 23 | var momentSettings model.MomentSettings 24 | err := respo.DB.WithContext(respo.Ctx).First(&momentSettings).Error 25 | if err == gorm.ErrRecordNotFound { 26 | return nil, nil 27 | } 28 | if err != nil { 29 | return nil, err 30 | } 31 | return &momentSettings, nil 32 | } 33 | 34 | func (respo *MomentSettings) Create(data *model.MomentSettings) error { 35 | return respo.DB.WithContext(respo.Ctx).Create(data).Error 36 | } 37 | 38 | func (respo *MomentSettings) Update(data *model.MomentSettings) error { 39 | return respo.DB.WithContext(respo.Ctx).Updates(data).Error 40 | } 41 | -------------------------------------------------------------------------------- /repository/system_settings.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "wechat-robot-client/model" 6 | 7 | "gorm.io/gorm" 8 | ) 9 | 10 | type SystemSettings struct { 11 | Ctx context.Context 12 | DB *gorm.DB 13 | } 14 | 15 | func NewSystemSettingsRepo(ctx context.Context, db *gorm.DB) *SystemSettings { 16 | return &SystemSettings{ 17 | Ctx: ctx, 18 | DB: db, 19 | } 20 | } 21 | 22 | func (respo *SystemSettings) GetSystemSettings() (*model.SystemSettings, error) { 23 | var systemSettings model.SystemSettings 24 | err := respo.DB.WithContext(respo.Ctx).First(&systemSettings).Error 25 | if err == gorm.ErrRecordNotFound { 26 | return nil, nil 27 | } 28 | if err != nil { 29 | return nil, err 30 | } 31 | return &systemSettings, nil 32 | } 33 | 34 | func (respo *SystemSettings) Create(data *model.SystemSettings) error { 35 | return respo.DB.WithContext(respo.Ctx).Create(data).Error 36 | } 37 | 38 | func (respo *SystemSettings) Update(data *model.SystemSettings) error { 39 | return respo.DB.WithContext(respo.Ctx).Updates(data).Error 40 | } 41 | -------------------------------------------------------------------------------- /controller/oss_settings.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "errors" 5 | "wechat-robot-client/model" 6 | "wechat-robot-client/pkg/appx" 7 | "wechat-robot-client/service" 8 | 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | type OSSSettings struct{} 13 | 14 | func NewOSSSettingsController() *OSSSettings { 15 | return &OSSSettings{} 16 | } 17 | 18 | func (s *OSSSettings) GetOSSSettings(c *gin.Context) { 19 | resp := appx.NewResponse(c) 20 | data, err := service.NewOSSSettingService(c).GetOSSSettingService() 21 | if err != nil { 22 | resp.ToErrorResponse(err) 23 | return 24 | } 25 | resp.ToResponse(data) 26 | } 27 | 28 | func (s *OSSSettings) SaveOSSSettings(c *gin.Context) { 29 | var req model.OSSSettings 30 | resp := appx.NewResponse(c) 31 | if ok, err := appx.BindAndValid(c, &req); !ok || err != nil { 32 | resp.ToErrorResponse(errors.New("参数错误")) 33 | return 34 | } 35 | err := service.NewOSSSettingService(c).SaveOSSSettingService(&req) 36 | if err != nil { 37 | resp.ToErrorResponse(err) 38 | return 39 | } 40 | resp.ToResponse(nil) 41 | } 42 | -------------------------------------------------------------------------------- /controller/system_message.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "errors" 5 | "wechat-robot-client/dto" 6 | "wechat-robot-client/pkg/appx" 7 | "wechat-robot-client/service" 8 | 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | type SystemMessage struct{} 13 | 14 | func NewSystemMessageController() *SystemMessage { 15 | return &SystemMessage{} 16 | } 17 | 18 | func (m *SystemMessage) GetRecentMonthMessages(c *gin.Context) { 19 | resp := appx.NewResponse(c) 20 | data, err := service.NewSystemMessageService(c).GetRecentMonthMessages() 21 | if err != nil { 22 | resp.ToErrorResponse(err) 23 | return 24 | } 25 | resp.ToResponse(data) 26 | } 27 | 28 | func (m *SystemMessage) MarkAsReadBatch(c *gin.Context) { 29 | var req dto.MarkAsReadBatchRequest 30 | resp := appx.NewResponse(c) 31 | if ok, err := appx.BindAndValid(c, &req); !ok || err != nil { 32 | resp.ToErrorResponse(errors.New("参数错误")) 33 | return 34 | } 35 | if err := service.NewSystemMessageService(c).MarkAsReadBatch(req.IDs); err != nil { 36 | resp.ToErrorResponse(err) 37 | return 38 | } 39 | resp.ToResponse(nil) 40 | } 41 | -------------------------------------------------------------------------------- /controller/system_settings.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "errors" 5 | "wechat-robot-client/model" 6 | "wechat-robot-client/pkg/appx" 7 | "wechat-robot-client/service" 8 | 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | type SystemSettings struct{} 13 | 14 | func NewSystemSettingsController() *SystemSettings { 15 | return &SystemSettings{} 16 | } 17 | 18 | func (s *SystemSettings) GetSystemSettings(c *gin.Context) { 19 | resp := appx.NewResponse(c) 20 | data, err := service.NewSystemSettingService(c).GetSystemSettings() 21 | if err != nil { 22 | resp.ToErrorResponse(err) 23 | return 24 | } 25 | resp.ToResponse(data) 26 | } 27 | 28 | func (s *SystemSettings) SaveSystemSettings(c *gin.Context) { 29 | var req model.SystemSettings 30 | resp := appx.NewResponse(c) 31 | if ok, err := appx.BindAndValid(c, &req); !ok || err != nil { 32 | resp.ToErrorResponse(errors.New("参数错误")) 33 | return 34 | } 35 | err := service.NewSystemSettingService(c).SaveSystemSettings(&req) 36 | if err != nil { 37 | resp.ToErrorResponse(err) 38 | return 39 | } 40 | resp.ToResponse(nil) 41 | } 42 | -------------------------------------------------------------------------------- /repository/friend_settings.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "wechat-robot-client/model" 6 | 7 | "gorm.io/gorm" 8 | ) 9 | 10 | type FriendSettings struct { 11 | Ctx context.Context 12 | DB *gorm.DB 13 | } 14 | 15 | func NewFriendSettingsRepo(ctx context.Context, db *gorm.DB) *FriendSettings { 16 | return &FriendSettings{ 17 | Ctx: ctx, 18 | DB: db, 19 | } 20 | } 21 | 22 | func (respo *FriendSettings) GetFriendSettings(contactID string) (*model.FriendSettings, error) { 23 | var friendSettings model.FriendSettings 24 | err := respo.DB.WithContext(respo.Ctx).Where("wechat_id = ?", contactID).First(&friendSettings).Error 25 | if err == gorm.ErrRecordNotFound { 26 | return nil, nil 27 | } 28 | if err != nil { 29 | return nil, err 30 | } 31 | return &friendSettings, nil 32 | } 33 | 34 | func (respo *FriendSettings) Create(data *model.FriendSettings) error { 35 | return respo.DB.WithContext(respo.Ctx).Create(data).Error 36 | } 37 | 38 | func (respo *FriendSettings) Update(data *model.FriendSettings) error { 39 | return respo.DB.WithContext(respo.Ctx).Updates(data).Error 40 | } 41 | -------------------------------------------------------------------------------- /common_cron/sync-contact.go: -------------------------------------------------------------------------------- 1 | package common_cron 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "wechat-robot-client/service" 7 | "wechat-robot-client/vars" 8 | ) 9 | 10 | type SyncContactCron struct { 11 | CronManager *CronManager 12 | } 13 | 14 | func NewSyncContactCron(cronManager *CronManager) vars.CommonCronInstance { 15 | return &SyncContactCron{ 16 | CronManager: cronManager, 17 | } 18 | } 19 | 20 | func (cron *SyncContactCron) IsActive() bool { 21 | return true 22 | } 23 | 24 | func (cron *SyncContactCron) Cron() error { 25 | return service.NewContactService(context.Background()).SyncContact(true) 26 | } 27 | 28 | func (cron *SyncContactCron) Register() { 29 | if !cron.IsActive() { 30 | log.Println("联系人同步任务未启用") 31 | return 32 | } 33 | err := cron.CronManager.AddJob(vars.FriendSyncCron, cron.CronManager.globalSettings.FriendSyncCron, func() { 34 | log.Println("开始同步联系人") 35 | if err := cron.Cron(); err != nil { 36 | log.Printf("同步联系人失败: %v", err) 37 | } else { 38 | log.Println("联系人同步完成") 39 | } 40 | }) 41 | if err != nil { 42 | log.Printf("联系人同步任务注册失败: %v", err) 43 | return 44 | } 45 | log.Println("同步联系人任务初始化成功") 46 | } 47 | -------------------------------------------------------------------------------- /service/global_settings.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "wechat-robot-client/model" 6 | "wechat-robot-client/repository" 7 | "wechat-robot-client/vars" 8 | ) 9 | 10 | type GlobalSettingsService struct { 11 | ctx context.Context 12 | gsRepo *repository.GlobalSettings 13 | } 14 | 15 | func NewGlobalSettingsService(ctx context.Context) *GlobalSettingsService { 16 | return &GlobalSettingsService{ 17 | ctx: ctx, 18 | gsRepo: repository.NewGlobalSettingsRepo(ctx, vars.DB), 19 | } 20 | } 21 | 22 | func (s *GlobalSettingsService) GetGlobalSettings() (*model.GlobalSettings, error) { 23 | return s.gsRepo.GetGlobalSettings() 24 | } 25 | 26 | func (s *GlobalSettingsService) SaveGlobalSettings(data *model.GlobalSettings) error { 27 | data.FriendSyncCron = "" // 这个不允许用户修改 28 | err := s.gsRepo.Update(data) 29 | if err != nil { 30 | return err 31 | } 32 | // 重置公共定时任务 33 | newData, err := s.GetGlobalSettings() 34 | if err != nil { 35 | return err 36 | } 37 | vars.CronManager.Clear() 38 | vars.CronManager.SetGlobalSettings(newData) 39 | if vars.RobotRuntime.Status == model.RobotStatusOnline { 40 | vars.CronManager.Start() 41 | } 42 | return nil 43 | } 44 | -------------------------------------------------------------------------------- /vars/cron.go: -------------------------------------------------------------------------------- 1 | package vars 2 | 3 | import ( 4 | "context" 5 | "wechat-robot-client/model" 6 | ) 7 | 8 | type CommonCron string 9 | 10 | const ( 11 | WordCloudDailyCron CommonCron = "word_cloud_daily_cron" 12 | ChatRoomRankingDailyCron CommonCron = "chat_room_ranking_daily_cron" 13 | ChatRoomRankingWeeklyCron CommonCron = "chat_room_ranking_weekly_cron" 14 | ChatRoomRankingMonthCron CommonCron = "chat_room_ranking_month_cron" 15 | ChatRoomSummaryCron CommonCron = "chat_room_summary_cron" 16 | NewsCron CommonCron = "news_cron" 17 | MorningCron CommonCron = "morning_cron" 18 | FriendSyncCron CommonCron = "friend_sync_cron" 19 | ) 20 | 21 | type TaskHandler func() 22 | 23 | type CronManagerInterface interface { 24 | Name() string 25 | Shutdown(ctx context.Context) error 26 | SetGlobalSettings(globalSettings *model.GlobalSettings) 27 | Start() 28 | AddJob(cronName CommonCron, cronExpr string, handler TaskHandler) error 29 | RemoveJob(cronName CommonCron) error 30 | UpdateJob(cronName CommonCron, cronExpr string, handler TaskHandler) error 31 | Clear() 32 | Stop() 33 | } 34 | 35 | type CommonCronInstance interface { 36 | IsActive() bool 37 | Cron() error 38 | Register() 39 | } 40 | -------------------------------------------------------------------------------- /repository/admin.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "wechat-robot-client/model" 6 | 7 | "gorm.io/gorm" 8 | ) 9 | 10 | type RobotAdmin struct { 11 | Ctx context.Context 12 | DB *gorm.DB 13 | } 14 | 15 | func NewRobotAdminRepo(ctx context.Context, db *gorm.DB) *RobotAdmin { 16 | return &RobotAdmin{ 17 | Ctx: ctx, 18 | DB: db, 19 | } 20 | } 21 | 22 | func (r *RobotAdmin) GetByRobotID(robotID int64) (*model.RobotAdmin, error) { 23 | var robotAdmin model.RobotAdmin 24 | err := r.DB.WithContext(r.Ctx).Where("id = ?", robotID).First(&robotAdmin).Error 25 | if err == gorm.ErrRecordNotFound { 26 | return nil, nil 27 | } 28 | if err != nil { 29 | return nil, err 30 | } 31 | return &robotAdmin, nil 32 | } 33 | 34 | func (r *RobotAdmin) GetByWeChatID(wechatID string) (*model.RobotAdmin, error) { 35 | var robotAdmin model.RobotAdmin 36 | err := r.DB.WithContext(r.Ctx).Where("wechat_id = ?", wechatID).First(&robotAdmin).Error 37 | if err == gorm.ErrRecordNotFound { 38 | return nil, nil 39 | } 40 | if err != nil { 41 | return nil, err 42 | } 43 | return &robotAdmin, nil 44 | } 45 | 46 | func (r *RobotAdmin) Update(robot *model.RobotAdmin) error { 47 | return r.DB.WithContext(r.Ctx).Updates(robot).Error 48 | } 49 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | GIN_MODE=debug 2 | 3 | WECHAT_CLIENT_PORT=9001 # 本服务的启动端口,注意不要和后端服务端口冲突,这里改成9001 4 | WECHAT_SERVER_HOST=127.0.0.1:3010 # 微信iPad协议服务的地址,iPad协议不提供源码,一般通过docker容器启动 5 | PPROF_PROXY_URL=http://127.0.0.1:9010 # 性能分析代理地址,非核心模块,可以先不管 6 | 7 | ROBOT_ID=27 # 机器人ID,启动了前端和后端服务后,可以在界面上创建机器人,创建完后会有机器人ID 8 | ROBOT_CODE=houhousama5 # 机器人编码,获取方式同机器人ID 9 | ROBOT_START_TIMEOUT=60 # 启动超时时间,单位秒,超过这个时间机器人还没有启动成功,则会报错 10 | 11 | # mysql 相关配置 12 | MYSQL_DRIVER=mysql 13 | MYSQL_HOST=127.0.0.1 14 | MYSQL_PORT=3306 15 | MYSQL_USER=houhou 16 | MYSQL_PASSWORD=houhou 17 | MYSQL_ADMIN_DB=robot_admin # 注意和后端服务那个数据库保持一致 18 | MYSQL_DB=houhousama5 # 机器人实例对应的数据库实例,这里要配置成和 ROBOT_CODE 一致,在创建机器人的时候会自动为机器人创建一个数据库 19 | MYSQL_SCHEMA=public # 写死 20 | 21 | # redis 相关配置 22 | REDIS_HOST=127.0.0.1 23 | REDIS_PORT=6379 24 | REDIS_PASSWORD=houhou 25 | REDIS_DB=0 # 机器人实例对应的redis数据库,可以在创建完机器人实例之后在界面详情看到,每个机器人实例会分配一个redis db 26 | 27 | # rabbitmq 相关配置,暂时没有用到,可以先不管 28 | RABBITMQ_HOST=127.0.0.1 29 | RABBITMQ_PORT=5672 30 | RABBITMQ_USER=houhou 31 | RABBITMQ_PASSWORD=houhou 32 | RABBITMQ_VHOST=wechat 33 | 34 | # 词云服务地址,非核心模块,可以先不管,这里的例子是容器之间通过服务名可以直接访问 35 | WORD_CLOUD_URL=http://word-cloud-server:9000/api/v1/word-cloud/gen 36 | 37 | # 第三方API密钥,非核心模块,可以先不管 38 | THIRD_PARTY_API_KEY=0000000000000000 39 | -------------------------------------------------------------------------------- /model/moment_settings.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type MomentSettings struct { 4 | ID int64 `gorm:"primaryKey;autoIncrement;comment:表主键ID" json:"id"` 5 | SyncKey string `gorm:"type:text;comment:朋友圈同步Key" json:"sync_key"` 6 | AutoLike *bool `gorm:"default:false;comment:开启自动点赞" json:"auto_like"` 7 | AutoComment *bool `gorm:"default:false;comment:开启自动评论" json:"auto_comment"` 8 | Whitelist *string `gorm:"type:text;comment:自动点赞、评论白名单" json:"whitelist"` 9 | Blacklist *string `gorm:"type:text;comment:自动点赞、评论黑名单" json:"blacklist"` 10 | AIBaseURL string `gorm:"type:varchar(255);default:'';comment:AI的基础URL地址" json:"ai_base_url"` 11 | AIAPIKey string `gorm:"type:varchar(255);default:'';comment:AI的API密钥" json:"ai_api_key"` 12 | WorkflowModel string `gorm:"type:varchar(100);default:'';comment:工作流模型" json:"workflow_model"` 13 | CommentModel string `gorm:"type:varchar(100);default:'';comment:评论模型" json:"comment_model"` 14 | CommentPrompt string `gorm:"type:text;comment:评论系统提示词" json:"comment_prompt"` 15 | MaxCompletionTokens *int `gorm:"default:0;comment:评论最大回复" json:"max_completion_tokens"` 16 | } 17 | 18 | func (MomentSettings) TableName() string { 19 | return "moment_settings" 20 | } 21 | -------------------------------------------------------------------------------- /plugin/plugins/auto_join_group.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "context" 5 | "regexp" 6 | "wechat-robot-client/interface/plugin" 7 | "wechat-robot-client/service" 8 | ) 9 | 10 | type AutoJoinGroupPlugin struct{} 11 | 12 | func NewAutoJoinGroupPlugin() plugin.MessageHandler { 13 | return &AutoJoinGroupPlugin{} 14 | } 15 | 16 | func (p *AutoJoinGroupPlugin) GetName() string { 17 | return "Auto Join Group" 18 | } 19 | 20 | func (p *AutoJoinGroupPlugin) GetLabels() []string { 21 | return []string{"text", "auto"} 22 | } 23 | 24 | func (p *AutoJoinGroupPlugin) PreAction(ctx *plugin.MessageContext) bool { 25 | return true 26 | } 27 | 28 | func (p *AutoJoinGroupPlugin) PostAction(ctx *plugin.MessageContext) { 29 | 30 | } 31 | 32 | func (p *AutoJoinGroupPlugin) Run(ctx *plugin.MessageContext) bool { 33 | re := regexp.MustCompile(`^申请进群\s+`) 34 | chatRoomName := re.ReplaceAllString(ctx.MessageContent, "") 35 | if chatRoomName == "" { 36 | ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, "群聊名称不能为空") 37 | return true 38 | } 39 | err := service.NewChatRoomService(context.Background()).AutoInviteChatRoomMember(chatRoomName, []string{ctx.Message.FromWxID}) 40 | if err != nil { 41 | ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, err.Error()) 42 | } 43 | return true 44 | } 45 | -------------------------------------------------------------------------------- /model/moment.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type Moment struct { 4 | ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` 5 | WechatID string `gorm:"column:wechat_id;type:varchar(64);index:idx_wechat_id" json:"wechat_id"` 6 | MomentID uint64 `gorm:"column:moment_id;not null;uniqueIndex:uniq_moment_id" json:"moment_id"` 7 | Type int `gorm:"column:type;not null;index:idx_type" json:"type"` 8 | AppMsgType *int `gorm:"column:app_msg_type" json:"app_msg_type"` 9 | Content string `gorm:"column:content;type:text" json:"content"` 10 | MessageSource string `gorm:"column:message_source;type:text" json:"message_source"` 11 | ImgBuf string `gorm:"column:img_buf;type:text" json:"img_buf"` 12 | Status int `gorm:"column:status;not null;default:0" json:"status"` 13 | ImgStatus int `gorm:"column:img_status;not null;default:0" json:"img_status"` 14 | PushContent string `gorm:"column:push_content;type:text" json:"push_content"` 15 | MessageSeq int `gorm:"column:message_seq;not null;default:0" json:"message_seq"` 16 | CreatedAt int64 `gorm:"column:created_at;not null;index:idx_created_at" json:"created_at"` 17 | UpdatedAt int64 `gorm:"column:updated_at;not null" json:"updated_at"` 18 | } 19 | 20 | func (Moment) TableName() string { 21 | return "moments" 22 | } 23 | -------------------------------------------------------------------------------- /common_cron/chat_room_summary.go: -------------------------------------------------------------------------------- 1 | package common_cron 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "wechat-robot-client/service" 7 | "wechat-robot-client/vars" 8 | ) 9 | 10 | type ChatRoomSummaryCron struct { 11 | CronManager *CronManager 12 | } 13 | 14 | func NewChatRoomSummaryCron(cronManager *CronManager) vars.CommonCronInstance { 15 | return &ChatRoomSummaryCron{ 16 | CronManager: cronManager, 17 | } 18 | } 19 | 20 | func (cron *ChatRoomSummaryCron) IsActive() bool { 21 | if cron.CronManager.globalSettings.ChatRoomSummaryEnabled != nil && *cron.CronManager.globalSettings.ChatRoomSummaryEnabled { 22 | return true 23 | } 24 | return false 25 | } 26 | 27 | func (cron *ChatRoomSummaryCron) Cron() error { 28 | return service.NewChatRoomService(context.Background()).ChatRoomAISummary() 29 | } 30 | 31 | func (cron *ChatRoomSummaryCron) Register() { 32 | if !cron.IsActive() { 33 | log.Println("每日群聊总结任务未启用") 34 | return 35 | } 36 | err := cron.CronManager.AddJob(vars.ChatRoomSummaryCron, cron.CronManager.globalSettings.ChatRoomSummaryCron, func() { 37 | log.Println("开始执行每日群聊总结任务") 38 | if err := cron.Cron(); err != nil { 39 | log.Printf("每日群聊总结任务执行失败: %v", err) 40 | } else { 41 | log.Println("每日群聊总结任务执行完成") 42 | } 43 | }) 44 | if err != nil { 45 | log.Printf("每日群聊总结任务注册失败: %v", err) 46 | return 47 | } 48 | log.Println("每日群聊总结任务初始化成功") 49 | } 50 | -------------------------------------------------------------------------------- /interface/ai/mcp.go: -------------------------------------------------------------------------------- 1 | package ai 2 | 3 | import ( 4 | "context" 5 | 6 | sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" 7 | "github.com/sashabaranov/go-openai" 8 | 9 | "wechat-robot-client/model" 10 | "wechat-robot-client/pkg/mcp" 11 | ) 12 | 13 | type MCPService interface { 14 | Name() string 15 | Initialize() error 16 | Shutdown(ctx context.Context) error 17 | GetAllTools() ([]openai.Tool, error) 18 | GetToolsByServerName(serverName string) ([]openai.Tool, error) 19 | GetToolsByServerID(serverID uint64) ([]*sdkmcp.Tool, error) 20 | ExecuteToolCall(robotCtx mcp.RobotContext, toolCall openai.ToolCall) (string, bool, error) 21 | ChatWithMCPTools( 22 | robotCtx mcp.RobotContext, 23 | client *openai.Client, 24 | req openai.ChatCompletionRequest, 25 | maxIterations int, 26 | ) (openai.ChatCompletionMessage, error) 27 | AddServer(server *model.MCPServer) error 28 | RemoveServer(serverID uint64) error 29 | UpdateServer(server *model.MCPServer) error 30 | EnableServer(serverID uint64) error 31 | DisableServer(serverID uint64) error 32 | GetServerByID(serverID uint64) (*model.MCPServer, error) 33 | GetAllServers() ([]*model.MCPServer, error) 34 | GetEnabledServers() ([]*model.MCPServer, error) 35 | GetServerStats(serverID uint64) (*mcp.MCPConnectionStats, error) 36 | GetActiveServerCount() int 37 | ReloadServer(serverID uint64) error 38 | TestServerConnection(server *model.MCPServer) error 39 | } 40 | -------------------------------------------------------------------------------- /common_cron/chat_room_ranking_daily.go: -------------------------------------------------------------------------------- 1 | package common_cron 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "wechat-robot-client/service" 7 | "wechat-robot-client/vars" 8 | ) 9 | 10 | type ChatRoomRankingDailyCron struct { 11 | CronManager *CronManager 12 | } 13 | 14 | func NewChatRoomRankingDailyCron(cronManager *CronManager) vars.CommonCronInstance { 15 | return &ChatRoomRankingDailyCron{ 16 | CronManager: cronManager, 17 | } 18 | } 19 | 20 | func (cron *ChatRoomRankingDailyCron) IsActive() bool { 21 | if cron.CronManager.globalSettings.ChatRoomRankingEnabled != nil && *cron.CronManager.globalSettings.ChatRoomRankingEnabled { 22 | return true 23 | } 24 | return false 25 | } 26 | 27 | func (cron *ChatRoomRankingDailyCron) Cron() error { 28 | return service.NewChatRoomService(context.Background()).ChatRoomRankingDaily() 29 | } 30 | 31 | func (cron *ChatRoomRankingDailyCron) Register() { 32 | if !cron.IsActive() { 33 | log.Println("每日群聊排行榜任务未启用") 34 | return 35 | } 36 | err := cron.CronManager.AddJob(vars.ChatRoomRankingDailyCron, cron.CronManager.globalSettings.ChatRoomRankingDailyCron, func() { 37 | log.Println("开始执行每日群聊排行榜任务") 38 | if err := cron.Cron(); err != nil { 39 | log.Printf("每日群聊排行榜任务执行失败: %v", err) 40 | } else { 41 | log.Println("每日群聊排行榜任务执行完成") 42 | } 43 | }) 44 | if err != nil { 45 | log.Printf("每日群聊排行榜任务注册失败: %v", err) 46 | return 47 | } 48 | log.Println("每日群聊排行榜任务初始化成功") 49 | } 50 | -------------------------------------------------------------------------------- /pkg/appx/req.go: -------------------------------------------------------------------------------- 1 | package appx 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | 7 | "github.com/gin-gonic/gin" 8 | ) 9 | 10 | type ValidError struct { 11 | Key string 12 | Message string 13 | } 14 | 15 | type ValidErrors []*ValidError 16 | 17 | func (v *ValidError) Error() string { 18 | return v.Message 19 | } 20 | 21 | func (v ValidErrors) Error() string { 22 | return strings.Join(v.Errors(), ",") 23 | } 24 | 25 | func (v ValidErrors) Errors() []string { 26 | var errs []string 27 | for _, err := range v { 28 | errs = append(errs, err.Error()) 29 | } 30 | 31 | return errs 32 | } 33 | 34 | func BindAndValid(c *gin.Context, v any) (isValid bool, errs ValidErrors) { 35 | err := c.ShouldBind(v) 36 | if err != nil { 37 | return false, errs 38 | } 39 | 40 | return true, nil 41 | } 42 | 43 | type Pager struct { 44 | // 页码 45 | PageIndex int `json:"page_index"` 46 | // 每页数量 47 | PageSize int `json:"page_size"` 48 | // 数据库偏移 49 | OffSet int 50 | } 51 | 52 | func InitPager(c *gin.Context) Pager { 53 | pager := Pager{} 54 | var err error 55 | index := c.DefaultQuery("page_index", "1") 56 | pager.PageIndex, err = strconv.Atoi(index) 57 | if err != nil { 58 | pager.PageIndex = 1 59 | } 60 | size := c.DefaultQuery("page_size", "20") 61 | pager.PageSize, err = strconv.Atoi(size) 62 | if err != nil { 63 | pager.PageSize = 20 64 | } 65 | pager.OffSet = (pager.PageIndex - 1) * pager.PageSize 66 | return pager 67 | } 68 | -------------------------------------------------------------------------------- /controller/friend_settings.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "errors" 5 | "wechat-robot-client/dto" 6 | "wechat-robot-client/model" 7 | "wechat-robot-client/pkg/appx" 8 | "wechat-robot-client/service" 9 | 10 | "github.com/gin-gonic/gin" 11 | ) 12 | 13 | type FriendSettings struct { 14 | } 15 | 16 | func NewFriendSettingsController() *FriendSettings { 17 | return &FriendSettings{} 18 | } 19 | 20 | func (ct *FriendSettings) GetFriendSettings(c *gin.Context) { 21 | var req dto.FriendSettingsRequest 22 | resp := appx.NewResponse(c) 23 | if ok, err := appx.BindAndValid(c, &req); !ok || err != nil { 24 | resp.ToErrorResponse(errors.New("参数错误")) 25 | return 26 | } 27 | friendSettings, err := service.NewFriendSettingsService(c).GetFriendSettings(req.ContactID) 28 | if err != nil { 29 | resp.ToErrorResponse(err) 30 | return 31 | } 32 | if friendSettings == nil { 33 | resp.ToResponse(model.FriendSettings{}) 34 | return 35 | } 36 | resp.ToResponse(friendSettings) 37 | } 38 | 39 | func (ct *FriendSettings) SaveFriendSettings(c *gin.Context) { 40 | var req model.FriendSettings 41 | resp := appx.NewResponse(c) 42 | if ok, err := appx.BindAndValid(c, &req); !ok || err != nil { 43 | resp.ToErrorResponse(errors.New("参数错误")) 44 | return 45 | } 46 | err := service.NewFriendSettingsService(c).SaveFriendSettings(&req) 47 | if err != nil { 48 | resp.ToErrorResponse(err) 49 | return 50 | } 51 | resp.ToResponse(nil) 52 | } 53 | -------------------------------------------------------------------------------- /repository/global_settings.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "wechat-robot-client/model" 6 | 7 | "gorm.io/gorm" 8 | ) 9 | 10 | type GlobalSettings struct { 11 | Ctx context.Context 12 | DB *gorm.DB 13 | } 14 | 15 | func NewGlobalSettingsRepo(ctx context.Context, db *gorm.DB) *GlobalSettings { 16 | return &GlobalSettings{ 17 | Ctx: ctx, 18 | DB: db, 19 | } 20 | } 21 | 22 | func (respo *GlobalSettings) GetGlobalSettings() (*model.GlobalSettings, error) { 23 | var globalSettings model.GlobalSettings 24 | err := respo.DB.WithContext(respo.Ctx).First(&globalSettings).Error 25 | if err == gorm.ErrRecordNotFound { 26 | return nil, nil 27 | } 28 | if err != nil { 29 | return nil, err 30 | } 31 | return &globalSettings, nil 32 | } 33 | 34 | func (respo *GlobalSettings) GetRandomOne() (*model.GlobalSettings, error) { 35 | var globalSettings model.GlobalSettings 36 | err := respo.DB.WithContext(respo.Ctx).First(&globalSettings).Error 37 | if err == gorm.ErrRecordNotFound { 38 | return nil, nil 39 | } 40 | if err != nil { 41 | return nil, err 42 | } 43 | return &globalSettings, nil 44 | } 45 | 46 | func (respo *GlobalSettings) Create(data *model.GlobalSettings) error { 47 | return respo.DB.WithContext(respo.Ctx).Create(data).Error 48 | } 49 | 50 | func (respo *GlobalSettings) Update(data *model.GlobalSettings) error { 51 | return respo.DB.WithContext(respo.Ctx).Where("id = ?", data.ID).Updates(data).Error 52 | } 53 | -------------------------------------------------------------------------------- /dto/ai_voice.callback.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type Phoneme struct { 4 | Ph string `form:"ph" json:"ph"` 5 | Begin int `form:"begin" json:"begin"` 6 | End int `form:"end" json:"end"` 7 | } 8 | 9 | type Word struct { 10 | Text string `form:"text" json:"text"` 11 | Begin int `form:"begin" json:"begin"` 12 | End int `form:"end" json:"end"` 13 | Phonemes []Phoneme `form:"phonemes" json:"phonemes"` 14 | } 15 | 16 | type Sentence struct { 17 | Text string `form:"text" json:"text"` 18 | OriginText string `form:"origin_text" json:"origin_text"` 19 | ParagraphNo int `form:"paragraph_no" json:"paragraph_no"` 20 | BeginTime int `form:"begin_time" json:"begin_time"` 21 | EndTime int `form:"end_time" json:"end_time"` 22 | Emotion string `form:"emotion" json:"emotion"` 23 | Words []Word `form:"words" json:"words"` 24 | } 25 | 26 | type DoubaoTTSCallbackRequest struct { 27 | Code int `form:"code" json:"code"` 28 | Message string `form:"message" json:"message"` 29 | TaskID string `form:"task_id" json:"task_id"` 30 | TaskStatus int `form:"task_status" json:"task_status"` 31 | TextLength int `form:"text_length" json:"text_length"` 32 | AudioURL string `form:"audio_url" json:"audio_url"` 33 | URLExpireTime int `form:"url_expire_time" json:"url_expire_time"` 34 | Sentences []Sentence `form:"sentences" json:"sentences"` 35 | } 36 | -------------------------------------------------------------------------------- /.deploy/local/nginx.conf: -------------------------------------------------------------------------------- 1 | resolver 127.0.0.11 ipv6=off valid=30s; 2 | 3 | upstream admin_backend { 4 | zone admin_backend 64k; # 共享内存区,用于缓存 upstream 状态 5 | server wechat-robot-admin-backend:9000 resolve; 6 | } 7 | 8 | upstream admin_frontend { 9 | zone admin_frontend 64k; 10 | server wechat-robot-admin-frontend:9000 resolve; 11 | } 12 | 13 | server { 14 | root /var/www/html; 15 | index index.html index.htm index.nginx-debian.html; 16 | server_name _; 17 | 18 | # 后端 API 19 | location /api/v1 { 20 | client_max_body_size 64m; 21 | proxy_http_version 1.1; 22 | proxy_pass http://admin_backend; 23 | proxy_set_header Host $host; 24 | proxy_set_header X-Real-IP $remote_addr; 25 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 26 | proxy_set_header X-Forwarded-Proto $scheme; 27 | proxy_cache_bypass $http_upgrade; 28 | proxy_set_header Accept-Encoding gzip; 29 | proxy_connect_timeout 5s; 30 | proxy_send_timeout 900s; 31 | proxy_read_timeout 900s; 32 | } 33 | 34 | # 前端静态资源 35 | location / { 36 | client_max_body_size 64m; 37 | proxy_http_version 1.1; 38 | proxy_pass http://admin_frontend; 39 | proxy_set_header Host $host; 40 | proxy_set_header X-Forwarded-For $remote_addr; 41 | proxy_cache_bypass $http_upgrade; 42 | proxy_set_header Accept-Encoding gzip; 43 | proxy_read_timeout 60s; 44 | } 45 | 46 | listen 9000 default_server; 47 | listen [::]:9000 default_server; 48 | } -------------------------------------------------------------------------------- /.deploy/server/nginx.conf: -------------------------------------------------------------------------------- 1 | resolver 127.0.0.11 ipv6=off valid=30s; 2 | 3 | upstream admin_backend { 4 | zone admin_backend 64k; # 共享内存区,用于缓存 upstream 状态 5 | server wechat-robot-admin-backend:9000 resolve; 6 | } 7 | 8 | upstream admin_frontend { 9 | zone admin_frontend 64k; 10 | server wechat-robot-admin-frontend:9000 resolve; 11 | } 12 | 13 | server { 14 | root /var/www/html; 15 | index index.html index.htm index.nginx-debian.html; 16 | server_name _; 17 | 18 | # 后端 API 19 | location /api/v1 { 20 | client_max_body_size 64m; 21 | proxy_http_version 1.1; 22 | proxy_pass http://admin_backend; 23 | proxy_set_header Host $host; 24 | proxy_set_header X-Real-IP $remote_addr; 25 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 26 | proxy_set_header X-Forwarded-Proto $scheme; 27 | proxy_cache_bypass $http_upgrade; 28 | proxy_set_header Accept-Encoding gzip; 29 | proxy_connect_timeout 5s; 30 | proxy_send_timeout 900s; 31 | proxy_read_timeout 900s; 32 | } 33 | 34 | # 前端静态资源 35 | location / { 36 | client_max_body_size 64m; 37 | proxy_http_version 1.1; 38 | proxy_pass http://admin_frontend; 39 | proxy_set_header Host $host; 40 | proxy_set_header X-Forwarded-For $remote_addr; 41 | proxy_cache_bypass $http_upgrade; 42 | proxy_set_header Accept-Encoding gzip; 43 | proxy_read_timeout 60s; 44 | } 45 | 46 | listen 9000 default_server; 47 | listen [::]:9000 default_server; 48 | } -------------------------------------------------------------------------------- /plugin/plugins/friend_chat.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "log" 5 | "wechat-robot-client/interface/plugin" 6 | "wechat-robot-client/service" 7 | "wechat-robot-client/vars" 8 | ) 9 | 10 | type FriendAIChatPlugin struct{} 11 | 12 | func NewFriendAIChatPlugin() plugin.MessageHandler { 13 | return &FriendAIChatPlugin{} 14 | } 15 | 16 | func (p *FriendAIChatPlugin) GetName() string { 17 | return "FriendAIChat" 18 | } 19 | 20 | func (p *FriendAIChatPlugin) GetLabels() []string { 21 | return []string{"text", "chat"} 22 | } 23 | 24 | func (p *FriendAIChatPlugin) PreAction(ctx *plugin.MessageContext) bool { 25 | return true 26 | } 27 | 28 | func (p *FriendAIChatPlugin) PostAction(ctx *plugin.MessageContext) { 29 | 30 | } 31 | 32 | func (p *FriendAIChatPlugin) Run(ctx *plugin.MessageContext) bool { 33 | if ctx.Message.IsChatRoom { 34 | return false 35 | } 36 | // 修复 AI 会响应自己发送(从其他设备)的消息的问题 37 | if ctx.Message != nil && ctx.Message.SenderWxID == vars.RobotRuntime.WxID { 38 | return false 39 | } 40 | aiChatService := service.NewAIChatService(ctx.Context, ctx.Settings) 41 | isAIEnabled := ctx.Settings.IsAIChatEnabled() 42 | if isAIEnabled { 43 | defer func() { 44 | aiChatService.RenewAISession(ctx.Message) 45 | err := ctx.MessageService.SetMessageIsInContext(ctx.Message) 46 | if err != nil { 47 | log.Printf("更新消息上下文失败: %v", err) 48 | } 49 | }() 50 | aiChat := NewAIChatPlugin() 51 | aiChat.Run(ctx) 52 | return true 53 | } 54 | return false 55 | } 56 | -------------------------------------------------------------------------------- /repository/moment_comment.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "time" 6 | "wechat-robot-client/model" 7 | 8 | "gorm.io/gorm" 9 | ) 10 | 11 | type MomentComment struct { 12 | Ctx context.Context 13 | DB *gorm.DB 14 | } 15 | 16 | func NewMomentCommentRepo(ctx context.Context, db *gorm.DB) *MomentComment { 17 | return &MomentComment{ 18 | Ctx: ctx, 19 | DB: db, 20 | } 21 | } 22 | 23 | // IsTodayHasCommented 今天这个好友的朋友圈是否被评论过了 24 | func (respo *MomentComment) IsTodayHasCommented(contactID string) (bool, error) { 25 | var comment model.MomentComment 26 | // 获取今天凌晨零点 27 | now := time.Now() 28 | todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) 29 | todayStartTimestamp := todayStart.Unix() 30 | err := respo.DB.WithContext(respo.Ctx). 31 | Where("created_at >= ? AND wechat_id = ?", todayStartTimestamp, contactID). 32 | First(&comment).Error 33 | if err == gorm.ErrRecordNotFound { 34 | return false, nil 35 | } 36 | if err != nil { 37 | return false, err 38 | } 39 | return true, nil 40 | } 41 | 42 | func (respo *MomentComment) Create(data *model.MomentComment) error { 43 | return respo.DB.WithContext(respo.Ctx).Create(data).Error 44 | } 45 | 46 | func (respo *MomentComment) Update(data *model.MomentComment) error { 47 | return respo.DB.WithContext(respo.Ctx).Updates(data).Error 48 | } 49 | 50 | func (c *MomentComment) Delete(data *model.MomentComment) error { 51 | return c.DB.WithContext(c.Ctx).Unscoped().Delete(data).Error 52 | } 53 | -------------------------------------------------------------------------------- /dto/login.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type TFARequest struct { 4 | Uuid string `form:"uuid" json:"uuid" binding:"required"` 5 | Code string `form:"code" json:"code" binding:"required"` 6 | Ticket string `form:"ticket" json:"ticket" binding:"required"` 7 | Data62 string `form:"data62" json:"data62" binding:"required"` 8 | } 9 | 10 | type LogoutNotificationRequest struct { 11 | WxID string `form:"wxid" json:"wxid"` 12 | Type string `form:"type" json:"type"` 13 | Status string `form:"status" json:"status"` 14 | RetryCount int `form:"retry_count" json:"retry_count"` 15 | } 16 | 17 | type PushPlusNotificationRequest struct { 18 | Token string `json:"token"` 19 | Title string `json:"title"` 20 | Content string `json:"content"` 21 | Template string `json:"template"` 22 | Channel string `json:"channel"` 23 | Webhook string `json:"webhook"` 24 | CallbackUrl string `json:"callbackUrl"` 25 | Timestamp string `json:"timestamp"` 26 | Pre string `json:"pre"` 27 | } 28 | 29 | type PushPlusNotificationResponse struct { 30 | Code int `json:"code"` 31 | Msg string `json:"msg"` 32 | Data string `json:"data"` 33 | } 34 | 35 | type SliderVerifyRequest struct { 36 | Data62 string `form:"data62" json:"data62" binding:"required"` 37 | Ticket string `form:"ticket" json:"ticket" binding:"required"` 38 | } 39 | 40 | type LoginRequest struct { 41 | Username string `form:"username" json:"username" binding:"required"` 42 | Password string `form:"password" json:"password" binding:"required"` 43 | } 44 | -------------------------------------------------------------------------------- /controller/wechat_server_callback.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "log" 5 | "wechat-robot-client/dto" 6 | "wechat-robot-client/pkg/appx" 7 | "wechat-robot-client/pkg/robot" 8 | "wechat-robot-client/service" 9 | 10 | "github.com/gin-gonic/gin" 11 | ) 12 | 13 | type WechatServerCallback struct { 14 | } 15 | 16 | func NewWechatServerCallbackController() *WechatServerCallback { 17 | return &WechatServerCallback{} 18 | } 19 | 20 | func (ct *WechatServerCallback) SyncMessageCallback(c *gin.Context) { 21 | wechatID := c.Param("wechatID") 22 | log.Printf("Received SyncMessageCallback for wechatID: %s", wechatID) 23 | var req robot.ClientResponse[robot.SyncMessage] 24 | resp := appx.NewResponse(c) 25 | if err := c.ShouldBindJSON(&req); err != nil { 26 | resp.ToErrorResponse(err) 27 | return 28 | } 29 | service.NewLoginService(c).SyncMessageCallback(wechatID, req.Data) 30 | 31 | resp.ToResponse(nil) 32 | } 33 | 34 | func (ct *WechatServerCallback) LogoutCallback(c *gin.Context) { 35 | wechatID := c.Param("wechatID") 36 | log.Printf("Received LogoutCallback for wechatID: %s", wechatID) 37 | var req dto.LogoutNotificationRequest 38 | resp := appx.NewResponse(c) 39 | if err := c.ShouldBindJSON(&req); err != nil { 40 | log.Printf("LogoutCallback binding error: %v", err) 41 | resp.ToErrorResponse(err) 42 | return 43 | } 44 | err := service.NewLoginService(c).LogoutCallback(req) 45 | if err != nil { 46 | log.Printf("LogoutCallback failed: %v\n", err) 47 | resp.ToErrorResponse(err) 48 | return 49 | } 50 | resp.ToResponse(nil) 51 | } 52 | -------------------------------------------------------------------------------- /.deploy/local/my.cnf: -------------------------------------------------------------------------------- 1 | # For advice on how to change settings please see 2 | # http://dev.mysql.com/doc/refman/8.2/en/server-configuration-defaults.html 3 | 4 | [mysqld] 5 | # 6 | # Remove leading # and set to the amount of RAM for the most important data 7 | # cache in MySQL. Start at 70% of total RAM for dedicated server, else 10%. 8 | # innodb_buffer_pool_size = 128M 9 | # 10 | # Remove leading # to turn on a very important data integrity option: logging 11 | # changes to the binary log between backups. 12 | # log_bin 13 | # 14 | # Remove leading # to set options mainly useful for reporting servers. 15 | # The server defaults are faster for transactions and fast SELECTs. 16 | # Adjust sizes as needed, experiment to find the optimal values. 17 | # join_buffer_size = 128M 18 | # sort_buffer_size = 2M 19 | # read_rnd_buffer_size = 2M 20 | 21 | # Remove leading # to revert to previous value for default_authentication_plugin, 22 | # this will increase compatibility with older clients. For background, see: 23 | # https://dev.mysql.com/doc/refman/8.2/en/server-system-variables.html#sysvar_default_authentication_plugin 24 | # default-authentication-plugin=mysql_native_password 25 | skip-host-cache 26 | skip-name-resolve 27 | datadir=/var/lib/mysql 28 | socket=/var/run/mysqld/mysqld.sock 29 | secure-file-priv=/var/lib/mysql-files 30 | user=mysql 31 | character-set-server=utf8mb4 32 | collation-server=utf8mb4_unicode_ci 33 | 34 | pid-file=/var/run/mysqld/mysqld.pid 35 | [client] 36 | socket=/var/run/mysqld/mysqld.sock 37 | default-character-set=utf8mb4 38 | 39 | !includedir /etc/mysql/conf.d/ -------------------------------------------------------------------------------- /.deploy/server/my.cnf: -------------------------------------------------------------------------------- 1 | # For advice on how to change settings please see 2 | # http://dev.mysql.com/doc/refman/8.2/en/server-configuration-defaults.html 3 | 4 | [mysqld] 5 | # 6 | # Remove leading # and set to the amount of RAM for the most important data 7 | # cache in MySQL. Start at 70% of total RAM for dedicated server, else 10%. 8 | # innodb_buffer_pool_size = 128M 9 | # 10 | # Remove leading # to turn on a very important data integrity option: logging 11 | # changes to the binary log between backups. 12 | # log_bin 13 | # 14 | # Remove leading # to set options mainly useful for reporting servers. 15 | # The server defaults are faster for transactions and fast SELECTs. 16 | # Adjust sizes as needed, experiment to find the optimal values. 17 | # join_buffer_size = 128M 18 | # sort_buffer_size = 2M 19 | # read_rnd_buffer_size = 2M 20 | 21 | # Remove leading # to revert to previous value for default_authentication_plugin, 22 | # this will increase compatibility with older clients. For background, see: 23 | # https://dev.mysql.com/doc/refman/8.2/en/server-system-variables.html#sysvar_default_authentication_plugin 24 | # default-authentication-plugin=mysql_native_password 25 | skip-host-cache 26 | skip-name-resolve 27 | datadir=/var/lib/mysql 28 | socket=/var/run/mysqld/mysqld.sock 29 | secure-file-priv=/var/lib/mysql-files 30 | user=mysql 31 | character-set-server=utf8mb4 32 | collation-server=utf8mb4_unicode_ci 33 | 34 | pid-file=/var/run/mysqld/mysqld.pid 35 | [client] 36 | socket=/var/run/mysqld/mysqld.sock 37 | default-character-set=utf8mb4 38 | 39 | !includedir /etc/mysql/conf.d/ -------------------------------------------------------------------------------- /common_cron/chat_room_ranking_month.go: -------------------------------------------------------------------------------- 1 | package common_cron 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "wechat-robot-client/service" 7 | "wechat-robot-client/vars" 8 | ) 9 | 10 | type ChatRoomRankingMonthCron struct { 11 | CronManager *CronManager 12 | } 13 | 14 | func NewChatRoomRankingMonthCron(cronManager *CronManager) vars.CommonCronInstance { 15 | return &ChatRoomRankingMonthCron{ 16 | CronManager: cronManager, 17 | } 18 | } 19 | 20 | func (cron *ChatRoomRankingMonthCron) IsActive() bool { 21 | if cron.CronManager.globalSettings.ChatRoomRankingEnabled != nil && *cron.CronManager.globalSettings.ChatRoomRankingEnabled { 22 | if cron.CronManager.globalSettings.ChatRoomRankingMonthCron != nil && *cron.CronManager.globalSettings.ChatRoomRankingMonthCron != "" { 23 | return true 24 | } 25 | } 26 | return false 27 | } 28 | 29 | func (cron *ChatRoomRankingMonthCron) Cron() error { 30 | return service.NewChatRoomService(context.Background()).ChatRoomRankingMonthly() 31 | } 32 | 33 | func (cron *ChatRoomRankingMonthCron) Register() { 34 | if !cron.IsActive() { 35 | log.Println("每月群聊排行榜任务未启用") 36 | return 37 | } 38 | err := cron.CronManager.AddJob(vars.ChatRoomRankingMonthCron, *cron.CronManager.globalSettings.ChatRoomRankingMonthCron, func() { 39 | log.Println("开始执行每月群聊排行榜任务") 40 | if err := cron.Cron(); err != nil { 41 | log.Printf("每月群聊排行榜任务执行失败: %v", err) 42 | } else { 43 | log.Println("每月群聊排行榜任务执行完成") 44 | } 45 | }) 46 | if err != nil { 47 | log.Printf("每月群聊排行榜任务注册失败: %v", err) 48 | return 49 | } 50 | log.Println("每月群聊排行榜任务初始化成功") 51 | } 52 | -------------------------------------------------------------------------------- /.github/workflows/ci-cd.yml: -------------------------------------------------------------------------------- 1 | name: Alibaba Cloud Registry Docker Image CI (异构) 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | push_to_acr: 9 | name: Push Docker image to Alibaba Cloud Registry 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Check out the repo 13 | uses: actions/checkout@v4 14 | 15 | - name: Set up QEMU 16 | uses: docker/setup-qemu-action@v3 17 | 18 | - name: Set up Docker Buildx 19 | uses: docker/setup-buildx-action@v3 20 | 21 | - name: Log in to Alibaba Cloud Registry 22 | run: | 23 | echo "${{ secrets.ACR_PASSWORD }}" | docker login --username="${{ secrets.ACR_USERNAME }}" --password-stdin ${{ secrets.ACR_REGISTRY }} 24 | 25 | - name: Extract metadata (tags, labels) for Docker 26 | id: meta 27 | uses: docker/metadata-action@v5 28 | with: 29 | images: ${{ secrets.ACR_REGISTRY }}/houhou/wechat-robot-client 30 | 31 | - name: Build and push Docker image to Alibaba Cloud Registry 32 | uses: docker/build-push-action@v5 33 | with: 34 | context: . 35 | file: ./Dockerfile 36 | platforms: linux/amd64,linux/arm64 37 | push: true 38 | build-args: | 39 | VERSION=${{ github.event.release.tag_name }} 40 | tags: | 41 | ${{ secrets.ACR_REGISTRY }}/houhou/wechat-robot-client:${{ github.event.release.tag_name }} 42 | ${{ secrets.ACR_REGISTRY }}/houhou/wechat-robot-client:latest 43 | labels: ${{ steps.meta.outputs.labels }} -------------------------------------------------------------------------------- /common_cron/chat_room_ranking_weekly.go: -------------------------------------------------------------------------------- 1 | package common_cron 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "wechat-robot-client/service" 7 | "wechat-robot-client/vars" 8 | ) 9 | 10 | type ChatRoomRankingWeeklyCron struct { 11 | CronManager *CronManager 12 | } 13 | 14 | func NewChatRoomRankingWeeklyCron(cronManager *CronManager) vars.CommonCronInstance { 15 | return &ChatRoomRankingWeeklyCron{ 16 | CronManager: cronManager, 17 | } 18 | } 19 | 20 | func (cron *ChatRoomRankingWeeklyCron) IsActive() bool { 21 | if cron.CronManager.globalSettings.ChatRoomRankingEnabled != nil && *cron.CronManager.globalSettings.ChatRoomRankingEnabled { 22 | if cron.CronManager.globalSettings.ChatRoomRankingWeeklyCron != nil && *cron.CronManager.globalSettings.ChatRoomRankingWeeklyCron != "" { 23 | return true 24 | } 25 | } 26 | return false 27 | } 28 | 29 | func (cron *ChatRoomRankingWeeklyCron) Cron() error { 30 | return service.NewChatRoomService(context.Background()).ChatRoomRankingWeekly() 31 | } 32 | 33 | func (cron *ChatRoomRankingWeeklyCron) Register() { 34 | if !cron.IsActive() { 35 | log.Println("每周群聊排行榜任务未启用") 36 | return 37 | } 38 | err := cron.CronManager.AddJob(vars.ChatRoomRankingWeeklyCron, *cron.CronManager.globalSettings.ChatRoomRankingWeeklyCron, func() { 39 | log.Println("开始执行每周群聊排行榜任务") 40 | if err := cron.Cron(); err != nil { 41 | log.Printf("每周群聊排行榜任务执行失败: %v", err) 42 | } else { 43 | log.Println("每周群聊排行榜任务执行完成") 44 | } 45 | }) 46 | if err != nil { 47 | log.Printf("每周群聊排行榜任务注册失败: %v", err) 48 | return 49 | } 50 | log.Println("每周群聊排行榜任务初始化成功") 51 | } 52 | -------------------------------------------------------------------------------- /model/system_message.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type SystemMessageType int 4 | 5 | const ( 6 | SystemMessageTypeVerify SystemMessageType = 37 // 认证消息 好友请求 7 | SystemMessageTypeJoinChatRoom SystemMessageType = 38 // 认证消息 加入群聊 8 | ) 9 | 10 | type SystemMessage struct { 11 | ID int64 `gorm:"primaryKey;autoIncrement;column:id" json:"id"` 12 | MsgID int64 `gorm:"uniqueIndex:uniq_msg_id;not null;column:msg_id" json:"msg_id"` 13 | ClientMsgID int64 `gorm:"not null;column:client_msg_id" json:"client_msg_id"` 14 | Type SystemMessageType `gorm:"index:idx_type;not null;column:type" json:"type"` 15 | ImageURL string `gorm:"type:varchar(512);column:image_url;comment:图片URL" json:"image_url"` 16 | Description string `gorm:"type:varchar(255);column:description;comment:备注" json:"description"` 17 | Content string `gorm:"type:text;column:content" json:"content"` 18 | FromWxid string `gorm:"type:varchar(64);index:idx_from_wxid;column:from_wxid" json:"from_wxid"` 19 | ToWxid string `gorm:"type:varchar(64);column:to_wxid" json:"to_wxid"` 20 | Status int `gorm:"not null;column:status;default:0;comment:'消息状态 0:未处理 1:已处理'" json:"status"` 21 | IsRead bool `gorm:"column:is_read;default:false;comment:'消息是否已读'" json:"is_read"` 22 | CreatedAt int64 `gorm:"index:idx_created_at;not null;column:created_at" json:"created_at"` 23 | UpdatedAt int64 `gorm:"not null;column:updated_at" json:"updated_at"` 24 | } 25 | 26 | func (SystemMessage) TableName() string { 27 | return "system_messages" 28 | } 29 | -------------------------------------------------------------------------------- /pkg/good_morning/good_morning_test.go: -------------------------------------------------------------------------------- 1 | package good_morning 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | "time" 9 | "wechat-robot-client/dto" 10 | ) 11 | 12 | func TestDraw(t *testing.T) { 13 | // 获取当前时间 14 | now := time.Now() 15 | // 获取年、月、日 16 | year := now.Year() 17 | month := now.Month() 18 | day := now.Day() 19 | // 获取星期 20 | weekday := now.Weekday() 21 | // 定义中文星期数组 22 | weekdays := [...]string{"星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"} 23 | 24 | summary := dto.ChatRoomSummary{} 25 | summary.ChatRoomID = "xxxxx" 26 | summary.Year = year 27 | summary.Month = int(month) 28 | summary.Date = day 29 | summary.Week = weekdays[weekday] 30 | summary.MemberTotalCount = 490 31 | summary.MemberJoinCount = 0 32 | summary.MemberLeaveCount = 1 33 | summary.MemberChatCount = 60 34 | summary.MessageCount = 1490 35 | 36 | image, err := Draw("知识是很美的,它们可以让你不出家门就了解这世上的许多事。", summary) 37 | if err != nil { 38 | t.Errorf("绘图失败: %v", err) 39 | return 40 | } 41 | // 将生成的图片保存到文件 42 | outputDir := "test_output" 43 | err = os.MkdirAll(outputDir, 0755) 44 | if err != nil { 45 | t.Errorf("创建输出目录失败: %v", err) 46 | return 47 | } 48 | 49 | // 生成文件名(包含时间戳) 50 | timestamp := now.Format("20060102_150405") 51 | filename := filepath.Join(outputDir, "good_morning_"+timestamp+".png") 52 | 53 | // 创建文件 54 | file, err := os.Create(filename) 55 | if err != nil { 56 | t.Errorf("创建文件失败: %v", err) 57 | return 58 | } 59 | defer file.Close() 60 | 61 | // 将 io.Reader 的内容复制到文件 62 | _, err = io.Copy(file, image) 63 | if err != nil { 64 | t.Errorf("保存图片失败: %v", err) 65 | return 66 | } 67 | 68 | t.Logf("图片已成功保存到: %s", filename) 69 | } 70 | -------------------------------------------------------------------------------- /pkg/mcp/types.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // 仅保留客户端内部用的统计与上下文字段 8 | 9 | // MCPServerInfo MCP服务器信息(本地缓存使用) 10 | type MCPServerInfo struct { 11 | Name string `json:"name"` 12 | Version string `json:"version"` 13 | Capabilities MCPCapabilities `json:"capabilities"` 14 | Instructions string `json:"instructions,omitempty"` 15 | Metadata map[string]string `json:"metadata,omitempty"` 16 | } 17 | 18 | // MCPCapabilities MCP服务器能力(本地缓存使用) 19 | type MCPCapabilities struct { 20 | Tools bool `json:"tools"` 21 | Resources bool `json:"resources"` 22 | Prompts bool `json:"prompts"` 23 | } 24 | 25 | // MCPConnectionStats MCP连接统计 26 | type MCPConnectionStats struct { 27 | ConnectedAt time.Time `json:"connectedAt"` 28 | LastActiveAt time.Time `json:"lastActiveAt"` 29 | RequestCount int64 `json:"requestCount"` 30 | SuccessCount int64 `json:"successCount"` 31 | ErrorCount int64 `json:"errorCount"` 32 | AverageLatency time.Duration `json:"averageLatency"` 33 | IsConnected bool `json:"isConnected"` 34 | } 35 | 36 | // RobotContext 在工具调用入参中透传的机器人上下文 37 | type RobotContext struct { 38 | WeChatClientPort string 39 | RobotID int64 40 | RobotCode string 41 | RobotRedisDB uint 42 | RobotWxID string 43 | FromWxID string 44 | SenderWxID string 45 | MessageID int64 46 | RefMessageID int64 47 | } 48 | 49 | // MessageSender 发送微信消息的适配器 50 | type MessageSender interface { 51 | SendTextMessage(toWxID, content string, at ...string) error 52 | SendAppMessage(toWxID string, appMsgType int, appMsgXml string) error 53 | } 54 | -------------------------------------------------------------------------------- /plugin/plugins/image_auto_upload.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "log" 5 | "time" 6 | "wechat-robot-client/interface/plugin" 7 | "wechat-robot-client/model" 8 | "wechat-robot-client/service" 9 | "wechat-robot-client/vars" 10 | ) 11 | 12 | type ImageAutoUploadPlugin struct{} 13 | 14 | func NewImageAutoUploadPlugin() plugin.MessageHandler { 15 | return &ImageAutoUploadPlugin{} 16 | } 17 | 18 | func (p *ImageAutoUploadPlugin) GetName() string { 19 | return "ImageAutoUpload" 20 | } 21 | 22 | func (p *ImageAutoUploadPlugin) GetLabels() []string { 23 | return []string{"image", "oss"} 24 | } 25 | 26 | func (p *ImageAutoUploadPlugin) PreAction(ctx *plugin.MessageContext) bool { 27 | return true 28 | } 29 | 30 | func (p *ImageAutoUploadPlugin) PostAction(ctx *plugin.MessageContext) { 31 | 32 | } 33 | 34 | func (p *ImageAutoUploadPlugin) Run(ctx *plugin.MessageContext) bool { 35 | if ctx.Message == nil || ctx.Message.Type != model.MsgTypeImage { 36 | return false 37 | } 38 | if time.Now().Unix()-vars.RobotRuntime.LoginTime < 60 { 39 | log.Printf("登录时间不足60秒,跳过图片自动上传") 40 | return true 41 | } 42 | ossSettingService := service.NewOSSSettingService(ctx.Context) 43 | ossSettings, err := ossSettingService.GetOSSSettingService() 44 | if err != nil { 45 | log.Printf("获取OSS设置失败: %v", err) 46 | return true 47 | } 48 | if ossSettings == nil { 49 | log.Printf("OSS设置为空") 50 | return true 51 | } 52 | if ossSettings.AutoUploadImage != nil && *ossSettings.AutoUploadImage && ossSettings.AutoUploadImageMode == model.AutoUploadModeAll { 53 | err := ossSettingService.UploadImageToOSS(ossSettings, ctx.Message) 54 | if err != nil { 55 | log.Printf("上传图片到OSS失败: %v", err) 56 | } 57 | return true 58 | } 59 | return false 60 | } 61 | -------------------------------------------------------------------------------- /dto/contact.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type ContactListRequest struct { 4 | ContactIDs []string `form:"contact_ids" json:"contact_ids"` 5 | Type string `form:"type" json:"type"` 6 | Keyword string `form:"keyword" json:"keyword"` 7 | } 8 | 9 | type FriendSearchRequest struct { 10 | ToUserName string `form:"to_username" json:"to_username" binding:"required"` 11 | FromScene int `form:"from_scene" json:"from_scene"` 12 | SearchScene int `form:"search_scene" json:"search_scene"` 13 | } 14 | 15 | type FriendSearchResponse struct { 16 | UserName string `json:"username"` 17 | NickName string `json:"nickname"` 18 | Avatar string `json:"avatar"` 19 | AntispamTicket string `json:"antispam_ticket"` 20 | } 21 | 22 | type FriendSendRequestRequest struct { 23 | V1 string `form:"v1" json:"V1" binding:"required"` 24 | V2 string `form:"v2" json:"V2"` 25 | Opcode int `form:"opcode" json:"Opcode"` 26 | Scene int `form:"scene" json:"Scene"` 27 | VerifyContent string `form:"verify_content" json:"verify_content"` 28 | } 29 | 30 | type FriendSendRequestFromChatRoomRequest struct { 31 | ChatRoomMemberID int64 `form:"chat_room_member_id" json:"chat_room_member_id" binding:"required"` 32 | VerifyContent string `form:"verify_content" json:"verify_content"` 33 | } 34 | 35 | type FriendSetRemarksRequest struct { 36 | ToWxid string `form:"to_wxid" json:"to_wxid" binding:"required"` 37 | Remarks string `form:"remarks" json:"remarks" binding:"required"` 38 | } 39 | 40 | type FriendPassVerifyRequest struct { 41 | SystemMessageID int64 `form:"system_message_id" json:"system_message_id"` 42 | } 43 | 44 | type FriendDeleteRequest struct { 45 | ContactID string `form:"contact_id" json:"contact_id"` 46 | } 47 | -------------------------------------------------------------------------------- /service/word_cloud.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "strings" 7 | "wechat-robot-client/dto" 8 | "wechat-robot-client/repository" 9 | "wechat-robot-client/utils" 10 | "wechat-robot-client/vars" 11 | 12 | "github.com/go-resty/resty/v2" 13 | ) 14 | 15 | type WordCloudService struct { 16 | ctx context.Context 17 | msgRepo *repository.Message 18 | } 19 | 20 | func NewWordCloudService(ctx context.Context) *WordCloudService { 21 | return &WordCloudService{ 22 | ctx: ctx, 23 | msgRepo: repository.NewMessageRepo(ctx, vars.DB), 24 | } 25 | } 26 | 27 | func (s *WordCloudService) WordCloudDaily(chatRoomID, aiTriggerWord string, startTime, endTime int64) ([]byte, error) { 28 | messages, err := s.msgRepo.GetMessagesByTimeRange(vars.RobotRuntime.WxID, chatRoomID, startTime, endTime) 29 | if err != nil { 30 | return nil, err 31 | } 32 | if len(messages) == 0 { 33 | log.Printf("[词云] 群聊 %s 在昨天没有消息,跳过处理\n", chatRoomID) 34 | return nil, nil 35 | } 36 | // 使用 strings.Builder 高效拼接字符串 37 | var builder strings.Builder 38 | for i, msg := range messages { 39 | // 去除首尾空格 40 | content := strings.TrimSpace(msg.Message) 41 | // 去除艾特,去除AI触发词 42 | content = utils.TrimAITriggerAll(content, aiTriggerWord) 43 | // 如果内容为空,跳过 44 | if content == "" { 45 | continue 46 | } 47 | // 添加内容 48 | builder.WriteString(content) 49 | // 如果不是最后一个元素,添加换行符 50 | if i < len(messages)-1 { 51 | builder.WriteString("\n") 52 | } 53 | } 54 | resp, err := resty.New().R(). 55 | SetBody(dto.WordCloudRequest{ 56 | ChatRoomID: chatRoomID, 57 | Content: builder.String(), 58 | Mode: "yesterday", 59 | }). 60 | Post(vars.WordCloudUrl) 61 | if err != nil { 62 | return nil, err 63 | } 64 | return resp.Body(), nil 65 | } 66 | -------------------------------------------------------------------------------- /model/chat_room_member.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type ChatRoomMember struct { 4 | ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` // 主键ID 5 | ChatRoomID string `gorm:"column:chat_room_id;not null;index:idx_chat_room_id" json:"chat_room_id"` // 群ID 6 | WechatID string `gorm:"column:wechat_id;not null;index:idx_wechat_id" json:"wechat_id"` // 微信ID 7 | Alias string `gorm:"column:alias" json:"alias"` // 微信号 8 | Nickname string `gorm:"column:nickname" json:"nickname"` // 昵称 9 | Avatar string `gorm:"column:avatar" json:"avatar"` // 头像 10 | InviterWechatID string `gorm:"column:inviter_wechat_id;not null" json:"inviter_wechat_id"` // 邀请人微信ID 11 | IsAdmin bool `gorm:"column:is_admin;default:false" json:"is_admin"` // 是否群管理员 12 | IsLeaved *bool `gorm:"column:is_leaved;default:false" json:"is_leaved"` // 是否已经离开群聊 13 | Score *int64 `gorm:"column:score" json:"score"` // 积分 14 | Remark string `gorm:"column:remark" json:"remark"` // 备注 15 | JoinedAt int64 `gorm:"column:joined_at;not null" json:"joined_at"` // 加入时间 16 | LastActiveAt int64 `gorm:"column:last_active_at;not null" json:"last_active_at"` // 最近活跃时间 17 | LeavedAt *int64 `gorm:"column:leaved_at" json:"leaved_at"` // 离开时间 18 | } 19 | 20 | // TableName 设置表名 21 | func (ChatRoomMember) TableName() string { 22 | return "chat_room_members" 23 | } 24 | -------------------------------------------------------------------------------- /interface/plugin/message.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "wechat-robot-client/interface/settings" 7 | "wechat-robot-client/model" 8 | "wechat-robot-client/pkg/robot" 9 | 10 | "github.com/sashabaranov/go-openai" 11 | ) 12 | 13 | type MessageServiceIface interface { 14 | SendTextMessage(toWxID, content string, at ...string) error 15 | SendLongTextMessage(toWxID string, longText string) error 16 | SendAppMessage(toWxID string, appMsgType int, appMsgXml string) error 17 | MsgUploadImg(toWxID string, image io.Reader) (*model.Message, error) 18 | SendImageMessageByRemoteURL(toWxID string, imageURL string) error 19 | SendVideoMessageByRemoteURL(toWxID string, videoURL string) error 20 | MsgSendVoice(toWxID string, voice io.Reader, voiceExt string) error 21 | MsgSendVideo(toWxID string, video io.Reader, videoExt string) error 22 | SendMusicMessage(toWxID string, songTitle string) error 23 | ShareLink(toWxID string, shareLinkInfo robot.ShareLinkMessage) error 24 | ResetChatRoomAIMessageContext(message *model.Message) error 25 | GetAIMessageContext(message *model.Message) ([]openai.ChatCompletionMessage, error) 26 | SetMessageIsInContext(message *model.Message) error 27 | XmlDecoder(content string) (robot.XmlMessage, error) 28 | UpdateMessage(message *model.Message) error 29 | ChatRoomAIDisabled(chatRoomID string) error 30 | } 31 | 32 | type MessageContext struct { 33 | Context context.Context 34 | Settings settings.Settings 35 | Message *model.Message 36 | MessageContent string 37 | Pat bool 38 | ReferMessage *model.Message 39 | MessageService MessageServiceIface 40 | } 41 | 42 | type MessageHandler interface { 43 | GetName() string 44 | GetLabels() []string 45 | PreAction(ctx *MessageContext) bool 46 | PostAction(ctx *MessageContext) 47 | Run(ctx *MessageContext) bool 48 | } 49 | -------------------------------------------------------------------------------- /model/ai_task.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "gorm.io/datatypes" 4 | 5 | type AITaskType string 6 | 7 | const ( 8 | AITaskTypeTTS AITaskType = "tts" // 长文本转语音 9 | AITaskTypeLongTextTTS AITaskType = "ltts" // 长文本转语音 10 | ) 11 | 12 | type AITaskStatus string 13 | 14 | const ( 15 | AITaskStatusPending AITaskStatus = "pending" // 待处理 16 | AITaskStatusProcessing AITaskStatus = "processing" // 处理中 17 | AITaskStatusCompleted AITaskStatus = "completed" // 已完成 18 | AITaskStatusFailed AITaskStatus = "failed" // 已失败 19 | ) 20 | 21 | type AITask struct { 22 | ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:主键ID" json:"id"` 23 | ContactID string `gorm:"column:contact_id;type:varchar(64);not null;index:idx_contact_id;comment:联系人ID" json:"contact_id"` 24 | MessageID int64 `gorm:"column:message_id;not null;comment:消息ID,关联messages表的msg_id" json:"message_id"` 25 | AIProviderTaskID string `gorm:"column:ai_provider_task_id;type:varchar(64);index:idx_ai_provider_task_id;comment:AI服务商任务ID" json:"ai_provider_task_id"` 26 | AITaskType AITaskType `gorm:"column:ai_task_type;type:enum('tts', 'ltts');not null;comment:ltts-长文本转语音" json:"ai_task_type"` 27 | AITaskStatus AITaskStatus `gorm:"column:ai_task_status;type:enum('pending','processing','completed','failed');not null;comment:任务状态:pending-待处理,processing-处理中,completed-已完成,failed-已失败" json:"ai_task_status"` 28 | Extra datatypes.JSON `gorm:"column:extra;type:json;comment:额外信息" json:"extra"` 29 | CreatedAt int64 `gorm:"column:created_at;not null;index:idx_created_at;comment:创建时间" json:"created_at"` 30 | UpdatedAt int64 `gorm:"column:updated_at;not null;comment:更新时间" json:"updated_at"` 31 | } 32 | 33 | // TableName 指定表名 34 | func (AITask) TableName() string { 35 | return "ai_task" 36 | } 37 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "time" 8 | "wechat-robot-client/common_cron" 9 | "wechat-robot-client/pkg/shutdown" 10 | "wechat-robot-client/router" 11 | "wechat-robot-client/startup" 12 | "wechat-robot-client/vars" 13 | 14 | "github.com/gin-gonic/gin" 15 | ) 16 | 17 | var Version = "unknown" 18 | 19 | func main() { 20 | log.Printf("[微信机器人]启动 版本: %s", Version) 21 | 22 | // 加载配置 23 | if err := startup.LoadConfig(); err != nil { 24 | log.Fatalf("加载配置失败: %v", err) 25 | } 26 | if err := startup.SetupVars(); err != nil { 27 | log.Fatalf("初始化失败: %v", err) 28 | } 29 | shutdownManager := shutdown.NewShutdownManager(30 * time.Second) 30 | // 注册消息处理插件 31 | startup.RegisterMessagePlugin() 32 | // 初始化微信机器人 33 | if err := startup.InitWechatRobot(); err != nil { 34 | log.Fatalf("启动微信机器人失败: %v", err) 35 | } 36 | // 初始化定时任务 37 | vars.CronManager = common_cron.NewCronManager() 38 | vars.CronManager.Clear() 39 | vars.CronManager.Start() 40 | // 初始化MCP服务 41 | err := startup.InitMCPService() 42 | if err != nil { 43 | log.Fatalf("初始化MCP服务失败: %v", err) 44 | } 45 | // 启动HTTP服务 46 | gin.SetMode(os.Getenv("GIN_MODE")) 47 | app := gin.Default() 48 | 49 | // 注册路由 50 | if err := router.RegisterRouter(app); err != nil { 51 | log.Fatalf("注册路由失败: %v", err) 52 | } 53 | dbConn := &shutdown.DBConnection{ 54 | DB: vars.DB, 55 | AdminDB: vars.AdminDB, 56 | } 57 | redisConn := &shutdown.RedisConnection{ 58 | Client: vars.RedisClient, 59 | } 60 | shutdownManager.Register(dbConn) 61 | shutdownManager.Register(redisConn) 62 | shutdownManager.Register(vars.RobotRuntime) 63 | shutdownManager.Register(vars.CronManager) 64 | shutdownManager.Register(vars.MCPService) 65 | // 开始监听停止信号 66 | shutdownManager.Start() 67 | // 启动服务 68 | if err := app.Run(fmt.Sprintf(":%s", vars.WechatClientPort)); err != nil { 69 | log.Panicf("服务启动失败:%v", err) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /service/system_settings.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "wechat-robot-client/model" 8 | "wechat-robot-client/repository" 9 | "wechat-robot-client/vars" 10 | ) 11 | 12 | type SystemSettingService struct { 13 | ctx context.Context 14 | systemSettingsRepo *repository.SystemSettings 15 | } 16 | 17 | func NewSystemSettingService(ctx context.Context) *SystemSettingService { 18 | return &SystemSettingService{ 19 | ctx: ctx, 20 | systemSettingsRepo: repository.NewSystemSettingsRepo(ctx, vars.DB), 21 | } 22 | } 23 | 24 | func (s *SystemSettingService) GetSystemSettings() (*model.SystemSettings, error) { 25 | systemSettings, err := s.systemSettingsRepo.GetSystemSettings() 26 | if err != nil { 27 | return nil, fmt.Errorf("获取系统设置失败: %w", err) 28 | } 29 | if systemSettings == nil { 30 | return &model.SystemSettings{}, nil 31 | } 32 | return systemSettings, nil 33 | } 34 | 35 | func (s *SystemSettingService) SaveSystemSettings(req *model.SystemSettings) error { 36 | var err error 37 | if req.ID == 0 { 38 | systemSettings, err := s.systemSettingsRepo.GetSystemSettings() 39 | if err != nil { 40 | return err 41 | } 42 | if systemSettings != nil { 43 | return fmt.Errorf("系统设置已存在,不能重复创建") 44 | } 45 | err = s.systemSettingsRepo.Create(req) 46 | if err != nil { 47 | return fmt.Errorf("创建系统设置失败: %w", err) 48 | } 49 | } else { 50 | err = s.systemSettingsRepo.Update(req) 51 | if err != nil { 52 | return fmt.Errorf("更新系统设置失败: %w", err) 53 | } 54 | } 55 | 56 | // 更新全局 webhook 配置 57 | s.updateWebhookConfig(req) 58 | return nil 59 | } 60 | 61 | func (s *SystemSettingService) updateWebhookConfig(req *model.SystemSettings) { 62 | if req.WebhookURL != nil { 63 | vars.Webhook.URL = *req.WebhookURL 64 | } else { 65 | vars.Webhook.URL = "" 66 | } 67 | if req.WebhookHeaders != nil { 68 | vars.Webhook.Headers = req.WebhookHeaders 69 | } else { 70 | vars.Webhook.Headers = nil 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /utils/ai_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "testing" 4 | 5 | func TestNormalizeAIBaseURL(t *testing.T) { 6 | tests := []struct { 7 | name string 8 | input string 9 | expected string 10 | }{ 11 | { 12 | name: "no version suffix", 13 | input: "https://api.openai.com", 14 | expected: "https://api.openai.com/v1", 15 | }, 16 | { 17 | name: "with trailing slash", 18 | input: "https://api.openai.com/", 19 | expected: "https://api.openai.com/v1", 20 | }, 21 | { 22 | name: "already has v1", 23 | input: "https://api.openai.com/v1", 24 | expected: "https://api.openai.com/v1", 25 | }, 26 | { 27 | name: "already has v1 with trailing slash", 28 | input: "https://api.openai.com/v1/", 29 | expected: "https://api.openai.com/v1", 30 | }, 31 | { 32 | name: "has v2", 33 | input: "https://api.openai.com/v2", 34 | expected: "https://api.openai.com/v2", 35 | }, 36 | { 37 | name: "has v3 with trailing slash", 38 | input: "https://api.openai.com/v3/", 39 | expected: "https://api.openai.com/v3", 40 | }, 41 | { 42 | name: "has v10", 43 | input: "https://api.openai.com/v10", 44 | expected: "https://api.openai.com/v10", 45 | }, 46 | { 47 | name: "has non-version path", 48 | input: "https://api.openai.com/api", 49 | expected: "https://api.openai.com/api/v1", 50 | }, 51 | { 52 | name: "complex path without version", 53 | input: "https://api.openai.com/chat/completions", 54 | expected: "https://api.openai.com/chat/completions/v1", 55 | }, 56 | { 57 | name: "complex path with version", 58 | input: "https://api.openai.com/chat/v2", 59 | expected: "https://api.openai.com/chat/v2", 60 | }, 61 | } 62 | 63 | for _, tt := range tests { 64 | t.Run(tt.name, func(t *testing.T) { 65 | result := NormalizeAIBaseURL(tt.input) 66 | if result != tt.expected { 67 | t.Errorf("NormalizeAIBaseURL(%q) = %q, want %q", tt.input, result, tt.expected) 68 | } 69 | }) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /repository/ai_task.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "time" 6 | "wechat-robot-client/model" 7 | 8 | "gorm.io/gorm" 9 | ) 10 | 11 | type AITask struct { 12 | Ctx context.Context 13 | DB *gorm.DB 14 | } 15 | 16 | func NewAITaskRepo(ctx context.Context, db *gorm.DB) *AITask { 17 | return &AITask{ 18 | Ctx: ctx, 19 | DB: db, 20 | } 21 | } 22 | 23 | func (repo *AITask) GetByID(id int64) (*model.AITask, error) { 24 | var task model.AITask 25 | err := repo.DB.WithContext(repo.Ctx).Where("id = ?", id).First(&task).Error 26 | if err == gorm.ErrRecordNotFound { 27 | return nil, nil 28 | } 29 | if err != nil { 30 | return nil, err 31 | } 32 | return &task, nil 33 | } 34 | 35 | func (repo *AITask) GetOngoingByWeChatID(wxID string) ([]*model.AITask, error) { 36 | var tasks []*model.AITask 37 | // 查询进行中的任务,状态为Pending或Processing,并且创建时间在3小时内 38 | err := repo.DB.WithContext(repo.Ctx).Where("contact_id = ? AND ai_task_status in (?) AND created_at > ?", wxID, []model.AITaskStatus{model.AITaskStatusPending, model.AITaskStatusProcessing}, time.Now().Add(-3*time.Hour)).Find(&tasks).Error 39 | if err != nil { 40 | return nil, err 41 | } 42 | return tasks, nil 43 | } 44 | 45 | func (repo *AITask) GetByMessageID(id int64) (*model.AITask, error) { 46 | var task model.AITask 47 | err := repo.DB.WithContext(repo.Ctx).Where("message_id = ?", id).First(&task).Error 48 | if err == gorm.ErrRecordNotFound { 49 | return nil, nil 50 | } 51 | if err != nil { 52 | return nil, err 53 | } 54 | return &task, nil 55 | } 56 | 57 | func (repo *AITask) GetByAIProviderTaskID(id string) (*model.AITask, error) { 58 | var task model.AITask 59 | err := repo.DB.WithContext(repo.Ctx).Where("ai_provider_task_id = ?", id).First(&task).Error 60 | if err == gorm.ErrRecordNotFound { 61 | return nil, nil 62 | } 63 | if err != nil { 64 | return nil, err 65 | } 66 | return &task, nil 67 | } 68 | 69 | func (repo *AITask) Create(data *model.AITask) error { 70 | return repo.DB.WithContext(repo.Ctx).Create(data).Error 71 | } 72 | 73 | func (repo *AITask) Update(data *model.AITask) error { 74 | return repo.DB.WithContext(repo.Ctx).Where("id = ?", data.ID).Updates(data).Error 75 | } 76 | -------------------------------------------------------------------------------- /dto/moments.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import "wechat-robot-client/pkg/robot" 4 | 5 | type FriendCircleGetListRequest struct { 6 | FristPageMd5 string `form:"frist_page_md5" json:"frist_page_md5"` 7 | MaxID string `form:"max_id" json:"max_id" binding:"required"` 8 | } 9 | 10 | type DownFriendCircleMediaRequest struct { 11 | Url string `form:"url" json:"url" binding:"required"` 12 | Key string `form:"key" json:"key"` 13 | } 14 | 15 | type MomentPostRequest struct { 16 | Content string `form:"content" json:"content"` 17 | MediaList []robot.FriendCircleUploadResponse `form:"media_list" json:"media_list"` 18 | WithUserList []string `form:"with_user_list" json:"with_user_list"` 19 | ShareType string `form:"share_type" json:"share_type" binding:"required"` 20 | ShareWith []string `form:"share_with" json:"share_with"` 21 | DoNotShare []string `form:"donot_share" json:"donot_share"` 22 | } 23 | 24 | type MomentOpRequest struct { 25 | Id string `form:"Id" json:"Id" binding:"required"` 26 | Type uint32 `form:"Type" json:"Type" binding:"required"` 27 | CommentId uint32 `form:"CommentId" json:"CommentId"` 28 | } 29 | 30 | type MomentPrivacySettingsRequest struct { 31 | Function uint32 `form:"Function" json:"Function"` 32 | Value uint32 `form:"Value" json:"Value"` 33 | } 34 | 35 | type FriendCircleCommentRequest struct { 36 | Type uint32 `form:"Type" json:"Type" binding:"required"` 37 | Id string `form:"Id" json:"Id" binding:"required"` 38 | ReplyCommnetId uint32 `form:"ReplyCommnetId" json:"ReplyCommnetId"` 39 | Content string `form:"Content" json:"Content"` 40 | } 41 | 42 | type FriendCircleGetDetailRequest struct { 43 | Towxid string `form:"Towxid" json:"Towxid" binding:"required"` 44 | Fristpagemd5 string `form:"Fristpagemd5" json:"Fristpagemd5"` 45 | Maxid uint64 `form:"Maxid" json:"Maxid"` 46 | } 47 | 48 | type FriendCircleGetIdDetailRequest struct { 49 | Towxid string `form:"Towxid" json:"Towxid" binding:"required"` 50 | Id uint64 `form:"Id" json:"Id" binding:"required"` 51 | } 52 | -------------------------------------------------------------------------------- /dto/chat_room.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type ChatRoomSettingsRequest struct { 4 | ChatRoomID string `form:"chat_room_id" json:"chat_room_id" binding:"required"` 5 | } 6 | 7 | type SyncChatRoomMemberRequest struct { 8 | ChatRoomID string `form:"chat_room_id" json:"chat_room_id" binding:"required"` 9 | } 10 | 11 | type ChatRoomMemberRequest struct { 12 | ChatRoomID string `form:"chat_room_id" json:"chat_room_id" binding:"required"` 13 | Keyword string `form:"keyword" json:"keyword"` 14 | } 15 | 16 | type ChatRoomRequestBase struct { 17 | ChatRoomID string `form:"chat_room_id" json:"chat_room_id" binding:"required"` 18 | } 19 | 20 | type ChatRoomOperateRequest struct { 21 | ChatRoomRequestBase 22 | Content string `form:"content" json:"content" binding:"required"` 23 | } 24 | 25 | type DelChatRoomMemberRequest struct { 26 | ChatRoomRequestBase 27 | MemberIDs []string `form:"member_ids" json:"member_ids" binding:"required"` 28 | } 29 | 30 | type CreateChatRoomRequest struct { 31 | ContactIDs []string `form:"contact_ids" json:"contact_ids" binding:"required"` 32 | } 33 | 34 | type InviteChatRoomMemberRequest struct { 35 | ChatRoomID string `form:"chat_room_id" json:"chat_room_id" binding:"required"` 36 | ContactIDs []string `form:"contact_ids" json:"contact_ids" binding:"required"` 37 | } 38 | 39 | type GroupConsentToJoinRequest struct { 40 | SystemMessageID int64 `form:"system_message_id" json:"system_message_id"` 41 | } 42 | 43 | // ChatRoomSummary 群动态 44 | type ChatRoomSummary struct { 45 | ChatRoomID string 46 | Year int 47 | Month int 48 | Date int 49 | Week string 50 | MemberTotalCount int // 当前群成员总数 51 | MemberJoinCount int // 昨天入群数 52 | MemberLeaveCount int // 昨天离群数 53 | MemberChatCount int // 昨天聊天人数 54 | MessageCount int // 昨天消息数 55 | } 56 | 57 | type ChatRoomRank struct { 58 | SenderWxID string `gorm:"column:sender_wxid" json:"sender_wxid"` // 微信Id 59 | ChatRoomMemberNickname string `gorm:"column:chat_room_member_nickname" json:"chat_room_member_nickname"` // 昵称 60 | Count int64 `gorm:"column:count" json:"count"` // 消息数 61 | } 62 | -------------------------------------------------------------------------------- /pkg/shutdown/manage.go: -------------------------------------------------------------------------------- 1 | package shutdown 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | "os/signal" 8 | "sync" 9 | "syscall" 10 | "time" 11 | ) 12 | 13 | // ShutdownHandler 优雅退出处理器 14 | type ShutdownHandler interface { 15 | Shutdown(ctx context.Context) error 16 | Name() string 17 | } 18 | 19 | // ShutdownManager 优雅退出管理器 20 | type ShutdownManager struct { 21 | handlers []ShutdownHandler 22 | timeout time.Duration 23 | mu sync.RWMutex 24 | } 25 | 26 | // NewShutdownManager 创建优雅退出管理器 27 | func NewShutdownManager(timeout time.Duration) *ShutdownManager { 28 | return &ShutdownManager{ 29 | handlers: make([]ShutdownHandler, 0), 30 | timeout: timeout, 31 | } 32 | } 33 | 34 | // Register 注册需要优雅退出的组件 35 | func (m *ShutdownManager) Register(handler ShutdownHandler) { 36 | m.mu.Lock() 37 | defer m.mu.Unlock() 38 | m.handlers = append(m.handlers, handler) 39 | log.Printf("注册优雅退出处理函数: %s", handler.Name()) 40 | } 41 | 42 | // Start 开始监听程序终止信号 43 | func (m *ShutdownManager) Start() { 44 | quit := make(chan os.Signal, 1) 45 | signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) 46 | 47 | go func() { 48 | sig := <-quit 49 | log.Printf("接收到了程序终止信号: %v", sig) 50 | m.shutdown() 51 | os.Exit(0) 52 | }() 53 | } 54 | 55 | // shutdown 执行所有组件的优雅退出 56 | func (m *ShutdownManager) shutdown() { 57 | m.mu.RLock() 58 | handlers := make([]ShutdownHandler, len(m.handlers)) 59 | copy(handlers, m.handlers) 60 | m.mu.RUnlock() 61 | 62 | ctx, cancel := context.WithTimeout(context.Background(), m.timeout) 63 | defer cancel() 64 | 65 | // 并发执行所有组件的停止操作 66 | var wg sync.WaitGroup 67 | for _, handler := range handlers { 68 | wg.Add(1) 69 | go func(h ShutdownHandler) { 70 | defer wg.Done() 71 | 72 | log.Printf("正在终止: %s", h.Name()) 73 | if err := h.Shutdown(ctx); err != nil { 74 | log.Printf("异常终止 %s: %v", h.Name(), err) 75 | } else { 76 | log.Printf("正常终止: %s", h.Name()) 77 | } 78 | }(handler) 79 | } 80 | 81 | // 等待所有组件停止完成或超时 82 | done := make(chan struct{}) 83 | go func() { 84 | wg.Wait() 85 | close(done) 86 | }() 87 | 88 | select { 89 | case <-done: 90 | log.Println("所有组件都已经优雅退出...") 91 | case <-ctx.Done(): 92 | log.Println("程序优雅退出超时,强制退出...") 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /pkg/appx/resp.go: -------------------------------------------------------------------------------- 1 | package appx 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | 9 | "github.com/gin-gonic/gin" 10 | "github.com/go-playground/validator/v10" 11 | ) 12 | 13 | type Response struct { 14 | Ctx *gin.Context 15 | } 16 | 17 | func NewResponse(ctx *gin.Context) *Response { 18 | return &Response{ 19 | Ctx: ctx, 20 | } 21 | } 22 | 23 | func (r *Response) ToResponseWithHttpCode(code int, data any) { 24 | r.Ctx.JSON(code, data) 25 | } 26 | 27 | func (r *Response) ToResponse(data any) { 28 | r.Ctx.JSON(http.StatusOK, gin.H{"code": 200, "message": "", "data": data}) 29 | } 30 | func (r *Response) ToResponseData(data any) { 31 | r.Ctx.JSON(http.StatusOK, gin.H{"name": data}) 32 | } 33 | 34 | func (r *Response) ToResponseList(list any, totalRows int64) { 35 | r.Ctx.JSON(http.StatusOK, gin.H{ 36 | "code": 200, 37 | "message": "请求成功", 38 | "data": gin.H{ 39 | "items": list, 40 | "total": totalRows, 41 | }, 42 | }) 43 | } 44 | 45 | func (r *Response) ToErrorResponse(err error) { 46 | response := gin.H{"code": 500, "message": err.Error(), "data": nil} 47 | r.Ctx.JSON(http.StatusOK, response) 48 | } 49 | 50 | func (r *Response) To401Response(err error) { 51 | response := gin.H{"code": 401, "message": err.Error(), "data": nil} 52 | r.Ctx.JSON(http.StatusOK, response) 53 | } 54 | 55 | // 处理 Gin validator 错误 56 | func (r *Response) ToValidatorError(err error) { 57 | if ve, ok := err.(validator.ValidationErrors); ok { 58 | details := []string{} 59 | for _, fe := range ve { 60 | details = append(details, fmt.Sprintf("Field: %s Error: failed on the '%s' tag", fe.Field(), fe.Tag())) 61 | } 62 | err = errors.New(strings.Join(details, "; ")) 63 | } 64 | r.ToErrorResponse(err) 65 | } 66 | 67 | func (r *Response) ToInvalidResponse(err ValidErrors) { 68 | r.Ctx.JSON(http.StatusOK, gin.H{"code": 400, "message": err.Error(), "data": struct{}{}}) 69 | } 70 | func (r *Response) ToInvalidResponseMsg(msg string) { 71 | r.Ctx.JSON(http.StatusOK, gin.H{"code": 400, "message": msg, "data": struct{}{}}) 72 | } 73 | 74 | func (r *Response) ToInvalidResponseWithEmptyArr(err ValidErrors) { 75 | r.Ctx.JSON(http.StatusOK, gin.H{"code": 400, "message": err.Error(), "data": []string{}}) 76 | } 77 | -------------------------------------------------------------------------------- /model/system_settings.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "gorm.io/datatypes" 4 | 5 | type NotificationType string 6 | 7 | const ( 8 | NotificationTypePushPlus NotificationType = "push_plus" 9 | NotificationTypeEmail NotificationType = "email" 10 | ) 11 | 12 | type SystemSettings struct { 13 | ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:表主键ID" json:"id"` 14 | WebhookURL *string `gorm:"column:webhook_url;type:varchar(255);default:'';comment:Webhook地址" json:"webhook_url"` 15 | WebhookHeaders datatypes.JSONMap `gorm:"column:webhook_headers;type:json;comment:Webhook请求头(键值对)" json:"webhook_headers"` 16 | APITokenEnabled *bool `gorm:"column:api_token_enabled;default:false;comment:启用API Token" json:"api_token_enabled"` 17 | OfflineNotificationEnabled *bool `gorm:"column:offline_notification_enabled;default:false;comment:启用离线通知" json:"offline_notification_enabled"` 18 | NotificationType NotificationType `gorm:"column:notification_type;type:enum('push_plus','email');default:'push_plus';not null;comment:通知方式:push_plus-推送加,email-邮件" json:"notification_type"` 19 | PushPlusURL *string `gorm:"column:push_plus_url;type:varchar(255);default:'';comment:Push Plus的URL" json:"push_plus_url"` 20 | PushPlusToken *string `gorm:"column:push_plus_token;type:varchar(255);default:'';comment:Push Plus的Token" json:"push_plus_token"` 21 | AutoVerifyUser *bool `gorm:"column:auto_verify_user;default:false;comment:自动通过好友验证" json:"auto_verify_user"` 22 | VerifyUserDelay *int `gorm:"column:verify_user_delay;default:60;comment:自动通过好友验证延迟时间(秒)" json:"verify_user_delay"` 23 | AutoChatroomInvite *bool `gorm:"column:auto_chatroom_invite;default:false;comment:自动邀请进群" json:"auto_chatroom_invite"` 24 | CreatedAt int64 `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"` 25 | UpdatedAt int64 `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"` 26 | } 27 | 28 | func (SystemSettings) TableName() string { 29 | return "system_settings" 30 | } 31 | -------------------------------------------------------------------------------- /model/friend_settings.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "gorm.io/datatypes" 5 | ) 6 | 7 | type FriendSettings struct { 8 | ID uint64 `gorm:"column:id;primaryKey;autoIncrement;comment:公共配置表主键ID" json:"id"` 9 | WeChatID string `gorm:"column:wechat_id;type:varchar(64);default:'';comment:好友微信ID" json:"wechat_id"` 10 | ChatAIEnabled *bool `gorm:"column:chat_ai_enabled;default:false;comment:是否启用AI聊天功能" json:"chat_ai_enabled"` 11 | ChatBaseURL *string `gorm:"column:chat_base_url;type:varchar(255);default:'';comment:聊天AI的基础URL地址" json:"chat_base_url"` 12 | ChatAPIKey *string `gorm:"column:chat_api_key;type:varchar(255);default:'';comment:聊天AI的API密钥" json:"chat_api_key"` 13 | WorkflowModel *string `gorm:"column:workflow_model;type:varchar(100);default:'';comment:聊天AI使用的模型名称" json:"workflow_model"` 14 | ChatModel *string `gorm:"column:chat_model;type:varchar(100);default:'';comment:聊天AI使用的模型名称" json:"chat_model"` 15 | ImageRecognitionModel *string `gorm:"column:image_recognition_model;type:varchar(100);default:'';comment:图像识别AI使用的模型名称" json:"image_recognition_model"` 16 | ChatPrompt *string `gorm:"column:chat_prompt;type:text;comment:聊天AI系统提示词" json:"chat_prompt"` 17 | MaxCompletionTokens *int `gorm:"column:max_completion_tokens;default:0;comment:最大回复" json:"max_completion_tokens"` 18 | ImageAIEnabled *bool `gorm:"column:image_ai_enabled;default:false;comment:是否启用AI绘图功能" json:"image_ai_enabled"` 19 | ImageModel *ImageModel `gorm:"column:image_model;type:varchar(255);default:'';comment:绘图AI模型" json:"image_model"` 20 | ImageAISettings datatypes.JSON `gorm:"column:image_ai_settings;type:json;comment:绘图AI配置项" json:"image_ai_settings"` 21 | TTSEnabled *bool `gorm:"column:tts_enabled;default:false;comment:是否启用AI文本转语音功能" json:"tts_enabled"` 22 | TTSSettings datatypes.JSON `gorm:"column:tts_settings;type:json;comment:文本转语音配置项" json:"tts_settings"` 23 | LTTSSettings datatypes.JSON `gorm:"column:ltts_settings;type:json;comment:长文本转语音配置项" json:"ltts_settings"` 24 | } 25 | 26 | // TableName 设置表名 27 | func (FriendSettings) TableName() string { 28 | return "friend_settings" 29 | } 30 | -------------------------------------------------------------------------------- /model/robot_admin.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "gorm.io/datatypes" 5 | "gorm.io/gorm" 6 | ) 7 | 8 | // RobotStatus 表示机器人状态的枚举类型 9 | type RobotStatus string 10 | 11 | const ( 12 | RobotStatusOnline RobotStatus = "online" 13 | RobotStatusOffline RobotStatus = "offline" 14 | RobotStatusError RobotStatus = "error" 15 | ) 16 | 17 | // Robot 表示微信机器人实例 18 | type RobotAdmin struct { 19 | ID int64 `gorm:"primarykey" json:"id"` 20 | RobotCode string `gorm:"column:robot_code;index;unique,length:64" json:"robot_code"` // 当前机器人的唯一标识 21 | Owner string `gorm:"column:owner;index;length:64" json:"owner"` // 当前机器人的拥有者 22 | DeviceID string `gorm:"column:device_id;" json:"device_id"` // 当前机器人登陆的设备Id 23 | DeviceName string `gorm:"column:device_name" json:"device_name"` // 当前机器人登陆的设备名称 24 | WeChatID string `gorm:"column:wechat_id;index;length:64" json:"wechat_id"` // 当前机器人登陆的微信id 25 | Alias *string `gorm:"column:alias;length:64" json:"alias"` // 当前机器人登陆的自定义微信号 26 | BindMobile *string `gorm:"column:bind_mobile" json:"bind_mobile"` // 当前机器人登陆的手机号 27 | Nickname *string `gorm:"column:nickname" json:"nickname"` // 当前机器人登陆的微信昵称 28 | Avatar *string `gorm:"column:avatar" json:"avatar"` // 当前机器人登陆的微信头像 29 | Status RobotStatus `gorm:"column:status;default:'offline'" json:"status"` // 当前机器人登陆的状态 30 | RedisDB uint `gorm:"column:redis_db;default:1" json:"redis_db"` // 当前机器人登陆的Redis数据库 31 | ErrorMessage string `gorm:"column:error_message" json:"error_message"` 32 | Profile datatypes.JSON `gorm:"column:profile" json:"profile"` // 当前机器人登陆的微信个人资料 33 | ProfileExt datatypes.JSON `gorm:"column:profile_ext" json:"profile_ext"` // 当前机器人登陆的微信扩展资料 34 | LastLoginAt int64 `gorm:"column:last_login_at" json:"last_login_at"` 35 | CreatedAt int64 `json:"created_at"` 36 | UpdatedAt int64 `json:"updated_at"` 37 | DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` 38 | } 39 | 40 | // TableName 指定表名 41 | func (RobotAdmin) TableName() string { 42 | return "robot" 43 | } 44 | -------------------------------------------------------------------------------- /model/contact.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "gorm.io/gorm" 4 | 5 | // ContactType 表示联系人类型的枚举 6 | type ContactType string 7 | 8 | const ( 9 | ContactTypeFriend ContactType = "friend" 10 | ContactTypeChatRoom ContactType = "chat_room" 11 | ContactTypeOfficialAccount ContactType = "official_account" 12 | ) 13 | 14 | // Contact 表示微信联系人,包括好友和群组 15 | type Contact struct { 16 | ID int64 `gorm:"primarykey" json:"id"` 17 | WechatID string `gorm:"column:wechat_id;index:deleted,unique" json:"wechat_id"` // 微信号 18 | Alias string `gorm:"column:alias" json:"alias"` // 微信号别名 19 | Nickname *string `gorm:"column:nickname" json:"nickname"` 20 | Avatar string `gorm:"column:avatar" json:"avatar"` 21 | Type ContactType `gorm:"column:type" json:"type"` 22 | Remark string `gorm:"column:remark" json:"remark"` 23 | Pyinitial *string `gorm:"column:pyinitial" json:"pyinitial"` // 昵称拼音首字母大写 24 | QuanPin *string `gorm:"column:quan_pin" json:"quan_pin"` // 昵称拼音全拼小写 25 | Sex int `gorm:"column:sex" json:"sex"` // 性别 0:未知 1:男 2:女 26 | Country string `gorm:"column:country" json:"country"` // 国家 27 | Province string `gorm:"column:province" json:"province"` // 省份 28 | City string `gorm:"column:city" json:"city"` // 城市 29 | Signature string `gorm:"column:signature" json:"signature"` // 个性签名 30 | SnsBackground *string `gorm:"column:sns_background" json:"sns_background"` // 朋友圈背景图 31 | ChatRoomOwner string `gorm:"column:chat_room_owner" json:"chat_room_owner"` // 群主微信号 32 | CreatedAt int64 `gorm:"column:created_at" json:"created_at"` 33 | LastActiveAt int64 `gorm:"column:last_active_at;not null" json:"last_active_at"` // 最近活跃时间 34 | UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"` 35 | DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` 36 | } 37 | 38 | // TableName 指定表名 39 | func (Contact) TableName() string { 40 | return "contacts" 41 | } 42 | 43 | // IsChatRoom 判断联系人是否为群组 44 | func (c *Contact) IsChatRoom() bool { 45 | return c.Type == ContactTypeChatRoom 46 | } 47 | -------------------------------------------------------------------------------- /pkg/robot/xml/app_tail.xml: -------------------------------------------------------------------------------- 1 | 2 | {{ .Title }} 3 | 4 | 5 | view 6 | {{ .Type }} 7 | 0 8 | 9 | 10 | 11 | 0 12 | 13 | 14 | 0 15 | 16 | 17 | 0 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 0 30 | 31 | 32 | 33 | 34 | 35 | 0 36 | 37 | 38 | 39 | 40 | 0 41 | 42 | 43 | 44 | 45 | 46 | 0 47 | 48 | 49 | 50 | 0 51 | 52 | 53 | 54 | 0 55 | null 56 | 57 | 58 | 59 | 0 60 | null 61 | null 62 | 63 | 64 | 0 65 | null 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | GhQKEnd4NWZhNGViZjMyMGNmNjlmNQ== 78 | gh_3dfda90e39d6 79 | {{ .DisplayName }} 80 | 81 | -------------------------------------------------------------------------------- /plugin/plugins/pat.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "encoding/json" 7 | "fmt" 8 | "log" 9 | "wechat-robot-client/interface/plugin" 10 | "wechat-robot-client/model" 11 | "wechat-robot-client/plugin/pkg" 12 | ) 13 | 14 | type PatPlugin struct{} 15 | 16 | func NewPatPlugin() plugin.MessageHandler { 17 | return &PatPlugin{} 18 | } 19 | 20 | func (p *PatPlugin) GetName() string { 21 | return "Pat" 22 | } 23 | 24 | func (p *PatPlugin) GetLabels() []string { 25 | return []string{"pat"} 26 | } 27 | 28 | func (p *PatPlugin) PreAction(ctx *plugin.MessageContext) bool { 29 | return true 30 | } 31 | 32 | func (p *PatPlugin) PostAction(ctx *plugin.MessageContext) { 33 | 34 | } 35 | 36 | func (p *PatPlugin) Run(ctx *plugin.MessageContext) bool { 37 | if !ctx.Pat { 38 | return false 39 | } 40 | patConfig := ctx.Settings.GetPatConfig() 41 | if !patConfig.PatEnabled { 42 | return true 43 | } 44 | if patConfig.PatType == model.PatTypeText { 45 | ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, patConfig.PatText) 46 | return true 47 | } 48 | if patConfig.PatType == model.PatTypeVoice { 49 | isTTSEnabled := ctx.Settings.IsTTSEnabled() 50 | if !isTTSEnabled { 51 | ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, "文本转语音功能未开启,请联系管理员。") 52 | return true 53 | } 54 | aiConfig := ctx.Settings.GetAIConfig() 55 | var doubaoConfig pkg.DoubaoTTSConfig 56 | if err := json.Unmarshal(aiConfig.TTSSettings, &doubaoConfig); err != nil { 57 | log.Printf("反序列化豆包文本转语音配置失败: %v", err) 58 | return true 59 | } 60 | doubaoConfig.Audio.VoiceType = patConfig.PatVoiceTimbre 61 | doubaoConfig.Request.Text = patConfig.PatText 62 | 63 | audioBase64, err := pkg.DoubaoTTSSubmit(&doubaoConfig) 64 | if err != nil { 65 | ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, fmt.Sprintf("豆包文本转语音请求失败: %v", err), ctx.Message.SenderWxID) 66 | return true 67 | } 68 | audioData, err := base64.StdEncoding.DecodeString(audioBase64) 69 | if err != nil { 70 | ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, fmt.Sprintf("音频数据解码失败: %v", err), ctx.Message.SenderWxID) 71 | return true 72 | } 73 | audioReader := bytes.NewReader(audioData) 74 | ctx.MessageService.MsgSendVoice(ctx.Message.FromWxID, audioReader, fmt.Sprintf(".%s", doubaoConfig.Audio.Encoding)) 75 | 76 | return true 77 | } 78 | return true 79 | } 80 | -------------------------------------------------------------------------------- /service/attach_download.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "io" 7 | "wechat-robot-client/dto" 8 | "wechat-robot-client/model" 9 | "wechat-robot-client/repository" 10 | "wechat-robot-client/vars" 11 | ) 12 | 13 | type AttachDownloadService struct { 14 | ctx context.Context 15 | msgRepo *repository.Message 16 | } 17 | 18 | func NewAttachDownloadService(ctx context.Context) *AttachDownloadService { 19 | return &AttachDownloadService{ 20 | ctx: ctx, 21 | msgRepo: repository.NewMessageRepo(ctx, vars.DB), 22 | } 23 | } 24 | 25 | func (a *AttachDownloadService) DownloadImage(messageID int64) ([]byte, string, string, error) { 26 | message, err := a.msgRepo.GetByID(messageID) 27 | if err != nil { 28 | return nil, "", "", err 29 | } 30 | if message == nil { 31 | return nil, "", "", errors.New("消息不存在") 32 | } 33 | if message.Type != model.MsgTypeImage { 34 | return nil, "", "", errors.New("消息类型错误") 35 | } 36 | return vars.RobotRuntime.DownloadImage(*message) 37 | } 38 | 39 | func (a *AttachDownloadService) DownloadVoice(req dto.AttachDownloadRequest) ([]byte, string, string, error) { 40 | message, err := a.msgRepo.GetByID(req.MessageID) 41 | if err != nil { 42 | return nil, "", "", err 43 | } 44 | if message == nil { 45 | return nil, "", "", errors.New("消息不存在") 46 | } 47 | if message.Type != model.MsgTypeVoice { 48 | return nil, "", "", errors.New("消息类型错误") 49 | } 50 | return vars.RobotRuntime.DownloadVoice(a.ctx, *message) 51 | } 52 | 53 | func (a *AttachDownloadService) DownloadFile(messageID int64) (io.ReadCloser, string, error) { 54 | message, err := a.msgRepo.GetByID(messageID) 55 | if err != nil { 56 | return nil, "", err 57 | } 58 | if message == nil { 59 | return nil, "", errors.New("消息不存在") 60 | } 61 | if message.Type != model.MsgTypeApp || message.AppMsgType != model.AppMsgTypeAttach { 62 | return nil, "", errors.New("消息类型错误") 63 | } 64 | return vars.RobotRuntime.DownloadFile(a.ctx, *message) 65 | } 66 | 67 | func (a *AttachDownloadService) DownloadVideo(req dto.AttachDownloadRequest) (io.ReadCloser, string, error) { 68 | message, err := a.msgRepo.GetByID(req.MessageID) 69 | if err != nil { 70 | return nil, "", err 71 | } 72 | if message == nil { 73 | return nil, "", errors.New("消息不存在") 74 | } 75 | if message.Type != model.MsgTypeVideo { 76 | return nil, "", errors.New("消息类型错误") 77 | } 78 | return vars.RobotRuntime.DownloadVideo(a.ctx, *message) 79 | } 80 | -------------------------------------------------------------------------------- /plugin/plugins/ai_image_upload.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "wechat-robot-client/interface/plugin" 7 | "wechat-robot-client/service" 8 | ) 9 | 10 | type AIImageUploadPlugin struct{} 11 | 12 | func NewAIImageUploadPlugin() plugin.MessageHandler { 13 | return &AIImageUploadPlugin{} 14 | } 15 | 16 | func (p *AIImageUploadPlugin) GetName() string { 17 | return "AIImageUpload" 18 | } 19 | 20 | func (p *AIImageUploadPlugin) GetLabels() []string { 21 | return []string{"text", "internal", "chat"} 22 | } 23 | 24 | func (p *AIImageUploadPlugin) PreAction(ctx *plugin.MessageContext) bool { 25 | return true 26 | } 27 | 28 | func (p *AIImageUploadPlugin) PostAction(ctx *plugin.MessageContext) { 29 | 30 | } 31 | 32 | func (p *AIImageUploadPlugin) GetOSSFileURL(ctx *plugin.MessageContext) (string, error) { 33 | ossSettingService := service.NewOSSSettingService(ctx.Context) 34 | ossSettings, err := ossSettingService.GetOSSSettingService() 35 | if err != nil { 36 | return "", fmt.Errorf("获取OSS设置失败: %w", err) 37 | } 38 | if ossSettings.AutoUploadImage != nil && *ossSettings.AutoUploadImage { 39 | err := ossSettingService.UploadImageToOSS(ossSettings, ctx.ReferMessage) 40 | if err != nil { 41 | return "", fmt.Errorf("上传图片到OSS失败: %w", err) 42 | } 43 | return ctx.ReferMessage.AttachmentUrl, nil 44 | } 45 | return "", nil 46 | } 47 | 48 | func (p *AIImageUploadPlugin) SendMessage(ctx *plugin.MessageContext, aiReplyText string) { 49 | var err error 50 | if ctx.Message.IsChatRoom { 51 | err = ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, aiReplyText, ctx.Message.SenderWxID) 52 | } else { 53 | err = ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, aiReplyText) 54 | } 55 | if err != nil { 56 | log.Printf("发送AI回复消息失败: %v", err) 57 | } 58 | } 59 | 60 | func (p *AIImageUploadPlugin) Run(ctx *plugin.MessageContext) bool { 61 | if ctx.ReferMessage == nil { 62 | ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, "你需要引用一条图片消息。") 63 | return true 64 | } 65 | if ctx.ReferMessage.AttachmentUrl == "" { 66 | imageURL, err := p.GetOSSFileURL(ctx) 67 | if err != nil { 68 | log.Printf("图片上传失败: %v", err) 69 | p.SendMessage(ctx, fmt.Sprintf("图片上传失败: %v,你可能没开启自动上传图片,请前往机器人详情 -> OSS 设置手动开启", err)) 70 | return true 71 | } 72 | if imageURL == "" { 73 | p.SendMessage(ctx, "图片上传失败: 图片URL为空,你可能没开启自动上传图片,请前往机器人详情 -> OSS 设置手动开启") 74 | return true 75 | } 76 | } 77 | 78 | return true 79 | } 80 | -------------------------------------------------------------------------------- /vars/vars.go: -------------------------------------------------------------------------------- 1 | package vars 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/redis/go-redis/v9" 7 | "gorm.io/gorm" 8 | 9 | "wechat-robot-client/interface/ai" 10 | "wechat-robot-client/pkg/robot" 11 | "wechat-robot-client/plugin" 12 | ) 13 | 14 | // 微信机器人客户端监听端口 15 | var WechatClientPort string 16 | 17 | // 微信机器人服务端地址,仅供本地调试用 18 | var WechatServerHost string 19 | 20 | // 机器人实例数据库 21 | var DB *gorm.DB 22 | 23 | // 机器人管理后台数据库 24 | var AdminDB *gorm.DB 25 | 26 | // redis实例 27 | var RedisClient *redis.Client 28 | 29 | var MessagePlugin *plugin.MessagePlugin 30 | 31 | // 机器人启动超时 32 | var RobotStartTimeout time.Duration 33 | 34 | // 机器人运行时实例 35 | var RobotRuntime = &robot.Robot{} 36 | 37 | var MCPService ai.MCPService 38 | 39 | var Webhook struct { 40 | URL string 41 | Headers map[string]any 42 | } 43 | 44 | // 任务调度器实例 45 | var CronManager CronManagerInterface 46 | 47 | // 歌曲搜索Api 48 | // var MusicSearchApi = "https://www.hhlqilongzhu.cn/api/joox/juhe_music.php" 49 | var MusicSearchApi = "https://api.cenguigui.cn/api/music/netease/WyY_Dg.php" 50 | 51 | var ThirdPartyApiKey string 52 | 53 | var WordCloudUrl string 54 | 55 | // Pprof 代理目标地址 56 | var PprofProxyURL string 57 | 58 | var UploadImageChunkSize int64 = 50000 59 | var UploadFileChunkSize int64 = 50000 60 | 61 | var AtAllRegexp = `@所有人(?: | )` 62 | 63 | var TrimAtRegexp = `@[^ | ]+?(?: | )` 64 | 65 | var OfficialAccount = map[string]bool{ 66 | "filehelper": true, 67 | "newsapp": true, 68 | "fmessage": true, 69 | "weibo": true, 70 | "qqmail": true, 71 | "tmessage": true, 72 | "qmessage": true, 73 | "qqsync": true, 74 | "floatbottle": true, 75 | "lbsapp": true, 76 | "shakeapp": true, 77 | "medianote": true, 78 | "qqfriend": true, 79 | "readerapp": true, 80 | "blogapp": true, 81 | "facebookapp": true, 82 | "masssendapp": true, 83 | "meishiapp": true, 84 | "feedsapp": true, 85 | "voip": true, 86 | "blogappweixin": true, 87 | "weixin": true, 88 | "brandsessionholder": true, 89 | "weixinreminder": true, 90 | "officialaccounts": true, 91 | "notification_messages": true, 92 | "wxitil": true, 93 | "userexperience_alarm": true, 94 | "exmail_tool": true, 95 | "mphelper": true, 96 | } 97 | -------------------------------------------------------------------------------- /startup/vars.go: -------------------------------------------------------------------------------- 1 | package startup 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | "strconv" 9 | "wechat-robot-client/vars" 10 | 11 | "github.com/redis/go-redis/v9" 12 | "gorm.io/driver/mysql" 13 | "gorm.io/gorm" 14 | "gorm.io/gorm/logger" 15 | ) 16 | 17 | func SetupVars() error { 18 | if err := InitMySQLClient(); err != nil { 19 | return fmt.Errorf("MySQL连接失败: %v", err) 20 | } 21 | log.Println("MySQL连接成功") 22 | if err := InitRedisClient(); err != nil { 23 | return fmt.Errorf("redis连接失败: %v", err) 24 | } 25 | log.Println("Redis连接成功") 26 | return nil 27 | } 28 | 29 | func InitMySQLClient() (err error) { 30 | // 创建机器人实例连接对象 31 | dsn := fmt.Sprintf("%s:%s@tcp(%s:%v)/%s?charset=utf8mb4&parseTime=True&loc=Local", 32 | vars.MysqlSettings.User, vars.MysqlSettings.Password, vars.MysqlSettings.Host, vars.MysqlSettings.Port, vars.MysqlSettings.Db) 33 | mysqlConfig := mysql.Config{ 34 | DSN: dsn, 35 | DontSupportRenameIndex: true, // 重命名索引时采用删除并新建的方式 36 | DontSupportRenameColumn: true, // 用 `change` 重命名列 37 | } 38 | // gorm 配置 39 | gormConfig := gorm.Config{} 40 | // 是否开启调试模式 41 | if flag, _ := strconv.ParseBool(os.Getenv("GORM_DEBUG")); flag { 42 | gormConfig.Logger = logger.Default.LogMode(logger.Info) 43 | } 44 | vars.DB, err = gorm.Open(mysql.New(mysqlConfig), &gormConfig) 45 | if err != nil { 46 | return err 47 | } 48 | 49 | // 创建机器人管理后台连接对象 50 | adminDsn := fmt.Sprintf("%s:%s@tcp(%s:%v)/%s?charset=utf8mb4&parseTime=True&loc=Local", 51 | vars.MysqlSettings.User, vars.MysqlSettings.Password, vars.MysqlSettings.Host, vars.MysqlSettings.Port, vars.MysqlSettings.AdminDb) 52 | adminYysqlConfig := mysql.Config{ 53 | DSN: adminDsn, 54 | DontSupportRenameIndex: true, // 重命名索引时采用删除并新建的方式 55 | DontSupportRenameColumn: true, // 用 `change` 重命名列 56 | } 57 | // gorm 配置 58 | adminGormConfig := gorm.Config{} 59 | // 是否开启调试模式 60 | if flag, _ := strconv.ParseBool(os.Getenv("GORM_DEBUG")); flag { 61 | adminGormConfig.Logger = logger.Default.LogMode(logger.Info) 62 | } 63 | vars.AdminDB, err = gorm.Open(mysql.New(adminYysqlConfig), &adminGormConfig) 64 | return err 65 | } 66 | 67 | func InitRedisClient() (err error) { 68 | vars.RedisClient = redis.NewClient(&redis.Options{ 69 | Addr: fmt.Sprintf("%s:%s", vars.RedisSettings.Host, vars.RedisSettings.Port), 70 | Password: vars.RedisSettings.Password, 71 | DB: vars.RedisSettings.Db, 72 | }) 73 | _, err = vars.RedisClient.Ping(context.Background()).Result() 74 | return err 75 | } 76 | -------------------------------------------------------------------------------- /controller/chat_room_settings.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "errors" 5 | "wechat-robot-client/dto" 6 | "wechat-robot-client/model" 7 | "wechat-robot-client/pkg/appx" 8 | "wechat-robot-client/service" 9 | 10 | "github.com/gin-gonic/gin" 11 | ) 12 | 13 | type ChatRoomSettings struct { 14 | } 15 | 16 | func NewChatRoomSettingsController() *ChatRoomSettings { 17 | return &ChatRoomSettings{} 18 | } 19 | 20 | func (ct *ChatRoomSettings) GetChatRoomSettings(c *gin.Context) { 21 | var req dto.ChatRoomSettingsRequest 22 | resp := appx.NewResponse(c) 23 | if ok, err := appx.BindAndValid(c, &req); !ok || err != nil { 24 | resp.ToErrorResponse(errors.New("参数错误")) 25 | return 26 | } 27 | chatRoomSettings, err := service.NewChatRoomSettingsService(c).GetChatRoomSettings(req.ChatRoomID) 28 | if err != nil { 29 | resp.ToErrorResponse(err) 30 | return 31 | } 32 | if chatRoomSettings == nil { 33 | resp.ToResponse(model.ChatRoomSettings{}) 34 | return 35 | } 36 | if chatRoomSettings.NewsType != nil && *chatRoomSettings.NewsType == model.NewsTypeNone { 37 | chatRoomSettings.NewsType = nil 38 | } 39 | resp.ToResponse(chatRoomSettings) 40 | } 41 | 42 | func (ct *ChatRoomSettings) SaveChatRoomSettings(c *gin.Context) { 43 | var req model.ChatRoomSettings 44 | resp := appx.NewResponse(c) 45 | if ok, err := appx.BindAndValid(c, &req); !ok || err != nil { 46 | resp.ToErrorResponse(errors.New("参数错误")) 47 | return 48 | } 49 | if req.WelcomeEnabled != nil && *req.WelcomeEnabled { 50 | if req.WelcomeType == "" { 51 | resp.ToErrorResponse(errors.New("参数错误")) 52 | return 53 | } 54 | if req.WelcomeType == model.WelcomeTypeText { 55 | if req.WelcomeText == "" { 56 | resp.ToErrorResponse(errors.New("参数错误")) 57 | return 58 | } 59 | } 60 | if req.WelcomeType == model.WelcomeTypeEmoji { 61 | if req.WelcomeEmojiMD5 == "" || req.WelcomeEmojiLen == 0 { 62 | resp.ToErrorResponse(errors.New("参数错误")) 63 | return 64 | } 65 | } 66 | if req.WelcomeType == model.WelcomeTypeImage { 67 | if req.WelcomeImageURL == "" { 68 | resp.ToErrorResponse(errors.New("参数错误")) 69 | return 70 | } 71 | } 72 | if req.WelcomeType == model.WelcomeTypeURL { 73 | if req.WelcomeText == "" || req.WelcomeURL == "" { 74 | resp.ToErrorResponse(errors.New("参数错误")) 75 | return 76 | } 77 | } 78 | } 79 | err := service.NewChatRoomSettingsService(c).SaveChatRoomSettings(&req) 80 | if err != nil { 81 | resp.ToErrorResponse(err) 82 | return 83 | } 84 | resp.ToResponse(nil) 85 | } 86 | -------------------------------------------------------------------------------- /service/ai_drawing.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "wechat-robot-client/interface/ai" 7 | "wechat-robot-client/interface/settings" 8 | "wechat-robot-client/model" 9 | "wechat-robot-client/vars" 10 | ) 11 | 12 | type AIDrawingService struct { 13 | ctx context.Context 14 | config settings.Settings 15 | } 16 | 17 | var _ ai.AIService = (*AIDrawingService)(nil) 18 | 19 | func NewAIDrawingService(ctx context.Context, config settings.Settings) *AIDrawingService { 20 | return &AIDrawingService{ 21 | ctx: ctx, 22 | config: config, 23 | } 24 | } 25 | 26 | func (s *AIDrawingService) SetAISession(message *model.Message) error { 27 | return vars.RedisClient.Set(s.ctx, s.GetSessionID(message), true, defaultTTL).Err() 28 | } 29 | 30 | func (s *AIDrawingService) RenewAISession(message *model.Message) error { 31 | return vars.RedisClient.Expire(s.ctx, s.GetSessionID(message), defaultTTL).Err() 32 | } 33 | 34 | func (s *AIDrawingService) ExpireAISession(message *model.Message) error { 35 | return vars.RedisClient.Del(s.ctx, s.GetSessionID(message)).Err() 36 | } 37 | 38 | func (s *AIDrawingService) ExpireAllAISessionByChatRoomID(chatRoomID string) error { 39 | sessionID := fmt.Sprintf("ai_drawing_session_%s:", chatRoomID) 40 | keys, err := vars.RedisClient.Keys(s.ctx, sessionID+"*").Result() 41 | if err != nil { 42 | return err 43 | } 44 | if len(keys) == 0 { 45 | return nil 46 | } 47 | return vars.RedisClient.Del(s.ctx, keys...).Err() 48 | } 49 | 50 | func (s *AIDrawingService) IsInAISession(message *model.Message) (bool, error) { 51 | cnt, err := vars.RedisClient.Exists(s.ctx, s.GetSessionID(message)).Result() 52 | return cnt == 1, err 53 | } 54 | 55 | func (s *AIDrawingService) GetSessionID(message *model.Message) string { 56 | return fmt.Sprintf("ai_drawing_session_%s:%s", message.FromWxID, message.SenderWxID) 57 | } 58 | 59 | func (s *AIDrawingService) IsAISessionStart(message *model.Message) bool { 60 | if message.Content == "#进入AI绘图" { 61 | err := s.SetAISession(message) 62 | return err == nil 63 | } 64 | return false 65 | } 66 | 67 | func (s *AIDrawingService) GetAISessionStartTips() string { 68 | return "AI绘图已开始,请输入您的绘图提示词。10分钟不说话会话将自动结束,您也可以输入 #退出AI绘图 来结束会话。" 69 | } 70 | 71 | func (s *AIDrawingService) IsAISessionEnd(message *model.Message) bool { 72 | if message.Content == "#退出AI绘图" { 73 | err := s.ExpireAISession(message) 74 | return err == nil 75 | } 76 | return false 77 | } 78 | 79 | func (s *AIDrawingService) GetAISessionEndTips() string { 80 | return "AI绘图已结束,您可以输入 #进入AI绘图 来重新开始。" 81 | } 82 | -------------------------------------------------------------------------------- /repository/chat_room_settings.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "wechat-robot-client/model" 6 | 7 | "gorm.io/gorm" 8 | ) 9 | 10 | type ChatRoomSettings struct { 11 | Ctx context.Context 12 | DB *gorm.DB 13 | } 14 | 15 | func NewChatRoomSettingsRepo(ctx context.Context, db *gorm.DB) *ChatRoomSettings { 16 | return &ChatRoomSettings{ 17 | Ctx: ctx, 18 | DB: db, 19 | } 20 | } 21 | 22 | func (respo *ChatRoomSettings) GetChatRoomSettings(chatRoomID string) (*model.ChatRoomSettings, error) { 23 | var chatRoomSettings model.ChatRoomSettings 24 | err := respo.DB.WithContext(respo.Ctx).Where("chat_room_id = ?", chatRoomID).First(&chatRoomSettings).Error 25 | if err == gorm.ErrRecordNotFound { 26 | return nil, nil 27 | } 28 | if err != nil { 29 | return nil, err 30 | } 31 | return &chatRoomSettings, nil 32 | } 33 | 34 | func (respo *ChatRoomSettings) GetAllEnableGoodMorning() ([]*model.ChatRoomSettings, error) { 35 | var chatRoomSettings []*model.ChatRoomSettings 36 | err := respo.DB.WithContext(respo.Ctx).Where("morning_enabled = ?", 1).Find(&chatRoomSettings).Error 37 | if err != nil { 38 | return nil, err 39 | } 40 | return chatRoomSettings, nil 41 | } 42 | 43 | func (respo *ChatRoomSettings) GetAllEnableNews() ([]*model.ChatRoomSettings, error) { 44 | var chatRoomSettings []*model.ChatRoomSettings 45 | err := respo.DB.WithContext(respo.Ctx).Where("news_enabled = ?", 1).Find(&chatRoomSettings).Error 46 | if err != nil { 47 | return nil, err 48 | } 49 | return chatRoomSettings, nil 50 | } 51 | 52 | func (respo *ChatRoomSettings) GetAllEnableChatRank() ([]*model.ChatRoomSettings, error) { 53 | var chatRoomSettings []*model.ChatRoomSettings 54 | err := respo.DB.WithContext(respo.Ctx).Where("chat_room_ranking_enabled = ?", 1).Find(&chatRoomSettings).Error 55 | if err != nil { 56 | return nil, err 57 | } 58 | return chatRoomSettings, nil 59 | } 60 | 61 | func (respo *ChatRoomSettings) GetAllEnableAISummary() ([]*model.ChatRoomSettings, error) { 62 | var chatRoomSettings []*model.ChatRoomSettings 63 | err := respo.DB.WithContext(respo.Ctx).Where("chat_room_summary_enabled = ?", 1).Find(&chatRoomSettings).Error 64 | if err != nil { 65 | return nil, err 66 | } 67 | return chatRoomSettings, nil 68 | } 69 | 70 | func (respo *ChatRoomSettings) Create(data *model.ChatRoomSettings) error { 71 | return respo.DB.WithContext(respo.Ctx).Create(data).Error 72 | } 73 | 74 | func (respo *ChatRoomSettings) Update(data *model.ChatRoomSettings) error { 75 | return respo.DB.WithContext(respo.Ctx).Where("id = ?", data.ID).Updates(data).Error 76 | } 77 | -------------------------------------------------------------------------------- /service/ai_callback.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "time" 9 | "wechat-robot-client/dto" 10 | "wechat-robot-client/model" 11 | "wechat-robot-client/repository" 12 | "wechat-robot-client/vars" 13 | ) 14 | 15 | type AICallbackService struct { 16 | ctx context.Context 17 | aiTaskRepo *repository.AITask 18 | msgRepo *repository.Message 19 | } 20 | 21 | func NewAICallbackService(ctx context.Context) *AICallbackService { 22 | return &AICallbackService{ 23 | ctx: ctx, 24 | aiTaskRepo: repository.NewAITaskRepo(ctx, vars.DB), 25 | msgRepo: repository.NewMessageRepo(ctx, vars.DB), 26 | } 27 | } 28 | 29 | func (s *AICallbackService) SendTextMessage(msgService *MessageService, message *model.Message, msg string) { 30 | if message.IsChatRoom { 31 | err := msgService.SendTextMessage(message.FromWxID, msg, message.SenderWxID) 32 | if err != nil { 33 | log.Println("发送消息失败: ", message.FromWxID, msg, err) 34 | return 35 | } 36 | } else { 37 | err := msgService.SendTextMessage(message.FromWxID, msg) 38 | if err != nil { 39 | log.Println("发送消息失败: ", message.FromWxID, msg, err) 40 | return 41 | } 42 | } 43 | } 44 | 45 | func (s *AICallbackService) DoubaoTTS(req dto.DoubaoTTSCallbackRequest) error { 46 | aiTask, err := s.aiTaskRepo.GetByAIProviderTaskID(req.TaskID) 47 | if err != nil { 48 | log.Println("获取任务失败: ", req.TaskID, err) 49 | return err 50 | } 51 | if aiTask == nil { 52 | log.Println("任务不存在: ", req.TaskID) 53 | return errors.New("任务不存在") 54 | } 55 | 56 | message, err := s.msgRepo.GetByID(aiTask.MessageID) 57 | if err != nil { 58 | log.Println("获取消息失败: ", aiTask.MessageID, err) 59 | return err 60 | } 61 | if message == nil { 62 | log.Println("消息不存在: ", aiTask.MessageID) 63 | return errors.New("消息不存在") 64 | } 65 | 66 | msgService := NewMessageService(s.ctx) 67 | aiTask.UpdatedAt = time.Now().Unix() 68 | if req.Code == 0 { 69 | switch req.TaskStatus { 70 | case 1: 71 | aiTask.AITaskStatus = model.AITaskStatusCompleted 72 | s.SendTextMessage(msgService, message, fmt.Sprintf("豆包长文本转语音任务完成,有效期24小时,请及时下载,音频地址: %s", req.AudioURL)) 73 | case 2: 74 | aiTask.AITaskStatus = model.AITaskStatusFailed 75 | s.SendTextMessage(msgService, message, req.Message) 76 | default: 77 | aiTask.AITaskStatus = model.AITaskStatusProcessing 78 | s.SendTextMessage(msgService, message, "任务处理中,请耐心等待...") 79 | } 80 | return s.aiTaskRepo.Update(aiTask) 81 | } 82 | 83 | aiTask.AITaskStatus = model.AITaskStatusFailed 84 | s.SendTextMessage(msgService, message, req.Message) 85 | 86 | return s.aiTaskRepo.Update(aiTask) 87 | } 88 | -------------------------------------------------------------------------------- /startup/config.go: -------------------------------------------------------------------------------- 1 | package startup 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "strconv" 7 | "strings" 8 | "time" 9 | "wechat-robot-client/vars" 10 | 11 | "github.com/joho/godotenv" 12 | ) 13 | 14 | func LoadConfig() error { 15 | loadEnvConfig() 16 | return nil 17 | } 18 | 19 | func loadEnvConfig() { 20 | // 本地开发模式 21 | isDevMode := strings.ToLower(os.Getenv("GO_ENV")) == "dev" 22 | if isDevMode { 23 | err := godotenv.Load() 24 | if err != nil { 25 | log.Fatal("加载本地环境变量失败,请检查是否存在 .env 文件") 26 | } 27 | } 28 | 29 | // 监听端口 30 | vars.WechatClientPort = os.Getenv("WECHAT_CLIENT_PORT") 31 | if vars.WechatClientPort == "" { 32 | log.Fatal("WECHAT_CLIENT_PORT 环境变量未设置") 33 | } 34 | vars.WechatServerHost = os.Getenv("WECHAT_SERVER_HOST") 35 | 36 | // mysql 37 | vars.MysqlSettings.Driver = os.Getenv("MYSQL_DRIVER") 38 | vars.MysqlSettings.Host = os.Getenv("MYSQL_HOST") 39 | vars.MysqlSettings.Port = os.Getenv("MYSQL_PORT") 40 | vars.MysqlSettings.User = os.Getenv("MYSQL_USER") 41 | vars.MysqlSettings.Password = os.Getenv("MYSQL_PASSWORD") 42 | // 机器人ID就是数据库名 43 | vars.MysqlSettings.Db = os.Getenv("ROBOT_CODE") 44 | vars.MysqlSettings.AdminDb = os.Getenv("MYSQL_ADMIN_DB") 45 | vars.MysqlSettings.Schema = os.Getenv("MYSQL_SCHEMA") 46 | 47 | // redis 48 | vars.RedisSettings.Host = os.Getenv("REDIS_HOST") 49 | vars.RedisSettings.Port = os.Getenv("REDIS_PORT") 50 | vars.RedisSettings.Password = os.Getenv("REDIS_PASSWORD") 51 | redisDb := os.Getenv("REDIS_DB") 52 | if redisDb == "" { 53 | log.Fatalf("REDIS_DB 环境变量未设置") 54 | } else { 55 | db, err := strconv.Atoi(redisDb) 56 | if err != nil { 57 | log.Fatalf("REDIS_DB 转换失败: %v", err) 58 | } 59 | vars.RedisSettings.Db = db 60 | } 61 | 62 | // rabbitmq 63 | vars.RabbitmqSettings.Host = os.Getenv("RABBITMQ_HOST") 64 | vars.RabbitmqSettings.Port = os.Getenv("RABBITMQ_PORT") 65 | vars.RabbitmqSettings.User = os.Getenv("RABBITMQ_USER") 66 | vars.RabbitmqSettings.Password = os.Getenv("RABBITMQ_PASSWORD") 67 | vars.RabbitmqSettings.Vhost = os.Getenv("RABBITMQ_VHOST") 68 | 69 | // robot 70 | robotStartTimeout := os.Getenv("ROBOT_START_TIMEOUT") 71 | if robotStartTimeout == "" { 72 | vars.RobotStartTimeout = 60 * time.Second 73 | } else { 74 | // 将字符串转换成int 75 | t, err := strconv.Atoi(robotStartTimeout) 76 | if err != nil { 77 | log.Fatalf("ROBOT_START_TIMEOUT 转换失败: %v", err) 78 | } 79 | vars.RobotStartTimeout = time.Duration(t) * time.Second 80 | } 81 | 82 | vars.ThirdPartyApiKey = os.Getenv("THIRD_PARTY_API_KEY") 83 | // 词云 84 | vars.WordCloudUrl = os.Getenv("WORD_CLOUD_URL") 85 | // pprof 代理地址 86 | vars.PprofProxyURL = os.Getenv("PPROF_PROXY_URL") 87 | } 88 | -------------------------------------------------------------------------------- /dto/message.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type MessageCommonRequest struct { 4 | MessageID int64 `form:"message_id" json:"message_id" binding:"required"` 5 | } 6 | 7 | type SendMessageCommonRequest struct { 8 | ToWxid string `form:"to_wxid" json:"to_wxid" binding:"required"` 9 | } 10 | 11 | type SendTextMessageRequest struct { 12 | SendMessageCommonRequest 13 | Content string `form:"content" json:"content" binding:"required"` 14 | At []string `form:"at" json:"at"` 15 | } 16 | 17 | type SendLongTextMessageRequest struct { 18 | SendMessageCommonRequest 19 | Content string `form:"content" json:"content" binding:"required"` 20 | } 21 | 22 | type SendAppMessageRequest struct { 23 | SendMessageCommonRequest 24 | Type int `form:"type" json:"type" binding:"required"` 25 | XML string `form:"xml" json:"xml" binding:"required"` 26 | } 27 | 28 | type SendMusicMessageRequest struct { 29 | SendMessageCommonRequest 30 | Song string `form:"song" json:"song" binding:"required"` 31 | } 32 | 33 | type TextMessageItem struct { 34 | Nickname string `json:"nickname"` 35 | Message string `json:"message"` 36 | CreatedAt int64 `json:"created_at"` 37 | } 38 | 39 | type SendImageMessageRequest struct { 40 | ToWxid string `form:"to_wxid" json:"to_wxid" binding:"required"` 41 | ClientImgId string `form:"client_img_id" json:"client_img_id" binding:"required"` 42 | FileSize int64 `form:"file_size" json:"file_size" binding:"required"` 43 | ChunkIndex int64 `form:"chunk_index" json:"chunk_index"` 44 | TotalChunks int64 `form:"total_chunks" json:"total_chunks" binding:"required"` 45 | ImageURL string `form:"image_url" json:"image_url"` // 冗余字段 46 | } 47 | 48 | type SendImageMessageByRemoteURLRequest struct { 49 | ToWxid string `form:"to_wxid" json:"to_wxid" binding:"required"` 50 | ImageURLs []string `form:"image_urls" json:"image_urls" binding:"required"` 51 | } 52 | 53 | type SendVideoMessageByRemoteURLRequest struct { 54 | ToWxid string `form:"to_wxid" json:"to_wxid" binding:"required"` 55 | VideoURLs []string `form:"video_urls" json:"video_urls" binding:"required"` 56 | } 57 | 58 | type SendFileMessageRequest struct { 59 | ToWxid string `form:"to_wxid" json:"to_wxid" binding:"required"` 60 | ClientAppDataId string `form:"client_app_data_id" json:"client_app_data_id" binding:"required"` 61 | Filename string `form:"filename" json:"filename" binding:"required"` 62 | FileHash string `form:"file_hash" json:"file_hash" binding:"required"` 63 | FileSize int64 `form:"file_size" json:"file_size" binding:"required"` 64 | ChunkIndex int64 `form:"chunk_index" json:"chunk_index"` 65 | TotalChunks int64 `form:"total_chunks" json:"total_chunks" binding:"required"` 66 | } 67 | -------------------------------------------------------------------------------- /controller/pprof_proxy.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httputil" 6 | "net/url" 7 | "strings" 8 | 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | /* 13 | * 14 | #### 1. 查看pprof首页 15 | ```bash 16 | curl http://your-domain/api/v1/pprof/debug/pprof/ 17 | ``` 18 | 或在浏览器中访问: 19 | ``` 20 | http://your-domain/api/v1/pprof/debug/pprof/ 21 | ``` 22 | 23 | #### 2. 查看堆内存信息 24 | ```bash 25 | curl http://your-domain/api/v1/pprof/debug/pprof/heap 26 | ``` 27 | 28 | #### 3. 查看goroutine信息 29 | ```bash 30 | curl http://your-domain/api/v1/pprof/debug/pprof/goroutine?debug=1 31 | ``` 32 | 33 | #### 4. 采集CPU Profile(30秒) 34 | ```bash 35 | curl http://your-domain/api/v1/pprof/debug/pprof/profile?seconds=30 -o cpu.prof 36 | ``` 37 | 38 | #### 5. 查看内存分配信息 39 | ```bash 40 | curl http://your-domain/api/v1/pprof/debug/pprof/allocs 41 | ``` 42 | 43 | #### 6. 查看阻塞信息 44 | ```bash 45 | curl http://your-domain/api/v1/pprof/debug/pprof/block 46 | ``` 47 | 48 | #### 7. 查看互斥锁信息 49 | ```bash 50 | curl http://your-domain/api/v1/pprof/debug/pprof/mutex 51 | ``` 52 | 53 | ### 使用go tool pprof分析 54 | 55 | 下载profile文件后,可以使用go工具进行分析: 56 | 57 | ```bash 58 | # 下载CPU profile 59 | curl http://your-domain/api/v1/pprof/debug/pprof/profile?seconds=30 -o cpu.prof 60 | 61 | # 使用go tool分析 62 | go tool pprof cpu.prof 63 | 64 | # 或者直接在线分析 65 | go tool pprof http://your-domain/api/v1/pprof/debug/pprof/heap 66 | ``` 67 | 68 | ### 生成可视化图表 69 | 70 | 如果安装了graphviz,可以生成可视化图表: 71 | 72 | ```bash 73 | # 生成CPU profile的PDF图表 74 | go tool pprof -pdf http://your-domain/api/v1/pprof/debug/pprof/profile > cpu.pdf 75 | 76 | # 生成内存profile的PDF图表 77 | go tool pprof -pdf http://your-domain/api/v1/pprof/debug/pprof/heap > heap.pdf 78 | ``` 79 | */ 80 | type PprofProxy struct { 81 | proxy *httputil.ReverseProxy 82 | } 83 | 84 | func NewPprofProxyController(targetURL string) *PprofProxy { 85 | target, err := url.Parse(targetURL) 86 | if err != nil { 87 | panic("invalid pprof target URL: " + err.Error()) 88 | } 89 | 90 | proxy := httputil.NewSingleHostReverseProxy(target) 91 | originalDirector := proxy.Director 92 | proxy.Director = func(req *http.Request) { 93 | originalDirector(req) 94 | req.URL.Path = strings.TrimPrefix(req.URL.Path, "/api/v1/robot/pprof") 95 | req.Host = target.Host 96 | } 97 | 98 | // 自定义错误处理 99 | proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { 100 | w.WriteHeader(http.StatusBadGateway) 101 | w.Write([]byte("pprof proxy error: " + err.Error())) 102 | } 103 | 104 | return &PprofProxy{ 105 | proxy: proxy, 106 | } 107 | } 108 | 109 | func (p *PprofProxy) ProxyPprof(c *gin.Context) { 110 | p.proxy.ServeHTTP(c.Writer, c.Request) 111 | } 112 | -------------------------------------------------------------------------------- /repository/system_message.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "time" 6 | "wechat-robot-client/model" 7 | 8 | "gorm.io/gorm" 9 | ) 10 | 11 | type SystemMessage struct { 12 | Ctx context.Context 13 | DB *gorm.DB 14 | } 15 | 16 | func NewSystemMessageRepo(ctx context.Context, db *gorm.DB) *SystemMessage { 17 | return &SystemMessage{ 18 | Ctx: ctx, 19 | DB: db, 20 | } 21 | } 22 | 23 | func (respo *SystemMessage) GetByID(id int64) (*model.SystemMessage, error) { 24 | var message model.SystemMessage 25 | err := respo.DB.WithContext(respo.Ctx).Where("id = ?", id).First(&message).Error 26 | if err == gorm.ErrRecordNotFound { 27 | return nil, nil 28 | } 29 | if err != nil { 30 | return nil, err 31 | } 32 | return &message, nil 33 | } 34 | 35 | func (respo *SystemMessage) GetRecentMessages(days int) ([]*model.SystemMessage, error) { 36 | var messages []*model.SystemMessage 37 | startTime := time.Now().AddDate(0, 0, -days).Unix() 38 | err := respo.DB.WithContext(respo.Ctx).Where("created_at >= ?", startTime).Order("created_at DESC").Find(&messages).Error 39 | return messages, err 40 | } 41 | 42 | // GetRecentMonthMessages 获取最近一个月的系统消息 43 | func (respo *SystemMessage) GetRecentMonthMessages() ([]*model.SystemMessage, error) { 44 | return respo.GetRecentMessages(30) 45 | } 46 | 47 | // GetRecentMessagesByType 获取最近指定天数的特定类型系统消息 48 | func (respo *SystemMessage) GetRecentMessagesByType(days int, msgType model.SystemMessageType) ([]*model.SystemMessage, error) { 49 | var messages []*model.SystemMessage 50 | startTime := time.Now().AddDate(0, 0, -days).Unix() 51 | err := respo.DB.WithContext(respo.Ctx).Where("created_at >= ? AND type = ?", startTime, msgType).Order("created_at DESC").Find(&messages).Error 52 | return messages, err 53 | } 54 | 55 | // GetUnreadMessages 获取未读的系统消息 56 | func (respo *SystemMessage) GetUnreadMessages() ([]*model.SystemMessage, error) { 57 | var messages []*model.SystemMessage 58 | err := respo.DB.WithContext(respo.Ctx).Where("is_read = ?", false).Order("created_at DESC").Find(&messages).Error 59 | return messages, err 60 | } 61 | 62 | // MarkAsRead 标记消息为已读 63 | func (respo *SystemMessage) MarkAsRead(id int64) error { 64 | return respo.DB.WithContext(respo.Ctx).Model(&model.SystemMessage{}).Where("id = ?", id).Update("is_read", true).Error 65 | } 66 | 67 | // MarkAsReadBatch 批量标记为已读 68 | func (respo *SystemMessage) MarkAsReadBatch(ids []int64) error { 69 | if len(ids) == 0 { 70 | return nil 71 | } 72 | return respo.DB.WithContext(respo.Ctx).Model(&model.SystemMessage{}).Where("id IN ?", ids).Update("is_read", true).Error 73 | } 74 | 75 | func (respo *SystemMessage) Create(data *model.SystemMessage) error { 76 | return respo.DB.WithContext(respo.Ctx).Create(data).Error 77 | } 78 | 79 | func (respo *SystemMessage) Update(data *model.SystemMessage) error { 80 | return respo.DB.WithContext(respo.Ctx).Where("id = ?", data.ID).Updates(data).Error 81 | } 82 | -------------------------------------------------------------------------------- /startup/robot.go: -------------------------------------------------------------------------------- 1 | package startup 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "os" 9 | "strconv" 10 | "time" 11 | "wechat-robot-client/pkg/robot" 12 | "wechat-robot-client/service" 13 | "wechat-robot-client/vars" 14 | ) 15 | 16 | func InitWechatRobot() error { 17 | // 从机器人管理后台加载机器人配置 18 | // 这些配置需要先登陆机器人管理后台注册微信机器人才能获得 19 | robotId := os.Getenv("ROBOT_ID") 20 | if robotId == "" { 21 | return errors.New("ROBOT_ID 环境变量未设置") 22 | } 23 | id, err := strconv.ParseInt(robotId, 10, 64) 24 | if err != nil { 25 | return err 26 | } 27 | robotAdmin, err := service.NewAdminService(context.Background()).GetRobotByID(id) 28 | if err != nil { 29 | return err 30 | } 31 | if robotAdmin == nil { 32 | return errors.New("未找到机器人配置") 33 | } 34 | vars.RobotRuntime.RobotID = robotAdmin.ID 35 | vars.RobotRuntime.RobotCode = robotAdmin.RobotCode 36 | vars.RobotRuntime.RobotRedisDB = robotAdmin.RedisDB 37 | vars.RobotRuntime.WxID = robotAdmin.WeChatID 38 | vars.RobotRuntime.DeviceID = robotAdmin.DeviceID 39 | vars.RobotRuntime.DeviceName = robotAdmin.DeviceName 40 | vars.RobotRuntime.Status = robotAdmin.Status 41 | var client *robot.Client 42 | if vars.WechatServerHost != "" { 43 | client = robot.NewClient(robot.WechatDomain(vars.WechatServerHost)) 44 | } else { 45 | client = robot.NewClient(robot.WechatDomain(fmt.Sprintf("server_%s:%d", robotAdmin.RobotCode, 9000))) 46 | } 47 | 48 | vars.RobotRuntime.Client = client 49 | 50 | systemSettings, err := service.NewSystemSettingService(context.Background()).GetSystemSettings() 51 | if err == nil && systemSettings != nil { 52 | if systemSettings.WebhookURL != nil { 53 | vars.Webhook.URL = *systemSettings.WebhookURL 54 | } 55 | if systemSettings.WebhookHeaders != nil { 56 | vars.Webhook.Headers = systemSettings.WebhookHeaders 57 | } 58 | } 59 | 60 | // 检测微信机器人服务端是否启动 61 | retryInterval := 10 * time.Second 62 | retryTicker := time.NewTicker(retryInterval) 63 | defer retryTicker.Stop() 64 | 65 | timeoutTimer := time.NewTimer(vars.RobotStartTimeout) 66 | defer timeoutTimer.Stop() 67 | 68 | for { 69 | select { 70 | case <-retryTicker.C: 71 | if vars.RobotRuntime.IsRunning() { 72 | log.Println("微信机器人服务端已启动") 73 | if vars.RobotRuntime.IsLoggedIn() { 74 | log.Println("微信机器人已登录") 75 | vars.RobotRuntime.LoginTime = time.Now().Unix() 76 | err := service.NewLoginService(context.Background()).Online() 77 | if err != nil { 78 | log.Println("微信机器人已登录,启动自动心跳失败:", err) 79 | return err 80 | } 81 | } else { 82 | log.Println("微信机器人服务端未登录") 83 | err := service.NewLoginService(context.Background()).Offline() 84 | if err != nil { 85 | log.Println("微信机器人服务端未登录,设置离线状态失败:", err) 86 | return err 87 | } 88 | } 89 | return nil 90 | } else { 91 | log.Println("等待微信机器人服务端启动...") 92 | } 93 | case <-timeoutTimer.C: 94 | return errors.New("等待微信机器人服务端启动超时,请检查服务端是否正常运行") 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /pkg/mcp/errors.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | var ( 9 | // ErrNotConnected MCP客户端未连接 10 | ErrNotConnected = errors.New("mcp client not connected") 11 | 12 | // ErrAlreadyConnected MCP客户端已连接 13 | ErrAlreadyConnected = errors.New("mcp client already connected") 14 | 15 | // ErrInvalidTransport 无效的传输类型 16 | ErrInvalidTransport = errors.New("invalid transport type") 17 | 18 | // ErrInvalidRequest 无效的请求 19 | ErrInvalidRequest = errors.New("invalid mcp request") 20 | 21 | // ErrInvalidResponse 无效的响应 22 | ErrInvalidResponse = errors.New("invalid mcp response") 23 | 24 | // ErrToolNotFound 工具未找到 25 | ErrToolNotFound = errors.New("tool not found") 26 | 27 | // ErrResourceNotFound 资源未找到 28 | ErrResourceNotFound = errors.New("resource not found") 29 | 30 | // ErrTimeout 请求超时 31 | ErrTimeout = errors.New("mcp request timeout") 32 | 33 | // ErrServerError 服务器错误 34 | ErrServerError = errors.New("mcp server error") 35 | 36 | // ErrAuthenticationFailed 认证失败 37 | ErrAuthenticationFailed = errors.New("authentication failed") 38 | 39 | // ErrConnectionClosed 连接已关闭 40 | ErrConnectionClosed = errors.New("connection closed") 41 | ) 42 | 43 | // MCPErrorCode MCP错误码 44 | const ( 45 | // JSON-RPC标准错误码 46 | ParseError = -32700 47 | InvalidRequest = -32600 48 | MethodNotFound = -32601 49 | InvalidParams = -32602 50 | InternalError = -32603 51 | 52 | // MCP自定义错误码 53 | ToolExecutionError = -32000 54 | ResourceAccessError = -32001 55 | ConnectionError = -32002 56 | AuthenticationError = -32003 57 | TimeoutError = -32004 58 | ) 59 | 60 | // NewMCPError 创建MCP错误(本地轻量错误结构,避免依赖已移除的协议镜像类型) 61 | type MCPError struct { 62 | Code int 63 | Message string 64 | Data any 65 | } 66 | 67 | func NewMCPError(code int, message string, data any) *MCPError { 68 | return &MCPError{Code: code, Message: message, Data: data} 69 | } 70 | 71 | // Error 实现error接口 72 | func (e *MCPError) Error() string { 73 | if e.Data != nil { 74 | return fmt.Sprintf("MCP Error [%d]: %s (data: %v)", e.Code, e.Message, e.Data) 75 | } 76 | return fmt.Sprintf("MCP Error [%d]: %s", e.Code, e.Message) 77 | } 78 | 79 | // WrapError 包装错误为MCP错误 80 | func WrapError(err error) *MCPError { 81 | if err == nil { 82 | return nil 83 | } 84 | 85 | if mcpErr, ok := err.(*MCPError); ok { 86 | return mcpErr 87 | } 88 | 89 | switch { 90 | case errors.Is(err, ErrNotConnected), errors.Is(err, ErrConnectionClosed): 91 | return NewMCPError(ConnectionError, err.Error(), nil) 92 | case errors.Is(err, ErrAuthenticationFailed): 93 | return NewMCPError(AuthenticationError, err.Error(), nil) 94 | case errors.Is(err, ErrTimeout): 95 | return NewMCPError(TimeoutError, err.Error(), nil) 96 | case errors.Is(err, ErrToolNotFound), errors.Is(err, ErrResourceNotFound): 97 | return NewMCPError(MethodNotFound, err.Error(), nil) 98 | case errors.Is(err, ErrInvalidRequest): 99 | return NewMCPError(InvalidRequest, err.Error(), nil) 100 | default: 101 | return NewMCPError(InternalError, err.Error(), nil) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /common_cron/good_morning.go: -------------------------------------------------------------------------------- 1 | package common_cron 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/http" 7 | "time" 8 | "wechat-robot-client/pkg/good_morning" 9 | "wechat-robot-client/service" 10 | "wechat-robot-client/vars" 11 | 12 | "github.com/go-resty/resty/v2" 13 | ) 14 | 15 | type GoodMorningCron struct { 16 | CronManager *CronManager 17 | } 18 | 19 | func NewGoodMorningCron(cronManager *CronManager) vars.CommonCronInstance { 20 | return &GoodMorningCron{ 21 | CronManager: cronManager, 22 | } 23 | } 24 | 25 | func (cron *GoodMorningCron) IsActive() bool { 26 | if cron.CronManager.globalSettings.MorningEnabled != nil && *cron.CronManager.globalSettings.MorningEnabled { 27 | return true 28 | } 29 | return false 30 | } 31 | 32 | func (cron *GoodMorningCron) Cron() error { 33 | // 获取当前时间 34 | now := time.Now() 35 | // 获取年、月、日 36 | year := now.Year() 37 | month := now.Month() 38 | day := now.Day() 39 | // 获取星期 40 | weekday := now.Weekday() 41 | // 定义中文星期数组 42 | weekdays := [...]string{"星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"} 43 | 44 | chatRoomSettings, err := service.NewChatRoomSettingsService(context.Background()).GetAllEnableGoodMorning() 45 | if err != nil { 46 | log.Printf("获取群聊设置失败: %v", err) 47 | return err 48 | } 49 | 50 | // 每日一言 51 | dailyWords := "早上好,今天接口挂了,没有早安语。" 52 | resp, err := resty.New().R(). 53 | Post("https://api.pearktrue.cn/api/hitokoto/") 54 | if err != nil || resp.StatusCode() != http.StatusOK { 55 | log.Printf("获取随机一言失败: %v", err) 56 | } else { 57 | respText := resp.String() 58 | if respText != "" { 59 | dailyWords = respText 60 | } 61 | } 62 | 63 | crService := service.NewChatRoomService(context.Background()) 64 | msgService := service.NewMessageService(context.Background()) 65 | for _, setting := range chatRoomSettings { 66 | summary, err := crService.GetChatRoomSummary(setting.ChatRoomID) 67 | if err != nil { 68 | log.Printf("统计群[%s]信息失败: %v", setting.ChatRoomID, err) 69 | continue 70 | } 71 | 72 | summary.Year = year 73 | summary.Month = int(month) 74 | summary.Date = day 75 | summary.Week = weekdays[weekday] 76 | 77 | image, err := good_morning.Draw(dailyWords, summary) 78 | if err != nil { 79 | log.Printf("绘制群[%s]早安图片失败: %v", setting.ChatRoomID, err) 80 | continue 81 | } 82 | 83 | _, err = msgService.MsgUploadImg(setting.ChatRoomID, image) 84 | if err != nil { 85 | log.Printf("群[%s]早安图片发送失败: %v", setting.ChatRoomID, err) 86 | continue 87 | } 88 | log.Printf("群[%s]早安图片发送成功", setting.ChatRoomID) 89 | } 90 | 91 | return nil 92 | } 93 | 94 | func (cron *GoodMorningCron) Register() { 95 | if !cron.IsActive() { 96 | log.Println("每日早安任务未启用") 97 | return 98 | } 99 | err := cron.CronManager.AddJob(vars.MorningCron, cron.CronManager.globalSettings.MorningCron, func() { 100 | log.Println("开始每日早安任务") 101 | if err := cron.Cron(); err != nil { 102 | log.Printf("每日早安任务执行失败: %v", err) 103 | } else { 104 | log.Println("每日早安任务执行完成") 105 | } 106 | }) 107 | if err != nil { 108 | log.Printf("每日早安任务注册失败: %v", err) 109 | return 110 | } 111 | log.Println("每日早安任务初始化成功") 112 | } 113 | -------------------------------------------------------------------------------- /pkg/mcp/client.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "context" 5 | "sync/atomic" 6 | "time" 7 | 8 | "wechat-robot-client/model" 9 | 10 | sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" 11 | ) 12 | 13 | // MCPClient MCP客户端接口 14 | type MCPClient interface { 15 | // Connect 连接到MCP服务器 16 | Connect(ctx context.Context) error 17 | 18 | // Disconnect 断开连接 19 | Disconnect() error 20 | 21 | // IsConnected 检查是否已连接 22 | IsConnected() bool 23 | 24 | // Initialize 初始化MCP会话 25 | Initialize(ctx context.Context) (*MCPServerInfo, error) 26 | 27 | // ListTools 列出所有可用工具(使用官方SDK结构) 28 | ListTools(ctx context.Context) ([]*sdkmcp.Tool, error) 29 | 30 | // CallTool 调用工具(使用官方SDK结构) 31 | CallTool(ctx context.Context, params *sdkmcp.CallToolParams) (*sdkmcp.CallToolResult, error) 32 | 33 | // ListResources 列出所有可用资源(使用官方SDK结构) 34 | ListResources(ctx context.Context) ([]*sdkmcp.Resource, error) 35 | 36 | // ReadResource 读取资源(使用官方SDK结构) 37 | ReadResource(ctx context.Context, params *sdkmcp.ReadResourceParams) (*sdkmcp.ReadResourceResult, error) 38 | 39 | // Ping 心跳检测 40 | Ping(ctx context.Context) error 41 | 42 | // GetServerInfo 获取服务器信息 43 | GetServerInfo() *MCPServerInfo 44 | 45 | // GetStats 获取连接统计 46 | GetStats() *MCPConnectionStats 47 | 48 | // GetConfig 获取服务器配置 49 | GetConfig() *model.MCPServer 50 | } 51 | 52 | // BaseClient MCP客户端基础实现 53 | type BaseClient struct { 54 | config *model.MCPServer 55 | serverInfo *MCPServerInfo 56 | connected atomic.Bool 57 | stats MCPConnectionStats 58 | } 59 | 60 | // NewBaseClient 创建基础客户端 61 | func NewBaseClient(config *model.MCPServer) *BaseClient { 62 | return &BaseClient{ 63 | config: config, 64 | stats: MCPConnectionStats{ 65 | IsConnected: false, 66 | }, 67 | } 68 | } 69 | 70 | // IsConnected 检查是否已连接 71 | func (c *BaseClient) IsConnected() bool { 72 | return c.connected.Load() 73 | } 74 | 75 | // GetServerInfo 获取服务器信息 76 | func (c *BaseClient) GetServerInfo() *MCPServerInfo { 77 | return c.serverInfo 78 | } 79 | 80 | // GetStats 获取连接统计 81 | func (c *BaseClient) GetStats() *MCPConnectionStats { 82 | return &c.stats 83 | } 84 | 85 | // GetConfig 获取服务器配置 86 | func (c *BaseClient) GetConfig() *model.MCPServer { 87 | return c.config 88 | } 89 | 90 | // setConnected 设置连接状态 91 | func (c *BaseClient) setConnected(connected bool) { 92 | c.connected.Store(connected) 93 | c.stats.IsConnected = connected 94 | if connected { 95 | c.stats.ConnectedAt = time.Now() 96 | c.stats.LastActiveAt = time.Now() 97 | } 98 | } 99 | 100 | // setServerInfo 设置服务器信息 101 | func (c *BaseClient) setServerInfo(info *MCPServerInfo) { 102 | c.serverInfo = info 103 | } 104 | 105 | // updateStats 更新统计信息 106 | func (c *BaseClient) updateStats(success bool, latency time.Duration) { 107 | c.stats.LastActiveAt = time.Now() 108 | c.stats.RequestCount++ 109 | if success { 110 | c.stats.SuccessCount++ 111 | c.stats.ErrorCount = 0 112 | } else { 113 | c.stats.SuccessCount = 0 114 | c.stats.ErrorCount++ 115 | } 116 | if c.stats.AverageLatency == 0 { 117 | c.stats.AverageLatency = latency 118 | } else { 119 | c.stats.AverageLatency = (c.stats.AverageLatency + latency) / 2 120 | } 121 | } 122 | 123 | // NewMCPClient 根据配置创建MCP客户端 124 | func NewMCPClient(config *model.MCPServer) (MCPClient, error) { 125 | switch config.Transport { 126 | case model.MCPTransportTypeStdio: 127 | return NewStdioClient(config), nil 128 | case model.MCPTransportTypeStream: 129 | return NewStreamableClient(config), nil 130 | default: 131 | return nil, ErrInvalidTransport 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /plugin/plugins/douyin_video_parse.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/go-resty/resty/v2" 10 | 11 | "wechat-robot-client/interface/plugin" 12 | "wechat-robot-client/pkg/robot" 13 | "wechat-robot-client/vars" 14 | ) 15 | 16 | type VideoParseResponse struct { 17 | Code int `json:"code"` 18 | Msg string `json:"msg"` 19 | Data struct { 20 | Author string `json:"author"` 21 | Avatar string `json:"avatar"` 22 | Title string `json:"title"` 23 | Desc string `json:"desc"` 24 | Digg int32 `json:"digg"` 25 | Comment int32 `json:"comment"` 26 | Play int32 `json:"play"` 27 | CreateTime int64 `json:"create_time"` 28 | Cover string `json:"cover"` 29 | URL string `json:"url"` 30 | MusicURL string `json:"music_url"` 31 | } `json:"data"` 32 | } 33 | 34 | type DouyinVideoParsePlugin struct{} 35 | 36 | func NewDouyinVideoParsePlugin() plugin.MessageHandler { 37 | return &DouyinVideoParsePlugin{} 38 | } 39 | 40 | func (p *DouyinVideoParsePlugin) GetName() string { 41 | return "DouyinVideoParse" 42 | } 43 | 44 | func (p *DouyinVideoParsePlugin) GetLabels() []string { 45 | return []string{"text", "douyin"} 46 | } 47 | 48 | func (p *DouyinVideoParsePlugin) PreAction(ctx *plugin.MessageContext) bool { 49 | return true 50 | } 51 | 52 | func (p *DouyinVideoParsePlugin) PostAction(ctx *plugin.MessageContext) { 53 | 54 | } 55 | 56 | func (p *DouyinVideoParsePlugin) Run(ctx *plugin.MessageContext) bool { 57 | var douyinShareContent string 58 | if ctx.ReferMessage != nil { 59 | douyinShareContent = ctx.ReferMessage.Content 60 | } else { 61 | douyinShareContent = ctx.Message.Content 62 | } 63 | 64 | if !strings.Contains(douyinShareContent, "https://v.douyin.com") { 65 | return false 66 | } 67 | 68 | re := regexp.MustCompile(`https://[^\s]+`) 69 | matches := re.FindAllString(douyinShareContent, -1) 70 | if len(matches) == 0 { 71 | ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, "未找到抖音链接") 72 | return true 73 | } 74 | 75 | // 获取第一个匹配的链接 76 | douyinURL := matches[0] 77 | 78 | var respData VideoParseResponse 79 | client := resty.New() 80 | resp, err := client.R(). 81 | SetHeader("Content-Type", "application/json"). 82 | SetBody(map[string]string{ 83 | "key": vars.ThirdPartyApiKey, 84 | "url": douyinURL, 85 | }). 86 | SetResult(&respData). 87 | Post("https://api.pearktrue.cn/api/video/api.php") 88 | if err != nil { 89 | ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, err.Error()) 90 | return true 91 | } 92 | if resp.StatusCode() != http.StatusOK { 93 | ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, http.StatusText(resp.StatusCode())) 94 | return true 95 | } 96 | if respData.Data.URL == "" { 97 | ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, "解析失败,可能是链接已失效或格式不正确") 98 | return true 99 | } 100 | 101 | shareLink := robot.ShareLinkMessage{ 102 | Title: fmt.Sprintf("抖音视频解析成功 - %s", respData.Data.Author), 103 | Des: respData.Data.Title, 104 | Url: respData.Data.URL, 105 | ThumbUrl: robot.CDATAString("https://mmbiz.qpic.cn/mmbiz_png/NbW0ZIUM8lVHoUbjXw2YbYXbNJDtUH7Sbkibm9Qwo9FhAiaEFG4jY3Q2MEleRpiaWDyDv8BZUfR85AW3kG4ib6DyAw/640?wx_fmt=png"), 106 | } 107 | if respData.Data.Desc != "" { 108 | shareLink.Des = respData.Data.Desc 109 | } 110 | 111 | _ = ctx.MessageService.ShareLink(ctx.Message.FromWxID, shareLink) 112 | _ = ctx.MessageService.SendVideoMessageByRemoteURL(ctx.Message.FromWxID, respData.Data.URL) 113 | 114 | return true 115 | } 116 | -------------------------------------------------------------------------------- /service/ai_chat.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "wechat-robot-client/interface/ai" 7 | "wechat-robot-client/interface/settings" 8 | "wechat-robot-client/model" 9 | "wechat-robot-client/pkg/mcp" 10 | "wechat-robot-client/vars" 11 | 12 | "github.com/sashabaranov/go-openai" 13 | ) 14 | 15 | type AIChatService struct { 16 | ctx context.Context 17 | config settings.Settings 18 | } 19 | 20 | var _ ai.AIService = (*AIChatService)(nil) 21 | 22 | func NewAIChatService(ctx context.Context, config settings.Settings) *AIChatService { 23 | return &AIChatService{ 24 | ctx: ctx, 25 | config: config, 26 | } 27 | } 28 | 29 | func (s *AIChatService) SetAISession(message *model.Message) error { 30 | return vars.RedisClient.Set(s.ctx, s.GetSessionID(message), true, defaultTTL).Err() 31 | } 32 | 33 | func (s *AIChatService) RenewAISession(message *model.Message) error { 34 | return vars.RedisClient.Expire(s.ctx, s.GetSessionID(message), defaultTTL).Err() 35 | } 36 | 37 | func (s *AIChatService) ExpireAISession(message *model.Message) error { 38 | return vars.RedisClient.Del(s.ctx, s.GetSessionID(message)).Err() 39 | } 40 | 41 | func (s *AIChatService) ExpireAllAISessionByChatRoomID(chatRoomID string) error { 42 | sessionID := fmt.Sprintf("ai_chat_session_%s:", chatRoomID) 43 | keys, err := vars.RedisClient.Keys(s.ctx, sessionID+"*").Result() 44 | if err != nil { 45 | return err 46 | } 47 | if len(keys) == 0 { 48 | return nil 49 | } 50 | return vars.RedisClient.Del(s.ctx, keys...).Err() 51 | } 52 | 53 | func (s *AIChatService) IsInAISession(message *model.Message) (bool, error) { 54 | cnt, err := vars.RedisClient.Exists(s.ctx, s.GetSessionID(message)).Result() 55 | return cnt == 1, err 56 | } 57 | 58 | func (s *AIChatService) GetSessionID(message *model.Message) string { 59 | return fmt.Sprintf("ai_chat_session_%s:%s", message.FromWxID, message.SenderWxID) 60 | } 61 | 62 | func (s *AIChatService) IsAISessionStart(message *model.Message) bool { 63 | if message.Content == "#进入AI会话" { 64 | err := s.SetAISession(message) 65 | return err == nil 66 | } 67 | return false 68 | } 69 | 70 | func (s *AIChatService) GetAISessionStartTips() string { 71 | return "AI会话已开始,请输入您的问题。10分钟不说话会话将自动结束,您也可以输入 #退出AI会话 来结束会话。" 72 | } 73 | 74 | func (s *AIChatService) IsAISessionEnd(message *model.Message) bool { 75 | if message.Content == "#退出AI会话" { 76 | err := s.ExpireAISession(message) 77 | return err == nil 78 | } 79 | return false 80 | } 81 | 82 | func (s *AIChatService) GetAISessionEndTips() string { 83 | return "AI会话已结束,您可以输入 #进入AI会话 来重新开始。" 84 | } 85 | 86 | func (s *AIChatService) Chat(robotCtx mcp.RobotContext, aiMessages []openai.ChatCompletionMessage) (openai.ChatCompletionMessage, error) { 87 | aiConfig := s.config.GetAIConfig() 88 | if aiConfig.Prompt != "" { 89 | systemMessage := openai.ChatCompletionMessage{ 90 | Role: openai.ChatMessageRoleSystem, 91 | Content: aiConfig.Prompt, 92 | } 93 | if aiConfig.MaxCompletionTokens > 0 { 94 | systemMessage.Content += fmt.Sprintf("\n\n请注意,每次回答不能超过%d个汉字。", aiConfig.MaxCompletionTokens) 95 | } 96 | aiMessages = append([]openai.ChatCompletionMessage{systemMessage}, aiMessages...) 97 | } 98 | openaiConfig := openai.DefaultConfig(aiConfig.APIKey) 99 | openaiConfig.BaseURL = aiConfig.BaseURL 100 | client := openai.NewClientWithConfig(openaiConfig) 101 | req := openai.ChatCompletionRequest{ 102 | Model: aiConfig.Model, 103 | Messages: aiMessages, 104 | Stream: false, 105 | } 106 | if aiConfig.MaxCompletionTokens > 0 { 107 | req.MaxCompletionTokens = aiConfig.MaxCompletionTokens 108 | } 109 | return vars.MCPService.ChatWithMCPTools(robotCtx, client, req, 0) 110 | } 111 | -------------------------------------------------------------------------------- /pkg/robot/common.go: -------------------------------------------------------------------------------- 1 | package robot 2 | 3 | type MmtlsClient struct { 4 | Shakehandpubkey string 5 | Shakehandpubkeylen int32 6 | Shakehandprikey string 7 | Shakehandprikeylen int32 8 | 9 | Shakehandpubkey_2 string 10 | Shakehandpubkeylen2 int32 11 | Shakehandprikey_2 string 12 | Shakehandprikeylen2 int32 13 | 14 | Mserverpubhashs string 15 | ServerSeq int 16 | ClientSeq int 17 | ShakehandECDHkey string 18 | ShakehandECDHkeyLen int32 19 | 20 | Encrptmmtlskey string 21 | Decryptmmtlskey string 22 | EncrptmmtlsIv string 23 | DecryptmmtlsIv string 24 | 25 | CurDecryptSeqIv string 26 | CurEncryptSeqIv string 27 | 28 | Decrypt_part2_hash256 string 29 | Decrypt_part3_hash256 string 30 | ShakehandECDHkeyhash string 31 | Hkdfexpand_pskaccess_key string 32 | Hkdfexpand_pskrefresh_key string 33 | HkdfExpand_info_serverfinish_key string 34 | Hkdfexpand_clientfinish_key string 35 | Hkdfexpand_secret_key string 36 | 37 | Hkdfexpand_application_key string 38 | Encrptmmtlsapplicationkey string 39 | Decryptmmtlsapplicationkey string 40 | EncrptmmtlsapplicationIv string 41 | DecryptmmtlsapplicationIv string 42 | 43 | Earlydatapart string 44 | Newsendbufferhashs string 45 | Encrptshortmmtlskey string 46 | Encrptshortmmtlsiv string 47 | Decrptshortmmtlskey string 48 | Decrptshortmmtlsiv string 49 | 50 | //http才需要 51 | Pskkey string 52 | Pskiv string 53 | MmtlsMode uint 54 | } 55 | 56 | type SKBuiltinStringT struct { 57 | String *string `json:"string,omitempty"` 58 | } 59 | 60 | type SKBuiltinBufferT struct { 61 | ILen *uint32 `protobuf:"varint,1,opt,name=iLen" json:"iLen,omitempty"` 62 | Buffer string `protobuf:"bytes,2,opt,name=buffer" json:"buffer,omitempty"` 63 | } 64 | 65 | type SKBuiltinString_S struct { 66 | ILen *uint32 `protobuf:"varint,1,opt,name=iLen" json:"iLen,omitempty"` 67 | Buffer *string `protobuf:"bytes,2,opt,name=buffer" json:"buffer,omitempty"` 68 | } 69 | 70 | type CommonRequest struct { 71 | Wxid string `json:"wxid"` 72 | } 73 | 74 | type ProxyInfo struct { 75 | ProxyIp string `json:"ProxyIp"` 76 | ProxyUser string `json:"ProxyUser"` 77 | ProxyPassword string `json:"ProxyPassword"` 78 | } 79 | 80 | type Dns struct { 81 | Ip string 82 | Host string 83 | } 84 | 85 | type Timespec struct { 86 | Tvsec uint64 `json:"tv_sec"` 87 | Tvnsec uint64 `json:"tv_nsec"` 88 | } 89 | 90 | type Statfs struct { 91 | Type uint64 `json:"type"` // f_type = 26 92 | Fstypename string `json:"fstypename"` //apfs 93 | Flags uint64 `json:"flags"` //statfs f_flags = 1417728009 94 | Mntonname string `json:"mntonname"` // / 95 | Mntfromname string `json:"mntfromname"` // com.apple.os.update-{%{96}s}@/dev/disk0s1s1 96 | Fsid uint64 `json:"fsid"` // f_fsid[0] 97 | } 98 | 99 | type Stat struct { 100 | Inode uint64 `json:"inode"` 101 | Statime Timespec `json:"st_atime"` 102 | Stmtime Timespec `json:"st_mtime"` 103 | Stctime Timespec `json:"st_ctime"` 104 | Stbtime Timespec `json:"st_btime"` 105 | } 106 | 107 | // BaseResponse 大部分返回对象都携带该信息 108 | type BaseResponse struct { 109 | Ret int `json:"ret"` 110 | ErrMsg *SKBuiltinStringT `json:"errMsg"` 111 | } 112 | 113 | func (b BaseResponse) Ok() bool { 114 | return b.Ret == 0 115 | } 116 | 117 | type OplogRet struct { 118 | Count *uint32 `json:"count,omitempty"` 119 | Ret string `json:"ret,omitempty"` 120 | ErrMsg string `json:"errMsg,omitempty"` 121 | } 122 | 123 | type OplogResponse struct { 124 | Ret *int32 `json:"ret,omitempty"` 125 | OplogRet *OplogRet `json:"oplogRet,omitempty"` 126 | } 127 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 更新日志 2 | 3 | ## [2.0.0] - 2025/11/15 4 | 5 | ### 破坏性更新 6 | 7 | - `wechat-robot-admin-backend`服务新增了一个必填环境变量`UUID_URL`,可填写为`http://wechat-slider:9000` 8 | 9 | - 表结构新增和更新,请按照 [SQL 升级脚本](https://github.com/hp0912/wechat-robot-admin-backend/blob/main/template/2_0_0.sql)进行升级 10 | 11 | - 需要更新`wechat-slider`服务的镜像,执行 `docker pull registry.cn-shenzhen.aliyuncs.com/houhou/wechat/wechat-slider-base:latest` 12 | 13 | - 移除 `jimeng-free-api` 服务,执行 `docker compose rm -s -f jimeng-free-api` 或者 `docker-compose rm -s -f jimeng-free-api`,哪个能用用哪个 14 | 15 | - 拉取最新代码获取最新`docker-compose.yml`文件,根据文件内容,手动拉一遍镜像,成功率更高。 16 | 17 | ### 新特性 18 | 19 | - 完整的 MCP 协议支持 20 | 21 | - 开放微信消息 Webhook 回调 22 | 23 | ### 体验性优化 24 | 25 | - iPad 协议新增支持 pprof 监控(启用方法: 机器人详情界面,更新镜像下拉框 -> 删除服务端容器 -> 创建服务端容器 (启用pprof)) 26 | 27 | - 即梦逆向 api 由 [jimeng-free-api](https://github.com/LLM-Red-Team/jimeng-free-api) 迁移到 [jimeng-api](https://github.com/iptag/jimeng-api),以支持更多功能 28 | 29 | - 优化机器人管理后台前端项目本地Docker构建(本地构建使用 dev.Dockerfile) 30 | 31 | ### BUG 修复 32 | 33 | - 修复 iPad 协议提取 ticket 异常的问题 34 | 35 | ## [1.6.0] - 2025/10/12 36 | 37 | ### 体验性优化 38 | 39 | - 提高抖音视频解析稳定性。(破坏性更新: 抖音解析的 API 由免费 API 改为收费 API,原先的环境变量`THIRD_PARTY_API_KEY`记得保持余额充足,一分钱解析10次。附:[充值链接](https://api.pearktrue.cn/dashboard/profile)) 40 | 41 | ## [1.5.2] - 2025/10/01 42 | 43 | ### 新特性 44 | 45 | - 消息图片自动上传 OSS (需要执行数据库升级脚本[https://github.com/hp0912/wechat-robot-admin-backend/blob/1.3.2/template/1_3_2.sql](https://github.com/hp0912/wechat-robot-admin-backend/blob/1.3.2/template/1_3_2.sql)) 46 | 47 | ## [1.5.1] - 2025/09/27 48 | 49 | ### 体验性优化 50 | 51 | - 支持登录设备迁移 (导出登录信息、导入登录信息) 52 | 53 | ## [1.5.0] - 2025/09/27 54 | 55 | ### 体验性优化 56 | 57 | - 显示当前登录设备类型和微信版本。 58 | 59 | ## [1.4.3] - 2025/09/22 60 | 61 | ### 新特性 62 | 63 | - iPad 伪装登录。 64 | 65 | ## [1.4.2] - 2025/09/21 66 | 67 | ### BUG 修复 68 | 69 | - 修复点歌接口挂了的问题 70 | 71 | - 修复发送AI消息获取音色接口挂了的问题 72 | 73 | - 修复扫码登录UUID检测异常的问题 74 | 75 | ## [1.4.1] - 2025/09/21 76 | 77 | ### 新特性 78 | 79 | - Mac 扫码登录支持自动过滑块。 80 | 81 | > 本次更新包含破坏性更新 82 | > 83 | > `wechat-robot-admin-backend`服务需要新增两个环境变量,否则服务会启动失败 84 | > 85 | > - SLIDER_SERVER_BASE_URL=http://wechat-slider:9000 86 | > 87 | > - SLIDER_TOKEN=xxxxxxx # 滑块验证码服务密钥,请加入官方交流群获取 88 | > 89 | 90 | ## [1.4.0] - 2025/09/20 91 | 92 | ### 新特性 93 | 94 | - ~~Mac 扫码登录支持手动过滑块。~~ 95 | 96 | > 本次更新包含破坏性更新 97 | > 98 | > `wechat-robot-admin-backend`服务需要新增两个环境变量,否则服务会启动失败 99 | > 100 | > - ~~SLIDER_VERIFY_URL=http://wechat-slider:9000/api/v1/slider-verify-html~~ 101 | > 102 | > - ~~SLIDER_VERIFY_SUBMIT_URL=http://wechat-slider:9000/api/v1/security-verify~~ 103 | > 104 | 105 | ## [1.3.0] - 2025/09/19 106 | 107 | ### 体验性优化 108 | 109 | - 抖音视频会同时发送链接和视频。 110 | 111 | ### BUG 修复 112 | 113 | - 修复 Mac 登录异常的问题。 114 | 115 | ## [1.2.0] - 2025/09/10 116 | 117 | ### 体验性优化 118 | 119 | - 抖音视频由手动出发改为自动触发。 120 | 121 | ## [1.1.15] - 2025/09/09 122 | 123 | ### 体验性优化 124 | 125 | - 抖音视频解析由直接发送视频改为发送卡片链接。 126 | 127 | ## [1.1.14] - 2025/09/06 128 | 129 | ### 体验性优化 130 | 131 | - 公众号如果没有手动开启AI聊天,默认不开启 (原来会继承全局 AI 设置)。 132 | 133 | - 群聊总结改为以聊天记录的形式发送,避免内容过多被折叠。 134 | 135 | ## [1.1.13] - 2025/09/04 136 | 137 | ### BUG 修复 138 | 139 | - 修复因为每日早报上游接口数据结构变化导致获取每日早报失败的问题 140 | 141 | - 修复AI机器人在私聊场景会响应自己发送(从其他设备发送)的消息的问题。 142 | 143 | ## [1.1.12] - 2025/08/29 144 | 145 | ### 新特性 146 | 147 | - 支持通过登录密钥登录机器人管理后台 148 | 149 | ## [1.1.11] - 2025/08/19 150 | 151 | ### 新特性 152 | 153 | - 支持多种登录方式(iPad、Windows微信、车载微信、Mac微信) 154 | 155 | - 支持Data62登录 156 | 157 | - 支持A16登录 158 | 159 | ### 修改 160 | 161 | - 优化创建机器人docker容器流程,如果docker镜像还没拉取过,会自动拉取 (wechat-robot-admin-backend) 162 | 163 | ## [1.1.10] - 2025/08/18 164 | 165 | ### 新特性 166 | 167 | - 新增文本消息群发接口 168 | 169 | ## [1.1.9] - 2025/08/16 170 | 171 | ### 新特性 172 | 173 | - 支持发送文件消息,流式发送,避免发送超大文件时,内存溢出 -------------------------------------------------------------------------------- /common_cron/news.go: -------------------------------------------------------------------------------- 1 | package common_cron 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "log" 8 | "os" 9 | "strings" 10 | "wechat-robot-client/service" 11 | "wechat-robot-client/vars" 12 | 13 | "github.com/go-resty/resty/v2" 14 | ) 15 | 16 | type NewsCron struct { 17 | CronManager *CronManager 18 | } 19 | 20 | type NewsResponse struct { 21 | Code int `json:"code"` 22 | Msg string `json:"msg"` 23 | Date string `json:"date"` 24 | HeadImg string `json:"head_image"` 25 | Image string `json:"image"` 26 | Audio string `json:"audio"` 27 | News []string `json:"news"` 28 | Weiyu string `json:"weiyu"` 29 | } 30 | 31 | func NewNewsCron(cronManager *CronManager) vars.CommonCronInstance { 32 | return &NewsCron{ 33 | CronManager: cronManager, 34 | } 35 | } 36 | 37 | func (cron *NewsCron) IsActive() bool { 38 | if cron.CronManager.globalSettings.NewsEnabled != nil && *cron.CronManager.globalSettings.NewsEnabled { 39 | return true 40 | } 41 | return false 42 | } 43 | 44 | func (cron *NewsCron) Cron() error { 45 | globalSettings, err := service.NewGlobalSettingsService(context.Background()).GetGlobalSettings() 46 | if err != nil { 47 | log.Printf("获取全局设置失败: %v", err) 48 | return err 49 | } 50 | settings, err := service.NewChatRoomSettingsService(context.Background()).GetAllEnableNews() 51 | if err != nil { 52 | log.Printf("获取群聊设置失败: %v", err) 53 | return err 54 | } 55 | 56 | var newsResp NewsResponse 57 | _, err = resty.New().R(). 58 | SetHeader("Content-Type", "application/json;chartset=utf-8"). 59 | SetQueryParam("type", "json"). 60 | SetResult(&newsResp). 61 | Get("https://api.suxun.site/api/sixs") 62 | if err != nil { 63 | return err 64 | } 65 | if newsResp.Code != 200 { 66 | return fmt.Errorf("获取每日早报失败: %s", newsResp.Msg) 67 | } 68 | 69 | msgService := service.NewMessageService(context.Background()) 70 | newsText := strings.Join(newsResp.News, "\n") 71 | 72 | newsImage := newsResp.Image 73 | resp, err := resty.New().R().SetDoNotParseResponse(true).Get(newsImage) 74 | if err != nil { 75 | return err 76 | } 77 | defer resp.RawBody().Close() 78 | // 创建临时文件 79 | tempFile, err := os.CreateTemp("", "news_image_*") 80 | if err != nil { 81 | return err 82 | } 83 | defer tempFile.Close() 84 | defer os.Remove(tempFile.Name()) // 清理临时文件 85 | // 将图片数据写入临时文件 86 | _, err = io.Copy(tempFile, resp.RawBody()) 87 | if err != nil { 88 | return err 89 | } 90 | 91 | for _, setting := range settings { 92 | newsType := globalSettings.NewsType 93 | if setting.NewsType != nil && *setting.NewsType != "" { 94 | newsType = *setting.NewsType 95 | } 96 | if newsType == "text" { 97 | err := msgService.SendTextMessage(setting.ChatRoomID, newsText) 98 | if err != nil { 99 | log.Printf("[每日早报] 发送文本消息失败: %v", err) 100 | } 101 | } else { 102 | // 重置文件指针到开始位置 103 | _, err := tempFile.Seek(0, 0) 104 | if err != nil { 105 | log.Printf("[每日早报] 重置文件指针失败: %v", err) 106 | continue 107 | } 108 | _, err = msgService.MsgUploadImg(setting.ChatRoomID, tempFile) 109 | if err != nil { 110 | log.Printf("[每日早报] 发送图片消息失败: %v", err) 111 | } 112 | } 113 | } 114 | 115 | return nil 116 | } 117 | 118 | func (cron *NewsCron) Register() { 119 | if !cron.IsActive() { 120 | log.Println("每日早报任务未启用") 121 | return 122 | } 123 | err := cron.CronManager.AddJob(vars.NewsCron, cron.CronManager.globalSettings.NewsCron, func() { 124 | log.Println("开始执行每日早报任务") 125 | if err := cron.Cron(); err != nil { 126 | log.Printf("执行每日早报任务失败: %v", err) 127 | } else { 128 | log.Println("每日早报任务执行完成") 129 | } 130 | }) 131 | if err != nil { 132 | log.Printf("每日早报任务注册失败: %v", err) 133 | return 134 | } 135 | log.Println("每日早报任务初始化成功") 136 | } 137 | -------------------------------------------------------------------------------- /controller/contact.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "errors" 5 | "wechat-robot-client/dto" 6 | "wechat-robot-client/pkg/appx" 7 | "wechat-robot-client/service" 8 | 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | type Contact struct { 13 | } 14 | 15 | func NewContactController() *Contact { 16 | return &Contact{} 17 | } 18 | 19 | func (ct *Contact) SyncContact(c *gin.Context) { 20 | resp := appx.NewResponse(c) 21 | err := service.NewContactService(c).SyncContact(false) 22 | if err != nil { 23 | resp.ToErrorResponse(err) 24 | return 25 | } 26 | resp.ToResponse(nil) 27 | } 28 | 29 | func (ct *Contact) GetContacts(c *gin.Context) { 30 | var req dto.ContactListRequest 31 | resp := appx.NewResponse(c) 32 | if ok, err := appx.BindAndValid(c, &req); !ok || err != nil { 33 | resp.ToErrorResponse(errors.New("参数错误")) 34 | return 35 | } 36 | pager := appx.InitPager(c) 37 | list, total, err := service.NewContactService(c).GetContacts(req, pager) 38 | if err != nil { 39 | resp.ToErrorResponse(err) 40 | return 41 | } 42 | resp.ToResponseList(list, total) 43 | } 44 | 45 | func (ct *Contact) FriendSearch(c *gin.Context) { 46 | var req dto.FriendSearchRequest 47 | resp := appx.NewResponse(c) 48 | if ok, err := appx.BindAndValid(c, &req); !ok || err != nil { 49 | resp.ToErrorResponse(errors.New("参数错误")) 50 | return 51 | } 52 | friend, err := service.NewContactService(c).FriendSearch(req) 53 | if err != nil { 54 | resp.ToErrorResponse(err) 55 | return 56 | } 57 | resp.ToResponse(friend) 58 | } 59 | 60 | func (ct *Contact) FriendSendRequest(c *gin.Context) { 61 | var req dto.FriendSendRequestRequest 62 | resp := appx.NewResponse(c) 63 | if ok, err := appx.BindAndValid(c, &req); !ok || err != nil { 64 | resp.ToErrorResponse(errors.New("参数错误")) 65 | return 66 | } 67 | err := service.NewContactService(c).FriendSendRequest(req) 68 | if err != nil { 69 | resp.ToErrorResponse(err) 70 | return 71 | } 72 | resp.ToResponse(nil) 73 | } 74 | 75 | func (ct *Contact) FriendSendRequestFromChatRoom(c *gin.Context) { 76 | var req dto.FriendSendRequestFromChatRoomRequest 77 | resp := appx.NewResponse(c) 78 | if ok, err := appx.BindAndValid(c, &req); !ok || err != nil { 79 | resp.ToErrorResponse(errors.New("参数错误")) 80 | return 81 | } 82 | err := service.NewContactService(c).FriendSendRequestFromChatRoom(req) 83 | if err != nil { 84 | resp.ToErrorResponse(err) 85 | return 86 | } 87 | resp.ToResponse(nil) 88 | } 89 | 90 | func (ct *Contact) FriendSetRemarks(c *gin.Context) { 91 | var req dto.FriendSetRemarksRequest 92 | resp := appx.NewResponse(c) 93 | if ok, err := appx.BindAndValid(c, &req); !ok || err != nil { 94 | resp.ToErrorResponse(errors.New("参数错误")) 95 | return 96 | } 97 | err := service.NewContactService(c).FriendSetRemarks(req) 98 | if err != nil { 99 | resp.ToErrorResponse(err) 100 | return 101 | } 102 | resp.ToResponse(nil) 103 | } 104 | 105 | func (ct *Contact) FriendPassVerify(c *gin.Context) { 106 | var req dto.FriendPassVerifyRequest 107 | resp := appx.NewResponse(c) 108 | if ok, err := appx.BindAndValid(c, &req); !ok || err != nil { 109 | resp.ToErrorResponse(errors.New("参数错误")) 110 | return 111 | } 112 | err := service.NewContactService(c).FriendPassVerify(req.SystemMessageID) 113 | if err != nil { 114 | resp.ToErrorResponse(err) 115 | return 116 | } 117 | resp.ToResponse(nil) 118 | } 119 | 120 | func (ct *Contact) FriendDelete(c *gin.Context) { 121 | var req dto.FriendDeleteRequest 122 | resp := appx.NewResponse(c) 123 | if ok, err := appx.BindAndValid(c, &req); !ok || err != nil { 124 | resp.ToErrorResponse(errors.New("参数错误")) 125 | return 126 | } 127 | err := service.NewContactService(c).FriendDelete(req.ContactID) 128 | if err != nil { 129 | resp.ToErrorResponse(err) 130 | return 131 | } 132 | resp.ToResponse(nil) 133 | } 134 | -------------------------------------------------------------------------------- /common_cron/word_cloud_daily.go: -------------------------------------------------------------------------------- 1 | package common_cron 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | "time" 10 | "wechat-robot-client/service" 11 | "wechat-robot-client/vars" 12 | ) 13 | 14 | type WordCloudDailyCron struct { 15 | CronManager *CronManager 16 | } 17 | 18 | func NewWordCloudDailyCron(cronManager *CronManager) vars.CommonCronInstance { 19 | return &WordCloudDailyCron{ 20 | CronManager: cronManager, 21 | } 22 | } 23 | 24 | func (cron *WordCloudDailyCron) IsActive() bool { 25 | if cron.CronManager.globalSettings.ChatRoomRankingEnabled != nil && *cron.CronManager.globalSettings.ChatRoomRankingEnabled { 26 | return true 27 | } 28 | return false 29 | } 30 | 31 | func (cron *WordCloudDailyCron) Cron() error { 32 | // 获取今天凌晨零点 33 | now := time.Now() 34 | todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) 35 | // 获取昨天凌晨零点 36 | yesterdayStart := todayStart.AddDate(0, 0, -1) 37 | // 转换为时间戳(秒) 38 | yesterdayStartTimestamp := yesterdayStart.Unix() 39 | todayStartTimestamp := todayStart.Unix() 40 | // 创建词云缓存目录 41 | wordCloudCacheDir := filepath.Join(string(filepath.Separator), "app", "word_cloud_cache") 42 | if err := os.MkdirAll(wordCloudCacheDir, 0755); err != nil { 43 | log.Printf("创建词云缓存目录失败: %v", err) 44 | return err 45 | } 46 | wcService := service.NewWordCloudService(context.Background()) 47 | globalSettings, err := service.NewGlobalSettingsService(context.Background()).GetGlobalSettings() 48 | if err != nil { 49 | log.Printf("获取全局设置失败: %v", err) 50 | return err 51 | } 52 | settings, err := service.NewChatRoomSettingsService(context.Background()).GetAllEnableChatRank() 53 | if err != nil { 54 | log.Printf("获取群聊设置失败: %v", err) 55 | return err 56 | } 57 | for _, setting := range settings { 58 | if setting == nil || setting.ChatRoomRankingEnabled == nil || !*setting.ChatRoomRankingEnabled { 59 | log.Printf("[词云] 群聊 %s 未开启群聊排行榜,跳过处理\n", setting.ChatRoomID) 60 | continue 61 | } 62 | // AI触发词,生成词云的时候,去掉这个无意义的触发词 63 | var aiTriggerWord string 64 | if globalSettings.ChatAITrigger != nil && *globalSettings.ChatAITrigger != "" { 65 | aiTriggerWord = *globalSettings.ChatAITrigger 66 | } 67 | if setting.ChatAITrigger != nil && *setting.ChatAITrigger != "" { 68 | aiTriggerWord = *setting.ChatAITrigger 69 | } 70 | imageData, err := wcService.WordCloudDaily(setting.ChatRoomID, aiTriggerWord, yesterdayStartTimestamp, todayStartTimestamp) 71 | if err != nil { 72 | log.Printf("[词云] 群聊 %s 生成词云失败: %v\n", setting.ChatRoomID, err) 73 | continue 74 | } 75 | if imageData == nil { 76 | log.Printf("[词云] 群聊 %s 生成了空图片,跳过处理\n", setting.ChatRoomID) 77 | continue 78 | } 79 | // 保存词云图片 80 | dateStr := yesterdayStart.Format("2006-01-02") 81 | filename := fmt.Sprintf("%s_%s.png", setting.ChatRoomID, dateStr) 82 | filePath := filepath.Join(wordCloudCacheDir, filename) 83 | if err := os.WriteFile(filePath, imageData, 0644); err != nil { 84 | log.Printf("[词云] 群聊 %s 保存词云图片失败: %v\n", setting.ChatRoomID, err) 85 | continue 86 | } 87 | log.Printf("[词云] 群聊 %s 词云图片已保存至: %s\n", setting.ChatRoomID, filePath) 88 | } 89 | return nil 90 | } 91 | 92 | func (cron *WordCloudDailyCron) Register() { 93 | if !cron.IsActive() { 94 | log.Println("每日词云任务未启用") 95 | return 96 | } 97 | if vars.WordCloudUrl == "" { 98 | log.Println("词云api地址未配置,无法执行每日词云任务") 99 | return 100 | } 101 | // 写死 5 0 * * * 102 | err := cron.CronManager.AddJob(vars.WordCloudDailyCron, "5 0 * * *", func() { 103 | log.Println("开始执行每日词云任务") 104 | if err := cron.Cron(); err != nil { 105 | log.Printf("每日词云任务执行失败: %v", err) 106 | } else { 107 | log.Println("每日词云任务执行完成") 108 | } 109 | }) 110 | if err != nil { 111 | log.Printf("每日词云任务注册失败: %v", err) 112 | return 113 | } 114 | log.Println("每日词云任务初始化成功") 115 | } 116 | -------------------------------------------------------------------------------- /model/oss_settings.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "gorm.io/datatypes" 4 | 5 | type AutoUploadMode string 6 | 7 | const ( 8 | AutoUploadModeAll AutoUploadMode = "all" // 全部上传 9 | AutoUploadModeAIOnly AutoUploadMode = "ai_only" 10 | ) 11 | 12 | type OSSProvider string 13 | 14 | const ( 15 | OSSProviderAliyun OSSProvider = "aliyun" // 阿里云 OSS 16 | OSSProviderTencentCloud OSSProvider = "tencent_cloud" // 腾讯云 COS 17 | OSSProviderCloudflare OSSProvider = "cloudflare" // Cloudflare R2 18 | ) 19 | 20 | type OSSSettings struct { 21 | ID uint64 `gorm:"column:id;primaryKey;autoIncrement;comment:表主键ID" json:"id"` 22 | AutoUploadImage *bool `gorm:"column:auto_upload_image;default:false;comment:启用自动上传图片" json:"auto_upload_image"` 23 | AutoUploadImageMode AutoUploadMode `gorm:"column:auto_upload_image_mode;type:enum('all','ai_only');default:'ai_only';not null;comment:自动上传图片模式" json:"auto_upload_image_mode"` 24 | AutoUploadVideo *bool `gorm:"column:auto_upload_video;default:false;comment:启用自动上传视频" json:"auto_upload_video"` 25 | AutoUploadVideoMode AutoUploadMode `gorm:"column:auto_upload_video_mode;type:enum('all','ai_only');default:'ai_only';not null;comment:自动上传视频模式" json:"auto_upload_video_mode"` 26 | AutoUploadFile *bool `gorm:"column:auto_upload_file;default:false;comment:启用自动上传文件" json:"auto_upload_file"` 27 | AutoUploadFileMode AutoUploadMode `gorm:"column:auto_upload_file_mode;type:enum('all','ai_only');default:'ai_only';not null;comment:自动上传文件模式" json:"auto_upload_file_mode"` 28 | OSSProvider OSSProvider `gorm:"column:oss_provider;type:enum('aliyun','tencent_cloud','cloudflare');default:'aliyun';not null;comment:对象存储服务商" json:"oss_provider"` 29 | AliyunOSSSettings datatypes.JSON `gorm:"column:aliyun_oss_settings;type:json;comment:阿里云OSS配置项" json:"aliyun_oss_settings"` 30 | TencentCloudOSSSettings datatypes.JSON `gorm:"column:tencent_cloud_oss_settings;type:json;comment:腾讯云OSS配置项" json:"tencent_cloud_oss_settings"` 31 | CloudflareR2Settings datatypes.JSON `gorm:"column:cloudflare_r2_settings;type:json;comment:Cloudflare R2配置项" json:"cloudflare_r2_settings"` 32 | CreatedAt int64 `gorm:"column:created_at;autoCreateTime;not null;comment:创建时间" json:"created_at"` 33 | UpdatedAt int64 `gorm:"column:updated_at;autoUpdateTime;not null;comment:更新时间" json:"updated_at"` 34 | } 35 | 36 | func (OSSSettings) TableName() string { 37 | return "oss_settings" 38 | } 39 | 40 | // AliyunOSSConfig 阿里云OSS配置 41 | type AliyunOSSConfig struct { 42 | Endpoint string `json:"endpoint"` // 访问域名 43 | AccessKeyID string `json:"access_key_id"` // AccessKey ID 44 | AccessKeySecret string `json:"access_key_secret"` // AccessKey Secret 45 | BucketName string `json:"bucket_name"` // 存储空间名称 46 | BasePath string `json:"base_path"` // 基础路径(可选) 47 | CustomDomain string `json:"custom_domain"` // 自定义域名(可选) 48 | } 49 | 50 | // TencentCloudCOSConfig 腾讯云COS配置 51 | type TencentCloudCOSConfig struct { 52 | Region string `json:"region"` // 地域 53 | SecretID string `json:"secret_id"` // SecretId 54 | SecretKey string `json:"secret_key"` // SecretKey 55 | BucketURL string `json:"bucket_url"` // 存储桶URL (格式: https://bucket-appid.cos.region.myqcloud.com) 56 | BasePath string `json:"base_path"` // 基础路径(可选) 57 | CustomDomain string `json:"custom_domain"` // 自定义域名(可选) 58 | } 59 | 60 | // CloudflareR2Config Cloudflare R2配置 61 | type CloudflareR2Config struct { 62 | AccountID string `json:"account_id"` // 账户ID 63 | AccessKeyID string `json:"access_key_id"` // Access Key ID 64 | SecretAccessKey string `json:"secret_access_key"` // Secret Access Key 65 | BucketName string `json:"bucket_name"` // 存储桶名称 66 | BasePath string `json:"base_path"` // 基础路径(可选) 67 | CustomDomain string `json:"custom_domain"` // 自定义域名(可选) 68 | } 69 | -------------------------------------------------------------------------------- /controller/mcp_server.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/gin-gonic/gin" 7 | 8 | "wechat-robot-client/model" 9 | "wechat-robot-client/pkg/appx" 10 | "wechat-robot-client/service" 11 | ) 12 | 13 | type MCPServer struct{} 14 | 15 | func NewMCPController() *MCPServer { 16 | return &MCPServer{} 17 | } 18 | 19 | func (s *MCPServer) GetMCPServers(c *gin.Context) { 20 | resp := appx.NewResponse(c) 21 | data, err := service.NewMCPServerService(c).GetMCPServers() 22 | if err != nil { 23 | resp.ToErrorResponse(err) 24 | return 25 | } 26 | resp.ToResponse(data) 27 | } 28 | 29 | func (s *MCPServer) GetMCPServer(c *gin.Context) { 30 | var req struct { 31 | ID uint64 `form:"id" json:"id" binding:"required"` 32 | } 33 | resp := appx.NewResponse(c) 34 | if ok, err := appx.BindAndValid(c, &req); !ok || err != nil { 35 | resp.ToErrorResponse(errors.New("参数错误")) 36 | return 37 | } 38 | data, err := service.NewMCPServerService(c).GetMCPServer(req.ID) 39 | if err != nil { 40 | resp.ToErrorResponse(err) 41 | return 42 | } 43 | resp.ToResponse(data) 44 | } 45 | 46 | func (s *MCPServer) CreateMCPServer(c *gin.Context) { 47 | var req model.MCPServer 48 | resp := appx.NewResponse(c) 49 | if ok, err := appx.BindAndValid(c, &req); !ok || err != nil { 50 | resp.ToErrorResponse(errors.New("参数错误")) 51 | return 52 | } 53 | err := service.NewMCPServerService(c).CreateMCPServer(&req) 54 | if err != nil { 55 | resp.ToErrorResponse(err) 56 | return 57 | } 58 | resp.ToResponse(nil) 59 | } 60 | 61 | func (s *MCPServer) UpdateMCPServer(c *gin.Context) { 62 | var req model.MCPServer 63 | resp := appx.NewResponse(c) 64 | if ok, err := appx.BindAndValid(c, &req); !ok || err != nil { 65 | resp.ToErrorResponse(errors.New("参数错误")) 66 | return 67 | } 68 | err := service.NewMCPServerService(c).UpdateMCPServer(&req) 69 | if err != nil { 70 | resp.ToErrorResponse(err) 71 | return 72 | } 73 | resp.ToResponse(nil) 74 | } 75 | 76 | func (s *MCPServer) EnableMCPServer(c *gin.Context) { 77 | var req struct { 78 | ID uint64 `json:"id" binding:"required"` 79 | } 80 | resp := appx.NewResponse(c) 81 | if ok, err := appx.BindAndValid(c, &req); !ok || err != nil { 82 | resp.ToErrorResponse(errors.New("参数错误")) 83 | return 84 | } 85 | err := service.NewMCPServerService(c).EnableMCPServer(req.ID) 86 | if err != nil { 87 | resp.ToErrorResponse(err) 88 | return 89 | } 90 | resp.ToResponse(nil) 91 | } 92 | 93 | func (s *MCPServer) DisableMCPServer(c *gin.Context) { 94 | var req struct { 95 | ID uint64 `json:"id" binding:"required"` 96 | } 97 | resp := appx.NewResponse(c) 98 | if ok, err := appx.BindAndValid(c, &req); !ok || err != nil { 99 | resp.ToErrorResponse(errors.New("参数错误")) 100 | return 101 | } 102 | err := service.NewMCPServerService(c).DisableMCPServer(req.ID) 103 | if err != nil { 104 | resp.ToErrorResponse(err) 105 | return 106 | } 107 | resp.ToResponse(nil) 108 | } 109 | 110 | func (s *MCPServer) DeleteMCPServer(c *gin.Context) { 111 | var req model.MCPServer 112 | resp := appx.NewResponse(c) 113 | if ok, err := appx.BindAndValid(c, &req); !ok || err != nil { 114 | resp.ToErrorResponse(errors.New("参数错误")) 115 | return 116 | } 117 | err := service.NewMCPServerService(c).DeleteMCPServer(&req) 118 | if err != nil { 119 | resp.ToErrorResponse(err) 120 | return 121 | } 122 | resp.ToResponse(nil) 123 | } 124 | 125 | func (s *MCPServer) GetMCPServerTools(c *gin.Context) { 126 | var req struct { 127 | ID uint64 `form:"id" json:"id" binding:"required"` 128 | } 129 | resp := appx.NewResponse(c) 130 | if ok, err := appx.BindAndValid(c, &req); !ok || err != nil { 131 | resp.ToErrorResponse(errors.New("参数错误")) 132 | return 133 | } 134 | data, err := service.NewMCPServerService(c).GetMCPServerTools(req.ID) 135 | if err != nil { 136 | resp.ToErrorResponse(err) 137 | return 138 | } 139 | resp.ToResponse(data) 140 | } 141 | -------------------------------------------------------------------------------- /plugin/pkg/doubao_tts.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "time" 10 | 11 | "github.com/google/uuid" 12 | ) 13 | 14 | type DoubaoTTSConfig struct { 15 | BaseURL string `json:"base_url"` 16 | AccessToken string `json:"access_token"` 17 | DoubaoTTSRequest 18 | } 19 | 20 | type DoubaoTTSRequest struct { 21 | App AppConfig `json:"app"` 22 | User UserConfig `json:"user"` 23 | Audio AudioConfig `json:"audio"` 24 | Request RequestConfig `json:"request"` 25 | } 26 | 27 | type AppConfig struct { 28 | AppID string `json:"appid"` 29 | Token string `json:"token"` 30 | Cluster string `json:"cluster"` 31 | } 32 | 33 | type UserConfig struct { 34 | UID string `json:"uid"` 35 | } 36 | 37 | type AudioConfig struct { 38 | VoiceType string `json:"voice_type"` 39 | Encoding string `json:"encoding"` 40 | CompressionRate int `json:"compression_rate"` 41 | Rate int `json:"rate"` 42 | SpeedRatio float64 `json:"speed_ratio"` 43 | VolumeRatio float64 `json:"volume_ratio"` 44 | PitchRatio float64 `json:"pitch_ratio"` 45 | Emotion string `json:"emotion"` 46 | Language string `json:"language"` 47 | } 48 | 49 | type RequestConfig struct { 50 | ReqID string `json:"reqid"` 51 | Text string `json:"text"` 52 | TextType string `json:"text_type"` 53 | Operation string `json:"operation"` 54 | SilenceDuration string `json:"silence_duration"` 55 | WithFrontend string `json:"with_frontend"` 56 | FrontendType string `json:"frontend_type"` 57 | PureEnglishOpt string `json:"pure_english_opt"` 58 | } 59 | 60 | type DoubaoTTSResponse struct { 61 | ReqID string `json:"reqid"` 62 | Code int `json:"code"` 63 | Operation string `json:"operation"` 64 | Message string `json:"message"` 65 | Sequence int `json:"sequence"` 66 | Data string `json:"data"` 67 | Addition Addition `json:"addition"` 68 | } 69 | 70 | type Addition struct { 71 | Description string `json:"description"` 72 | Duration string `json:"duration"` 73 | Frontend string `json:"frontend"` 74 | } 75 | 76 | func DoubaoTTSSubmit(config *DoubaoTTSConfig) (string, error) { 77 | if config.App.AppID == "" { 78 | return "", fmt.Errorf("应用ID不能为空") 79 | } 80 | if config.AccessToken == "" { 81 | return "", fmt.Errorf("未找到语音合成密钥") 82 | } 83 | 84 | config.App.Token = uuid.NewString() 85 | config.App.Cluster = "volcano_tts" 86 | config.User.UID = uuid.NewString() 87 | config.Audio.Encoding = "mp3" 88 | config.Request.ReqID = uuid.NewString() 89 | config.Request.Operation = "query" 90 | config.Request.TextType = "plain" 91 | 92 | // 准备请求体 93 | requestBody, err := json.Marshal(config.DoubaoTTSRequest) 94 | if err != nil { 95 | return "", fmt.Errorf("序列化请求体失败: %v", err) 96 | } 97 | // 创建HTTP请求 98 | req, err := http.NewRequest("POST", config.BaseURL, bytes.NewBuffer(requestBody)) 99 | if err != nil { 100 | return "", fmt.Errorf("创建请求失败: %v", err) 101 | } 102 | // 设置请求头 103 | req.Header.Set("Content-Type", "application/json") 104 | req.Header.Set("Authorization", fmt.Sprintf("Bearer; %s", config.AccessToken)) 105 | 106 | // 发送请求 107 | client := &http.Client{Timeout: 300 * time.Second} 108 | resp, err := client.Do(req) 109 | if err != nil { 110 | return "", fmt.Errorf("发送请求失败: %v", err) 111 | } 112 | defer resp.Body.Close() 113 | // 读取响应 114 | body, err := io.ReadAll(resp.Body) 115 | if err != nil { 116 | return "", fmt.Errorf("读取响应失败: %v", err) 117 | } 118 | // 检查HTTP状态码 119 | if resp.StatusCode != http.StatusOK { 120 | return "", fmt.Errorf("API请求失败,状态码 %d: %s", resp.StatusCode, string(body)) 121 | } 122 | // 解析响应 123 | var ttsResp DoubaoTTSResponse 124 | if err := json.Unmarshal(body, &ttsResp); err != nil { 125 | return "", fmt.Errorf("解析响应失败: %v", err) 126 | } 127 | if ttsResp.Message != "Success" { 128 | return "", fmt.Errorf("合成失败: %s", ttsResp.Message) 129 | } 130 | return ttsResp.Data, nil 131 | } 132 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module wechat-robot-client 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.3 6 | 7 | require ( 8 | github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible 9 | github.com/aws/aws-sdk-go-v2 v1.39.2 10 | github.com/aws/aws-sdk-go-v2/config v1.31.12 11 | github.com/aws/aws-sdk-go-v2/credentials v1.18.16 12 | github.com/aws/aws-sdk-go-v2/service/s3 v1.88.3 13 | github.com/gin-gonic/gin v1.10.0 14 | github.com/go-co-op/gocron v1.37.0 15 | github.com/go-playground/validator/v10 v10.20.0 16 | github.com/go-resty/resty/v2 v2.16.5 17 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 18 | github.com/google/uuid v1.6.0 19 | github.com/h2non/filetype v1.1.3 20 | github.com/joho/godotenv v1.5.1 21 | github.com/modelcontextprotocol/go-sdk v1.1.0 22 | github.com/redis/go-redis/v9 v9.10.0 23 | github.com/sashabaranov/go-openai v1.41.2 24 | github.com/tencentyun/cos-go-sdk-v5 v0.7.70 25 | golang.org/x/time v0.12.0 26 | gorm.io/datatypes v1.2.5 27 | gorm.io/driver/mysql v1.5.7 28 | gorm.io/gorm v1.25.12 29 | ) 30 | 31 | require ( 32 | filippo.io/edwards25519 v1.1.0 // indirect 33 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 // indirect 34 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9 // indirect 35 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9 // indirect 36 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9 // indirect 37 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect 38 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.9 // indirect 39 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect 40 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.9 // indirect 41 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9 // indirect 42 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.9 // indirect 43 | github.com/aws/aws-sdk-go-v2/service/sso v1.29.6 // indirect 44 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1 // indirect 45 | github.com/aws/aws-sdk-go-v2/service/sts v1.38.6 // indirect 46 | github.com/aws/smithy-go v1.23.0 // indirect 47 | github.com/bytedance/sonic v1.11.6 // indirect 48 | github.com/bytedance/sonic/loader v0.1.1 // indirect 49 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 50 | github.com/clbanning/mxj v1.8.4 // indirect 51 | github.com/cloudwego/base64x v0.1.4 // indirect 52 | github.com/cloudwego/iasm v0.2.0 // indirect 53 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 54 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect 55 | github.com/gin-contrib/sse v0.1.0 // indirect 56 | github.com/go-playground/locales v0.14.1 // indirect 57 | github.com/go-playground/universal-translator v0.18.1 // indirect 58 | github.com/go-sql-driver/mysql v1.8.1 // indirect 59 | github.com/goccy/go-json v0.10.2 // indirect 60 | github.com/google/go-querystring v1.0.0 // indirect 61 | github.com/google/jsonschema-go v0.3.0 // indirect 62 | github.com/jinzhu/inflection v1.0.0 // indirect 63 | github.com/jinzhu/now v1.1.5 // indirect 64 | github.com/json-iterator/go v1.1.12 // indirect 65 | github.com/klauspost/cpuid/v2 v2.2.7 // indirect 66 | github.com/leodido/go-urn v1.4.0 // indirect 67 | github.com/mattn/go-isatty v0.0.20 // indirect 68 | github.com/mitchellh/mapstructure v1.4.3 // indirect 69 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 70 | github.com/modern-go/reflect2 v1.0.2 // indirect 71 | github.com/mozillazg/go-httpheader v0.2.1 // indirect 72 | github.com/pelletier/go-toml/v2 v2.2.2 // indirect 73 | github.com/robfig/cron/v3 v3.0.1 // indirect 74 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 75 | github.com/ugorji/go/codec v1.2.12 // indirect 76 | github.com/yosida95/uritemplate/v3 v3.0.2 // indirect 77 | go.uber.org/atomic v1.9.0 // indirect 78 | golang.org/x/arch v0.8.0 // indirect 79 | golang.org/x/crypto v0.31.0 // indirect 80 | golang.org/x/image v0.27.0 // indirect 81 | golang.org/x/net v0.33.0 // indirect 82 | golang.org/x/oauth2 v0.33.0 // indirect 83 | golang.org/x/sys v0.28.0 // indirect 84 | golang.org/x/text v0.25.0 // indirect 85 | google.golang.org/protobuf v1.34.1 // indirect 86 | gopkg.in/yaml.v3 v3.0.1 // indirect 87 | ) 88 | -------------------------------------------------------------------------------- /plugin/plugins/chatroom_chat.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "log" 5 | "wechat-robot-client/interface/plugin" 6 | "wechat-robot-client/service" 7 | ) 8 | 9 | type ChatRoomAIChatSessionStartPlugin struct{} 10 | 11 | func NewChatRoomAIChatSessionStartPlugin() plugin.MessageHandler { 12 | return &ChatRoomAIChatSessionStartPlugin{} 13 | } 14 | 15 | func (p *ChatRoomAIChatSessionStartPlugin) GetName() string { 16 | return "ChatRoomAIChatSessionStart" 17 | } 18 | 19 | func (p *ChatRoomAIChatSessionStartPlugin) GetLabels() []string { 20 | return []string{"chat"} 21 | } 22 | 23 | func (p *ChatRoomAIChatSessionStartPlugin) PreAction(ctx *plugin.MessageContext) bool { 24 | return true 25 | } 26 | 27 | func (p *ChatRoomAIChatSessionStartPlugin) PostAction(ctx *plugin.MessageContext) { 28 | 29 | } 30 | 31 | func (p *ChatRoomAIChatSessionStartPlugin) Run(ctx *plugin.MessageContext) bool { 32 | if !ctx.Message.IsChatRoom { 33 | return false 34 | } 35 | aiChatService := service.NewAIChatService(ctx.Context, ctx.Settings) 36 | aiDrawingService := service.NewAIDrawingService(ctx.Context, ctx.Settings) 37 | if aiChatService.IsAISessionStart(ctx.Message) { 38 | ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, aiChatService.GetAISessionStartTips(), ctx.Message.SenderWxID) 39 | // 是闲聊,则结束绘画上下文,如果有的话 40 | err := aiDrawingService.ExpireAISession(ctx.Message) 41 | if err != nil { 42 | log.Println("结束绘画会话失败:", err) 43 | } 44 | // 重置一下会话上下文 45 | err = ctx.MessageService.ResetChatRoomAIMessageContext(ctx.Message) 46 | if err != nil { 47 | log.Println("重置会话上下文失败:", err) 48 | } 49 | return true 50 | } 51 | return false 52 | } 53 | 54 | type ChatRoomAIChatSessionEndPlugin struct{} 55 | 56 | func NewChatRoomAIChatSessionEndPlugin() plugin.MessageHandler { 57 | return &ChatRoomAIChatSessionEndPlugin{} 58 | } 59 | 60 | func (p *ChatRoomAIChatSessionEndPlugin) GetName() string { 61 | return "ChatRoomAIChatSessionEnd" 62 | } 63 | 64 | func (p *ChatRoomAIChatSessionEndPlugin) GetLabels() []string { 65 | return []string{"chat"} 66 | } 67 | 68 | func (p *ChatRoomAIChatSessionEndPlugin) PreAction(ctx *plugin.MessageContext) bool { 69 | return true 70 | } 71 | 72 | func (p *ChatRoomAIChatSessionEndPlugin) PostAction(ctx *plugin.MessageContext) { 73 | 74 | } 75 | 76 | func (p *ChatRoomAIChatSessionEndPlugin) Run(ctx *plugin.MessageContext) bool { 77 | if !ctx.Message.IsChatRoom { 78 | return false 79 | } 80 | aiChatService := service.NewAIChatService(ctx.Context, ctx.Settings) 81 | if aiChatService.IsAISessionEnd(ctx.Message) { 82 | ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, aiChatService.GetAISessionEndTips(), ctx.Message.SenderWxID) 83 | return true 84 | } 85 | return false 86 | } 87 | 88 | type ChatRoomAIChatPlugin struct{} 89 | 90 | func NewChatRoomAIChatPlugin() plugin.MessageHandler { 91 | return &ChatRoomAIChatPlugin{} 92 | } 93 | 94 | func (p *ChatRoomAIChatPlugin) GetName() string { 95 | return "ChatRoomAIChat" 96 | } 97 | 98 | func (p *ChatRoomAIChatPlugin) GetLabels() []string { 99 | return []string{"text", "chat"} 100 | } 101 | 102 | func (p *ChatRoomAIChatPlugin) PreAction(ctx *plugin.MessageContext) bool { 103 | return true 104 | } 105 | 106 | func (p *ChatRoomAIChatPlugin) PostAction(ctx *plugin.MessageContext) { 107 | 108 | } 109 | 110 | func (p *ChatRoomAIChatPlugin) Run(ctx *plugin.MessageContext) bool { 111 | if !ctx.Message.IsChatRoom { 112 | return false 113 | } 114 | aiChatService := service.NewAIChatService(ctx.Context, ctx.Settings) 115 | isInSession, err := aiChatService.IsInAISession(ctx.Message) 116 | if err != nil { 117 | log.Printf("检查AI会话失败: %v", err) 118 | return true 119 | } 120 | isAIEnabled := ctx.Settings.IsAIChatEnabled() 121 | isAITrigger := ctx.Settings.IsAITrigger() 122 | if isAIEnabled { 123 | if isAITrigger || isInSession { 124 | defer func() { 125 | aiChatService.RenewAISession(ctx.Message) 126 | err := ctx.MessageService.SetMessageIsInContext(ctx.Message) 127 | if err != nil { 128 | log.Printf("更新消息上下文失败: %v", err) 129 | } 130 | }() 131 | aiChat := NewAIChatPlugin() 132 | aiChat.Run(ctx) 133 | return true 134 | } 135 | } 136 | return false 137 | } 138 | -------------------------------------------------------------------------------- /controller/attach_download.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "net/http" 8 | "wechat-robot-client/dto" 9 | "wechat-robot-client/pkg/appx" 10 | "wechat-robot-client/service" 11 | 12 | "github.com/gin-gonic/gin" 13 | ) 14 | 15 | type AttachDownload struct { 16 | } 17 | 18 | func NewAttachDownloadController() *AttachDownload { 19 | return &AttachDownload{} 20 | } 21 | 22 | func (a *AttachDownload) DownloadImage(c *gin.Context) { 23 | var req dto.AttachDownloadRequest 24 | if ok, err := appx.BindAndValid(c, &req); !ok || err != nil { 25 | c.JSON(http.StatusInternalServerError, gin.H{ 26 | "message": "参数错误", 27 | }) 28 | return 29 | } 30 | imageData, contentType, extension, err := service.NewAttachDownloadService(c).DownloadImage(req.MessageID) 31 | if err != nil { 32 | c.JSON(http.StatusInternalServerError, gin.H{ 33 | "message": err.Error(), 34 | }) 35 | return 36 | } 37 | 38 | // 设置响应头 39 | c.Header("Content-Type", contentType) 40 | c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%d%s\"", req.MessageID, extension)) 41 | c.Header("Content-Length", fmt.Sprintf("%d", len(imageData))) 42 | 43 | // 将图片数据写入响应 44 | c.Writer.WriteHeader(http.StatusOK) 45 | _, err = c.Writer.Write(imageData) 46 | if err != nil { 47 | // 这里已经开始写入响应,无法再更改状态码,只能记录错误 48 | fmt.Printf("返回图片数据失败: %v\n", err) 49 | } 50 | } 51 | 52 | func (a *AttachDownload) DownloadVoice(c *gin.Context) { 53 | var req dto.AttachDownloadRequest 54 | if ok, err := appx.BindAndValid(c, &req); !ok || err != nil { 55 | c.JSON(http.StatusInternalServerError, gin.H{ 56 | "message": "参数错误", 57 | }) 58 | return 59 | } 60 | voiceData, contentType, extension, err := service.NewAttachDownloadService(c).DownloadVoice(req) 61 | if err != nil { 62 | c.JSON(http.StatusInternalServerError, gin.H{ 63 | "message": err.Error(), 64 | }) 65 | return 66 | } 67 | 68 | // 设置响应头 69 | c.Header("Content-Type", contentType) 70 | c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%d%s\"", req.MessageID, extension)) 71 | c.Header("Content-Length", fmt.Sprintf("%d", len(voiceData))) 72 | 73 | // 将语音数据写入响应 74 | c.Writer.WriteHeader(http.StatusOK) 75 | _, err = c.Writer.Write(voiceData) 76 | if err != nil { 77 | // 这里已经开始写入响应,无法再更改状态码,只能记录错误 78 | fmt.Printf("返回语音数据失败: %v\n", err) 79 | } 80 | } 81 | 82 | func (a *AttachDownload) DownloadFile(c *gin.Context) { 83 | var req dto.AttachDownloadRequest 84 | if ok, err := appx.BindAndValid(c, &req); !ok || err != nil { 85 | c.JSON(http.StatusInternalServerError, gin.H{ 86 | "message": "参数错误", 87 | }) 88 | return 89 | } 90 | 91 | reader, filename, err := service.NewAttachDownloadService(c).DownloadFile(req.MessageID) 92 | if err != nil { 93 | c.AbortWithStatusJSON(http.StatusInternalServerError, 94 | gin.H{"message": err.Error()}) 95 | return 96 | } 97 | defer reader.Close() 98 | 99 | // 写响应头,采用 chunked-encoding;无需提前知道 Content-Length 100 | c.Header("Content-Disposition", 101 | fmt.Sprintf("attachment; filename=\"%s\"", filename)) 102 | c.Header("Content-Type", "application/octet-stream") 103 | c.Status(http.StatusOK) 104 | 105 | if _, err = io.Copy(c.Writer, reader); err != nil { 106 | log.Printf("stream copy error: %v", err) 107 | } 108 | } 109 | 110 | func (a *AttachDownload) DownloadVideo(c *gin.Context) { 111 | var req dto.AttachDownloadRequest 112 | if ok, err := appx.BindAndValid(c, &req); !ok || err != nil { 113 | c.JSON(http.StatusInternalServerError, gin.H{ 114 | "message": "参数错误", 115 | }) 116 | return 117 | } 118 | 119 | reader, filename, err := service.NewAttachDownloadService(c).DownloadVideo(req) 120 | if err != nil { 121 | c.AbortWithStatusJSON(http.StatusInternalServerError, 122 | gin.H{"message": err.Error()}) 123 | return 124 | } 125 | defer reader.Close() 126 | 127 | // 写响应头,采用 chunked-encoding;无需提前知道 Content-Length 128 | c.Header("Content-Disposition", 129 | fmt.Sprintf("attachment; filename=\"%s\"", filename)) 130 | c.Header("Content-Type", "application/octet-stream") 131 | c.Status(http.StatusOK) 132 | 133 | if _, err = io.Copy(c.Writer, reader); err != nil { 134 | log.Printf("stream copy error: %v", err) 135 | } 136 | } 137 | --------------------------------------------------------------------------------