├── .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 |
--------------------------------------------------------------------------------