diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 0000000..b2b1091 --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,61 @@ +# This is a basic workflow to help you get started with Actions + +name: build docker image + +# Controls when the action will run. +on: + push: + branches: + - main + +# Allows you to run this workflow manually from the Actions tab + # 可以手动触发 + workflow_dispatch: + inputs: + logLevel: + description: 'Log level' + required: true + default: 'warning' + tags: + description: 'Test scenario tags' + +jobs: + buildx: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Get current date + id: date + run: echo "::set-output name=today::$(date +'%Y-%m-%d_%H-%M')" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v1 + + - name: Available platforms + run: echo ${{ steps.buildx.outputs.platforms }} + + - name: Login to DockerHub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v2 + with: + context: . + file: ./Dockerfile + # 所需要的体系结构,可以在 Available platforms 步骤中获取所有的可用架构 + platforms: linux/amd64,linux/arm64/v8 + # 镜像推送时间 + push: ${{ github.event_name != 'pull_request' }} + # 给清单打上多个标签 + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/wechatbot:${{ steps.date.outputs.today }} + ${{ secrets.DOCKERHUB_USERNAME }}/wechatbot:latest \ No newline at end of file diff --git a/.github/workflows/go-release.yml b/.github/workflows/go-release.yml new file mode 100644 index 0000000..f7fbd55 --- /dev/null +++ b/.github/workflows/go-release.yml @@ -0,0 +1,26 @@ +name: build + +on: + release: + types: [created] # 表示在创建新的 Release 时触发 + +jobs: + build-go-binary: + runs-on: ubuntu-latest + strategy: + matrix: + goos: [linux, windows, darwin] # 需要打包的系统 + goarch: [amd64, arm64] # 需要打包的架构 + exclude: # 排除某些平台和架构 + - goarch: arm64 + goos: windows + steps: + - uses: actions/checkout@v3 + - uses: wangyoucao577/go-release-action@v1.30 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} # 一个默认的变量,用来实现往 Release 中添加文件 + goos: ${{ matrix.goos }} + goarch: ${{ matrix.goarch }} + goversion: 1.16 # 可以指定编译使用的 Golang 版本 + binary_name: "wechatbot" # 可以指定二进制文件的名称 + extra_files: README.md config.dev.json # 需要包含的额外文件 \ No newline at end of file diff --git a/.gitignore b/.gitignore index caccd51..f7dea7e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ *.so *.dylib .idea/ +.vscode/ +wechatbot storage.json # Test binary, built with `go test -c` @@ -15,3 +17,4 @@ storage.json # Dependency directories (remove the comment below to include it) # vendor/ +/config.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6523b78 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,65 @@ +#FROM golang:1.17.10 AS builder +# +## ENV GOPROXY https://goproxy.io +# +#RUN mkdir /app +#ADD . /app/ +#WORKDIR /app +#RUN go build -o wechatbot . +# +#FROM centos:centos7 +#RUN mkdir /app +#WORKDIR /app +#COPY --from=builder /app/ . +#RUN chmod +x wechatbot && cp config.dev.json config.json && yum -y install vim net-tools telnet wget curl && yum clean all +# +#CMD ./wechatbot + +# wechatbot/Dockerfile + +# 使用 golang 官方镜像提供 Go 运行环境,并且命名为 buidler 以便后续引用 +FROM golang:1.16-alpine as builder + +# 启用 Go Modules 并设置 GOPROXY +ENV GO111MODULE on +ENV GOPROXY https://goproxy.cn + +# 更新安装源 +RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories + +# 安装 git +RUN apk --no-cache add git + +# 设置工作目录 +WORKDIR /app + +# 将当前项目所在目录代码拷贝到镜像中 +COPY . . + +# 下载依赖 +RUN go mod download + +# 构建二进制文件,添加来一些额外参数以便可以在 Alpine 中运行它 +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o wechatbot + +# 下面是第二阶段的镜像构建,和之前保持一致 +FROM alpine:latest + +# 更新安装源 +RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories + +# 安装相关软件 +RUN apk update && apk add --no-cache bash supervisor ca-certificates + +# 和上个阶段一样设置工作目录 +RUN mkdir /app +WORKDIR /app + +# 而是从上一个阶段构建的 builder容器中拉取 +COPY --from=builder /app/wechatbot . +ADD supervisord.conf /etc/supervisord.conf +ADD config.dev.json /app/config.dev.json +RUN cp config.dev.json config.json + +# 通过 Supervisor 管理服务 +CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a1a8300 --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +.PHONY: build +build: + CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags '-w' -o wechatbot ./main.go + +.PHONY: docker +docker: + docker build . -t wechatbot:latest diff --git a/README.md b/README.md index cc69d9b..840d6e7 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,166 @@ # wechatbot -最近chatGPT异常火爆,想到将其接入到个人微信是件比较有趣的事,所以有了这个项目。项目基于[openwechat](https://github.com/eatmoreapple/openwechat) -开发 -###目前实现了以下功能 - + 群聊@回复 - + 私聊回复 - + 自动通过回复 - -# 注册openai -chatGPT注册可以参考[这里](https://juejin.cn/post/7173447848292253704) - -# 安装使用 + +> 本项目是 fork 他人的项目来进行学习和使用,请勿商用,可以下载下来做自定义的功能 +> 最近ChatGPT异常火爆,本项目可以将个人微信化身GPT机器人, +> 项目基于[openwechat](https://github.com/eatmoreapple/openwechat) 开发。 + +> `友链:`[chatgpt-dingtalk](https://github.com/eryajf/chatgpt-dingtalk) 本项目可以将GPT机器人集成到钉钉群聊中。 + + +### 目前实现了以下功能 + +* GPT机器人模型热度可配置 +* 提问增加上下文 +* 指令清空上下文(指令:根据配置) +* 机器人群聊@回复 +* 机器人私聊回复 +* 私聊回复前缀设置 +* 好友添加自动通过可配置 +* ~~增加每天工作的起始时间和结束时间,只有在该时间段才会对外提供 chatgpt 服务~~ +* ~~增加 vip 用户在任意时段都可享受 chatgpt 服务,只需要在 \wechatbot\handlers\group_msg_handler.go 中 的 VipUserList 切片中, +加入具体的 vip 昵称~~ + +# 实现机制 +目前机器人有两种实现方式 +* 逆向功能,扒取官网API,通过抓取cookie获取GPT响应信息,`优点:`效果与官网一致,`缺点:`cookie会过期需要不定时更新。 +* 基于openai官网提供的API,`优点`:模型以及各种参数可以自由配置,`缺点:`效果达不到官网智能,且API收费,新账号有18美元免费额度。 + +> 本项目基于第二种方式实现,模型之间具体差异可以参考[官方文档](https://beta.openai.com/docs/models/overview), 详细[参数示例](https://beta.openai.com/examples) 。 + +# 常见问题 +* 如无法登录 login error: write storage.json: bad file descriptor 删除掉storage.json文件重新登录。 +* 如无法登录 login error: wechat network error: Get "https://wx.qq.com/cgi-bin/mmwebwx-bin/webwxnewloginpage": 301 response missing Location header 一般是微信登录权限问题,先确保PC端能否正常登录。 +* 其他无法登录问题,依然尝试删除掉storage.json文件,结束进程(linux一般是kill -9 进程id)之后重启程序,重新扫码登录,(如为docket部署,Supervisord进程管理工具会自动重启程序)。 +* ~~机器人无法正常回复,检查ApiKey能否正常使用,控制台日志中有详细错误信息~~ 新版本会机器人会直接输出,因为被问得好烦了。 +* linux中二维码无法扫描,缩小命令行功能,让二维码像素尽可能清晰。(无法从代码层面解决) +* 机器人一直答非所问,可能因为上下文累积过多。切换不同问题时,发送指令:启动时配置的`session_clear_token`字段。会清空上下文 + +# 使用前提 + +> * ~~目前只支持在windows上运行因为需要弹窗扫码登录微信,后续会支持linux~~ 已支持 +> * 有openai账号,并且创建好api_key,注册事项可以参考[此文章](https://juejin.cn/post/7173447848292253704) 。 +> * 应用可以参考这篇文章 [此文章](https://juejin.cn/post/7176813187705077816) 。 +> * 微信必须实名认证。 + +# 注意事项 + +> * 项目仅供娱乐,滥用可能有微信封禁的风险,请勿用于商业用途。 +> * 请注意收发敏感信息,本项目不做信息过滤。 + +# 使用docker运行 + +你可以使用docker快速运行本项目。 + +`第一种:基于环境变量运行` + +```sh +# 运行项目,环境变量参考下方配置说明 +$ docker run -itd --name wechatbot --restart=always \ + -e APIKEY=换成你的key \ + -e AUTO_PASS=false \ + -e SESSION_TIMEOUT=60s \ + -e MODEL=text-davinci-003 \ + -e MAX_TOKENS=512 \ + -e TEMPREATURE=0.9 \ + -e REPLY_PREFIX=我是来自机器人回复: \ + -e SESSION_CLEAR_TOKEN=下一个问题 \ + docker.mirrors.sjtug.sjtu.edu.cn/qingshui869413421/wechatbot:latest + +# 查看二维码 +$ docker exec -it wechatbot bash +$ tail -f -n 50 /app/run.log +``` + +运行命令中映射的配置文件参考下边的配置文件说明。 + +`第二种:基于配置文件挂载运行` + +```sh +# 复制配置文件,根据自己实际情况,调整配置里的内容 +$ cp config.dev.json config.json # 其中 config.dev.json 从项目的根目录获取 + +# 运行项目 +$ docker run -itd --name wechatbot -v `pwd`/config.json:/app/config.json docker.mirrors.sjtug.sjtu.edu.cn/qingshui869413421/wechatbot:latest + +# 查看二维码 +$ docker exec -it wechatbot bash +$ tail -f -n 50 /app/run.log +``` + +其中配置文件参考下边的配置文件说明。 + +# 快速开始 + +`第一种:直接下载二进制(适合对编程不了解的同学)` + +> 非技术人员请直接下载release中的[压缩包](https://github.com/869413421/wechatbot/releases) ,请根据自己系统以及架构选择合适的压缩包,下载之后直接解压运行。 + +下载之后,在本地解压,即可看到可执行程序,与配置文件: + +``` +# windows +1.下载压缩包解压 +2.复制文件中config.dev.json更改为config.json +3.将config.json中的api_key替换为自己的 +4.双击exe,扫码登录 + +# linux +$ tar xf wechatbot-v0.0.2-darwin-arm64.tar.gz +$ cd wechatbot-v0.0.2-darwin-arm64 +$ cp config.dev.json # 根据情况调整配置文件内容 +$ ./wechatbot # 直接运行 + +# 如果要守护在后台运行 +$ nohup ./wechatbot &> run.log & +$ tail -f run.log +``` + +`第二种:基于源码运行(适合了解go语言编程的同学)` + ```` # 获取项目 -git clone https://github.com/869413421/wechatbot.git +$ git clone https://github.com/869413421/wechatbot.git # 进入项目目录 -cd wechatbot +$ cd wechatbot # 复制配置文件 -copy config.dev.json config.json +$ copy config.dev.json config.json # 启动项目 -go run main.go +$ go run main.go +```` + +# 配置文件说明 + +```` +{ + "api_key": "your api key", + "auto_pass": true, + "session_timeout": 60, + "max_tokens": 1024, + "model": "text-davinci-003", + "temperature": 1, + "reply_prefix": "来自机器人回复:", + "session_clear_token": "清空会话" +} + +api_key:openai api_key +auto_pass:是否自动通过好友添加 +session_timeout:会话超时时间,默认60秒,单位秒,在会话时间内所有发送给机器人的信息会作为上下文。 +max_tokens: GPT响应字符数,最大2048,默认值512。max_tokens会影响接口响应速度,字符越大响应越慢。 +model: GPT选用模型,默认text-davinci-003,具体选项参考官网训练场 +temperature: GPT热度,0到1,默认0.9。数字越大创造力越强,但更偏离训练事实,越低越接近训练事实 +reply_prefix: 私聊回复前缀 +session_clear_token: 会话清空口令,默认`下一个问题` +```` + +# 使用示例 +### 私聊 + + + +### 群聊@回复 + + -启动前需替换config中的api_key diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index 7049740..7a3166f 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -1,31 +1,35 @@ package bootstrap import ( - "github.com/869413421/wechatbot/handlers" + "fmt" + "github.com/qingconglaixueit/wechatbot/handlers" + "github.com/qingconglaixueit/wechatbot/pkg/logger" "github.com/eatmoreapple/openwechat" - "log" ) - - func Run() { //bot := openwechat.DefaultBot() bot := openwechat.DefaultBot(openwechat.Desktop) // 桌面模式,上面登录不上的可以尝试切换这种模式 // 注册消息处理函数 - bot.MessageHandler = handlers.Handler + handler, err := handlers.NewHandler() + if err != nil { + logger.Danger("register error: %v", err) + return + } + bot.MessageHandler = handler + // 注册登陆二维码回调 - bot.UUIDCallback = openwechat.PrintlnQrcodeUrl + bot.UUIDCallback = handlers.QrCodeCallBack // 创建热存储容器对象 reloadStorage := openwechat.NewJsonFileHotReloadStorage("storage.json") + // 执行热登录 - err := bot.HotLogin(reloadStorage) + err = bot.HotLogin(reloadStorage, true) if err != nil { - if err = bot.Login(); err != nil { - log.Printf("login error: %v \n", err) - return - } + logger.Warning(fmt.Sprintf("login error: %v ", err)) + return } // 阻塞主goroutine, 直到发生异常或者用户主动退出 bot.Block() diff --git a/config.dev.json b/config.dev.json index b78e4db..84bf81c 100644 --- a/config.dev.json +++ b/config.dev.json @@ -1,4 +1,10 @@ { "api_key": "your api key", - "auto_pass": true + "auto_pass": true, + "session_timeout": 60, + "max_tokens": 1024, + "model": "text-davinci-003", + "temperature": 1, + "reply_prefix": "来自机器人回复:", + "session_clear_token": "清空会话" } diff --git a/config/config.go b/config/config.go index 6afede1..25b916a 100644 --- a/config/config.go +++ b/config/config.go @@ -2,17 +2,33 @@ package config import ( "encoding/json" + "fmt" + "github.com/qingconglaixueit/wechatbot/pkg/logger" "log" "os" + "strconv" "sync" + "time" ) // Configuration 项目配置 type Configuration struct { - // gtp apikey + // gpt apikey ApiKey string `json:"api_key"` // 自动通过好友 AutoPass bool `json:"auto_pass"` + // 会话超时时间 + SessionTimeout time.Duration `json:"session_timeout"` + // GPT请求最大字符数 + MaxTokens uint `json:"max_tokens"` + // GPT模型 + Model string `json:"model"` + // 热度 + Temperature float64 `json:"temperature"` + // 回复前缀 + ReplyPrefix string `json:"reply_prefix"` + // 清空会话口令 + SessionClearToken string `json:"session_clear_token"` } var config *Configuration @@ -21,30 +37,85 @@ var once sync.Once // LoadConfig 加载配置 func LoadConfig() *Configuration { once.Do(func() { - // 从文件中读取 - config = &Configuration{} - f, err := os.Open("config.json") - if err != nil { - log.Fatalf("open config err: %v", err) - return - } - defer f.Close() - encoder := json.NewDecoder(f) - err = encoder.Decode(config) - if err != nil { - log.Fatalf("decode config err: %v", err) - return + // 给配置赋默认值 + config = &Configuration{ + AutoPass: false, + SessionTimeout: 60, + MaxTokens: 512, + Model: "text-davinci-003", + Temperature: 0.9, + SessionClearToken: "下一个问题", } - // 如果环境变量有配置,读取环境变量 - ApiKey := os.Getenv("ApiKey") - AutoPass := os.Getenv("AutoPass") + // 判断配置文件是否存在,存在直接JSON读取 + _, err := os.Stat("config.json") + if err == nil { + f, err := os.Open("config.json") + if err != nil { + log.Fatalf("open config err: %v", err) + return + } + defer f.Close() + encoder := json.NewDecoder(f) + err = encoder.Decode(config) + if err != nil { + log.Fatalf("decode config err: %v", err) + return + } + } + // 有环境变量使用环境变量 + ApiKey := os.Getenv("APIKEY") + AutoPass := os.Getenv("AUTO_PASS") + SessionTimeout := os.Getenv("SESSION_TIMEOUT") + Model := os.Getenv("MODEL") + MaxTokens := os.Getenv("MAX_TOKENS") + Temperature := os.Getenv("TEMPREATURE") + ReplyPrefix := os.Getenv("REPLY_PREFIX") + SessionClearToken := os.Getenv("SESSION_CLEAR_TOKEN") if ApiKey != "" { config.ApiKey = ApiKey } if AutoPass == "true" { config.AutoPass = true } + if SessionTimeout != "" { + duration, err := time.ParseDuration(SessionTimeout) + if err != nil { + logger.Danger(fmt.Sprintf("config session timeout err: %v ,get is %v", err, SessionTimeout)) + return + } + config.SessionTimeout = duration + } + if Model != "" { + config.Model = Model + } + if MaxTokens != "" { + max, err := strconv.Atoi(MaxTokens) + if err != nil { + logger.Danger(fmt.Sprintf("config MaxTokens err: %v ,get is %v", err, MaxTokens)) + return + } + config.MaxTokens = uint(max) + } + if Temperature != "" { + temp, err := strconv.ParseFloat(Temperature, 64) + if err != nil { + logger.Danger(fmt.Sprintf("config Temperature err: %v ,get is %v", err, Temperature)) + return + } + config.Temperature = temp + } + if ReplyPrefix != "" { + config.ReplyPrefix = ReplyPrefix + } + if SessionClearToken != "" { + config.SessionClearToken = SessionClearToken + } + }) + if config.ApiKey == "" { + logger.Danger("config err: api key required") + } + return config } diff --git a/go.mod b/go.mod index a3f92ad..391ae9b 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,16 @@ -module github.com/869413421/wechatbot +module github.com/qingconglaixueit/wechatbot go 1.16 -require github.com/eatmoreapple/openwechat v1.2.1 +require ( + github.com/BurntSushi/toml v1.2.1 // indirect + github.com/eatmoreapple/openwechat v1.2.1 + github.com/natefinch/lumberjack v2.0.0+incompatible // indirect + github.com/otiai10/openaigo v1.0.0 + github.com/patrickmn/go-cache v2.1.0+incompatible + github.com/qingconglaixueit/abing_logger v0.0.2 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e + go.uber.org/zap v1.24.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/go.sum b/go.sum index 6fe8c3d..fcc71fa 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,81 @@ +github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= +github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/eatmoreapple/openwechat v1.2.1 h1:ez4oqF/Y2NSEX/DbPV8lvj7JlfkYqvieeo4awx5lzfU= github.com/eatmoreapple/openwechat v1.2.1/go.mod h1:61HOzTyvLobGdgWhL68jfGNwTJEv0mhQ1miCXQrvWU8= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4UpgHp07nNdFX7mqFfM= +github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk= +github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE= +github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs= +github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo= +github.com/otiai10/mint v1.4.1 h1:HOVBfKP1oXIc0wWo9hZ8JLdZtyCPWqjvmFDuVZ0yv2Y= +github.com/otiai10/mint v1.4.1/go.mod h1:gifjb2MYOoULtKLqUAEILUG/9KONW6f7YsJ6vQLTlFI= +github.com/otiai10/openaigo v1.0.0 h1:E4SoShWjMVltBAkM4/QsxGPhPKZlpKp0GSK6Ov1MSOg= +github.com/otiai10/openaigo v1.0.0/go.mod h1:792bx6AWTS61weDi2EzKpHHnTF4eDMAlJ5GvAk/mgPg= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/qingconglaixueit/abing_logger v0.0.2 h1:KPv+dzAwVYqB/nPViVy8YhQDORm7/ejQ4JSkAs8RnpA= +github.com/qingconglaixueit/abing_logger v0.0.2/go.mod h1:jXdlkJitAiZFx9IM9M0U1P+8myv0ieQRPznThQA6myg= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= +go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= +go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/gtp/gtp.go b/gpt/gpt.go similarity index 56% rename from gtp/gtp.go rename to gpt/gpt.go index 15cd8e2..ec6c39b 100644 --- a/gtp/gtp.go +++ b/gpt/gpt.go @@ -1,35 +1,43 @@ -package gtp +package gpt import ( "bytes" "encoding/json" - "github.com/869413421/wechatbot/config" + "errors" + "fmt" + "github.com/qingconglaixueit/wechatbot/config" + "github.com/qingconglaixueit/wechatbot/pkg/logger" "io/ioutil" "log" "net/http" + "time" ) const BASEURL = "https://api.openai.com/v1/" // ChatGPTResponseBody 请求体 type ChatGPTResponseBody struct { - ID string `json:"id"` - Object string `json:"object"` - Created int `json:"created"` - Model string `json:"model"` - Choices []map[string]interface{} `json:"choices"` - Usage map[string]interface{} `json:"usage"` + ID string `json:"id"` + Object string `json:"object"` + Created int `json:"created"` + Model string `json:"model"` + Choices []ChoiceItem `json:"choices"` + Usage map[string]interface{} `json:"usage"` } type ChoiceItem struct { + Text string `json:"text"` + Index int `json:"index"` + Logprobs int `json:"logprobs"` + FinishReason string `json:"finish_reason"` } // ChatGPTRequestBody 响应体 type ChatGPTRequestBody struct { Model string `json:"model"` Prompt string `json:"prompt"` - MaxTokens int `json:"max_tokens"` - Temperature float32 `json:"temperature"` + MaxTokens uint `json:"max_tokens"` + Temperature float64 `json:"temperature"` TopP int `json:"top_p"` FrequencyPenalty int `json:"frequency_penalty"` PresencePenalty int `json:"presence_penalty"` @@ -41,11 +49,12 @@ type ChatGPTRequestBody struct { //-H "Authorization: Bearer your chatGPT key" //-d '{"model": "text-davinci-003", "prompt": "give me good song", "temperature": 0, "max_tokens": 7}' func Completions(msg string) (string, error) { + cfg := config.LoadConfig() requestBody := ChatGPTRequestBody{ - Model: "text-davinci-003", + Model: cfg.Model, Prompt: msg, - MaxTokens: 2048, - Temperature: 0.7, + MaxTokens: cfg.MaxTokens, + Temperature: cfg.Temperature, TopP: 1, FrequencyPenalty: 0, PresencePenalty: 0, @@ -55,7 +64,7 @@ func Completions(msg string) (string, error) { if err != nil { return "", err } - log.Printf("request gtp json string : %v", string(requestData)) + logger.Info(fmt.Sprintf("request gpt json string : %v", string(requestData))) req, err := http.NewRequest("POST", BASEURL+"completions", bytes.NewBuffer(requestData)) if err != nil { return "", err @@ -64,17 +73,21 @@ func Completions(msg string) (string, error) { apiKey := config.LoadConfig().ApiKey req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+apiKey) - client := &http.Client{} + client := &http.Client{Timeout: 30 * time.Second} response, err := client.Do(req) if err != nil { return "", err } defer response.Body.Close() - + if response.StatusCode != 200 { + body, _ := ioutil.ReadAll(response.Body) + return "", errors.New(fmt.Sprintf("请求GTP出错了,gpt api status code not equals 200,code is %d ,details: %v ", response.StatusCode, string(body))) + } body, err := ioutil.ReadAll(response.Body) if err != nil { return "", err } + logger.Info(fmt.Sprintf("response gpt json string : %v", string(body))) gptResponseBody := &ChatGPTResponseBody{} log.Println(string(body)) @@ -82,13 +95,11 @@ func Completions(msg string) (string, error) { if err != nil { return "", err } + var reply string if len(gptResponseBody.Choices) > 0 { - for _, v := range gptResponseBody.Choices { - reply = v["text"].(string) - break - } + reply = gptResponseBody.Choices[0].Text } - log.Printf("gpt response text: %s \n", reply) + logger.Info(fmt.Sprintf("gpt response text: %s ", reply)) return reply, nil } diff --git a/gpt/gpt35.go b/gpt/gpt35.go new file mode 100644 index 0000000..5ab0536 --- /dev/null +++ b/gpt/gpt35.go @@ -0,0 +1,41 @@ +// @Author Bing +// @Date 2023/3/6 20:31:00 +// @Desc +package gpt + +import ( + "context" + "github.com/otiai10/openaigo" + "github.com/qingconglaixueit/abing_logger" + "github.com/qingconglaixueit/wechatbot/config" +) + +type MyGpt struct { + C *openaigo.Client +} + +func NewGpr35() *openaigo.Client { + cfg := config.LoadConfig() + return openaigo.NewClient(cfg.ApiKey) +} + +func (c *MyGpt) Gpt3P5(req string) string { + request := openaigo.ChatCompletionRequestBody{ + Model: "gpt-3.5-turbo", + Messages: []openaigo.ChatMessage{ + {Role: "user", Content: req}, + }, + } + ctx := context.Background() + rsp, err := c.C.Chat(ctx, request) + if err != nil { + abing_logger.SugarLogger.Errorf("gpt client chat erorr:%+v", err) + return "" + } + + if len(rsp.Choices) == 0 || rsp.Choices[0].Message.Content == "" { + return "" + } + + return rsp.Choices[0].Message.Content +} diff --git a/handlers/group_msg_handler.go b/handlers/group_msg_handler.go index ec0560a..4fb5fde 100644 --- a/handlers/group_msg_handler.go +++ b/handlers/group_msg_handler.go @@ -1,9 +1,12 @@ package handlers import ( - "github.com/869413421/wechatbot/gtp" + "errors" + "fmt" "github.com/eatmoreapple/openwechat" - "log" + "github.com/qingconglaixueit/wechatbot/gpt" + "github.com/qingconglaixueit/wechatbot/pkg/logger" + "github.com/qingconglaixueit/wechatbot/service" "strings" ) @@ -11,61 +14,165 @@ var _ MessageHandlerInterface = (*GroupMessageHandler)(nil) // GroupMessageHandler 群消息处理 type GroupMessageHandler struct { + // 获取自己 + self *openwechat.Self + // 群 + group *openwechat.Group + // 接收到消息 + msg *openwechat.Message + // 发送的用户 + sender *openwechat.User + // 实现的用户业务 + service service.UserServiceInterface } -// handle 处理消息 -func (g *GroupMessageHandler) handle(msg *openwechat.Message) error { - if msg.IsText() { - return g.ReplyText(msg) +func GroupMessageContextHandler() func(ctx *openwechat.MessageContext) { + return func(ctx *openwechat.MessageContext) { + msg := ctx.Message + // 获取用户消息处理器 + handler, err := NewGroupMessageHandler(msg) + if err != nil { + logger.Warning(fmt.Sprintf("init group message handler error: %s", err)) + return + } + + // 处理用户消息 + err = handler.handle() + if err != nil { + logger.Warning(fmt.Sprintf("handle group message error: %s", err)) + } } - return nil } // NewGroupMessageHandler 创建群消息处理器 -func NewGroupMessageHandler() MessageHandlerInterface { - return &GroupMessageHandler{} +func NewGroupMessageHandler(msg *openwechat.Message) (MessageHandlerInterface, error) { + sender, err := msg.Sender() + if err != nil { + return nil, err + } + group := &openwechat.Group{User: sender} + groupSender, err := msg.SenderInGroup() + if err != nil { + return nil, err + } + + userService := service.NewUserService(c, groupSender) + handler := &GroupMessageHandler{ + self: sender.Self, + msg: msg, + group: group, + sender: groupSender, + service: userService, + } + return handler, nil + } -// ReplyText 发送文本消息到群 -func (g *GroupMessageHandler) ReplyText(msg *openwechat.Message) error { - // 接收群消息 - sender, err := msg.Sender() - group := openwechat.Group{sender} - log.Printf("Received Group %v Text Msg : %v", group.NickName, msg.Content) +// handle 处理消息 +func (g *GroupMessageHandler) handle() error { + if g.msg.IsText() { + return g.ReplyText() + } + return nil +} + +// ReplyText 发息送文本消到群 +func (g *GroupMessageHandler) ReplyText() error { + logger.Info(fmt.Sprintf("Received Group %v Text Msg : %v", g.group.NickName, g.msg.Content)) + var ( + err error + reply string + ) - // 不是@的不处理 - if !msg.IsAt() { + // 1.不是@的不处理 + if !g.msg.IsAt() { return nil } - // 替换掉@文本,然后向GPT发起请求 - replaceText := "@" + sender.Self.NickName - requestText := strings.TrimSpace(strings.ReplaceAll(msg.Content, replaceText, "")) - reply, err := gtp.Completions(requestText) - if err != nil { - log.Printf("gtp request error: %v \n", err) - msg.ReplyText("机器人神了,我一会发现了就去修。") - return err - } - if reply == "" { + // 2.获取请求的文本,如果为空字符串不处理 + requestText := g.getRequestText() + if requestText == "" { + logger.Info("user message is null") return nil } - // 获取@我的用户 - groupSender, err := msg.SenderInGroup() + // 3.请求GPT获取回复 + reply, err = gpt.Completions(requestText) if err != nil { - log.Printf("get sender in group error :%v \n", err) + // 2.1 将GPT请求失败信息输出给用户,省得整天来问又不知道日志在哪里。 + errMsg := fmt.Sprintf("gpt request error: %v", err) + _, err = g.msg.ReplyText(errMsg) + if err != nil { + return errors.New(fmt.Sprintf("response group error: %v ", err)) + } return err } - // 回复@我的用户 - reply = strings.TrimSpace(reply) - reply = strings.Trim(reply, "\n") - atText := "@" + groupSender.NickName - replyText := atText + reply - _, err = msg.ReplyText(replyText) + // 4.设置上下文,并响应信息给用户 + g.service.SetUserSessionContext(requestText, reply) + _, err = g.msg.ReplyText(g.buildReplyText(reply)) if err != nil { - log.Printf("response group error: %v \n", err) + return errors.New(fmt.Sprintf("response user error: %v ", err)) } + + // 5.返回错误信息 return err } + +// getRequestText 获取请求接口的文本,要做一些清洗 +func (g *GroupMessageHandler) getRequestText() string { + // 1.去除空格以及换行 + requestText := strings.TrimSpace(g.msg.Content) + requestText = strings.Trim(g.msg.Content, "\n") + + // 2.替换掉当前用户名称 + replaceText := "@" + g.self.NickName + requestText = strings.TrimSpace(strings.ReplaceAll(g.msg.Content, replaceText, "")) + if requestText == "" { + return "" + } + + // 3.获取上下文,拼接在一起,如果字符长度超出4000,截取为4000。(GPT按字符长度算),达芬奇3最大为4068,也许后续为了适应要动态进行判断。 + sessionText := g.service.GetUserSessionContext() + if sessionText != "" { + requestText = sessionText + "\n" + requestText + } + if len(requestText) >= 4000 { + requestText = requestText[:4000] + } + + // 4.检查用户发送文本是否包含结束标点符号 + punctuation := ",.;!?,。!?、…" + runeRequestText := []rune(requestText) + lastChar := string(runeRequestText[len(runeRequestText)-1:]) + if strings.Index(punctuation, lastChar) < 0 { + requestText = requestText + "?" // 判断最后字符是否加了标点,没有的话加上句号,避免openai自动补齐引起混乱。 + } + + // 5.返回请求文本 + return requestText +} + +// buildReply 构建回复文本 +func (g *GroupMessageHandler) buildReplyText(reply string) string { + // 1.获取@我的用户 + atText := "@" + g.sender.NickName + textSplit := strings.Split(reply, "\n\n") + if len(textSplit) > 1 { + trimText := textSplit[0] + reply = strings.Trim(reply, trimText) + } + reply = strings.TrimSpace(reply) + if reply == "" { + return atText + " 请求得不到任何有意义的回复,请具体提出问题。" + } + + // 2.拼接回复,@我的用户,问题,回复 + replaceText := "@" + g.self.NickName + question := strings.TrimSpace(strings.ReplaceAll(g.msg.Content, replaceText, "")) + reply = atText + "\n" + question + "\n --------------------------------\n" + reply + reply = strings.Trim(reply, "\n") + + // 3.返回回复的内容 + return reply +} diff --git a/handlers/handler.go b/handlers/handler.go index 50d2b54..7a608df 100644 --- a/handlers/handler.go +++ b/handlers/handler.go @@ -1,53 +1,71 @@ package handlers import ( - "github.com/869413421/wechatbot/config" + "fmt" + "github.com/qingconglaixueit/wechatbot/config" + "github.com/qingconglaixueit/wechatbot/pkg/logger" "github.com/eatmoreapple/openwechat" + "github.com/patrickmn/go-cache" + "github.com/skip2/go-qrcode" "log" + "runtime" + "strings" + "time" ) +var c = cache.New(config.LoadConfig().SessionTimeout, time.Minute*5) + // MessageHandlerInterface 消息处理接口 type MessageHandlerInterface interface { - handle(*openwechat.Message) error - ReplyText(*openwechat.Message) error + handle() error + ReplyText() error } -type HandlerType string - -const ( - GroupHandler = "group" - UserHandler = "user" -) +// QrCodeCallBack 登录扫码回调, +func QrCodeCallBack(uuid string) { + if runtime.GOOS == "windows" { + // 运行在Windows系统上 + openwechat.PrintlnQrcodeUrl(uuid) + } else { + log.Println("login in linux") + url := "https://login.weixin.qq.com/l/" + uuid + log.Printf("如果二维码无法扫描,请缩小控制台尺寸,或更换命令行工具,缩小二维码像素") + q, _ := qrcode.New(url, qrcode.High) + fmt.Println(q.ToSmallString(true)) + } +} -// handlers 所有消息类型类型的处理器 -var handlers map[HandlerType]MessageHandlerInterface +func NewHandler() (msgFunc func(msg *openwechat.Message), err error) { + dispatcher := openwechat.NewMessageMatchDispatcher() -func init() { - handlers = make(map[HandlerType]MessageHandlerInterface) - handlers[GroupHandler] = NewGroupMessageHandler() - handlers[UserHandler] = NewUserMessageHandler() -} + // 清空会话 + dispatcher.RegisterHandler(func(message *openwechat.Message) bool { + return strings.Contains(message.Content, config.LoadConfig().SessionClearToken) + }, TokenMessageContextHandler()) -// Handler 全局处理入口 -func Handler(msg *openwechat.Message) { - log.Printf("hadler Received msg : %v", msg.Content) // 处理群消息 - if msg.IsSendByGroup() { - handlers[GroupHandler].handle(msg) - return - } + dispatcher.RegisterHandler(func(message *openwechat.Message) bool { + return message.IsSendByGroup() + }, GroupMessageContextHandler()) // 好友申请 - if msg.IsFriendAdd() { + dispatcher.RegisterHandler(func(message *openwechat.Message) bool { + return message.IsFriendAdd() + }, func(ctx *openwechat.MessageContext) { + msg := ctx.Message if config.LoadConfig().AutoPass { - _, err := msg.Agree("你好我是基于chatGPT引擎开发的微信机器人,你可以向我提问任何问题。") + _, err := msg.Agree("") if err != nil { - log.Fatalf("add friend agree error : %v", err) + logger.Warning(fmt.Sprintf("add friend agree error : %v", err)) return } } - } + }) // 私聊 - handlers[UserHandler].handle(msg) + // 获取用户消息处理器 + dispatcher.RegisterHandler(func(message *openwechat.Message) bool { + return !(strings.Contains(message.Content, config.LoadConfig().SessionClearToken) || message.IsSendByGroup() || message.IsFriendAdd()) + }, UserMessageContextHandler()) + return openwechat.DispatchMessage(dispatcher), nil } diff --git a/handlers/token_msg_handler.go b/handlers/token_msg_handler.go new file mode 100644 index 0000000..13393c3 --- /dev/null +++ b/handlers/token_msg_handler.go @@ -0,0 +1,79 @@ +package handlers + +import ( + "fmt" + "github.com/qingconglaixueit/wechatbot/pkg/logger" + "github.com/qingconglaixueit/wechatbot/service" + "github.com/eatmoreapple/openwechat" +) + +var _ MessageHandlerInterface = (*TokenMessageHandler)(nil) + +// TokenMessageHandler 口令消息处理器 +type TokenMessageHandler struct { + // 接收到消息 + msg *openwechat.Message + // 发送的用户 + sender *openwechat.User + // 实现的用户业务 + service service.UserServiceInterface +} + +func TokenMessageContextHandler() func(ctx *openwechat.MessageContext) { + return func(ctx *openwechat.MessageContext) { + msg := ctx.Message + // 获取口令消息处理器 + handler, err := NewTokenMessageHandler(msg) + if err != nil { + logger.Warning(fmt.Sprintf("init token message handler error: %s", err)) + } + + // 获取口令消息处理器 + err = handler.handle() + if err != nil { + logger.Warning(fmt.Sprintf("handle token message error: %s", err)) + } + + } +} + +// NewTokenMessageHandler 口令消息处理器 +func NewTokenMessageHandler(msg *openwechat.Message) (MessageHandlerInterface, error) { + sender, err := msg.Sender() + if err != nil { + return nil, err + } + if msg.IsComeFromGroup() { + sender, err = msg.SenderInGroup() + } + userService := service.NewUserService(c, sender) + handler := &TokenMessageHandler{ + msg: msg, + sender: sender, + service: userService, + } + + return handler, nil +} + +// handle 处理口令 +func (t *TokenMessageHandler) handle() error { + return t.ReplyText() +} + +// ReplyText 回复清空口令 +func (t *TokenMessageHandler) ReplyText() error { + logger.Info("user clear token") + t.service.ClearUserSessionContext() + var err error + if t.msg.IsComeFromGroup() { + if !t.msg.IsAt() { + return err + } + atText := "@" + t.sender.NickName + "上下文已经清空,请问下一个问题。" + _, err = t.msg.ReplyText(atText) + } else { + _, err = t.msg.ReplyText("上下文已经清空,请问下一个问题。") + } + return err +} diff --git a/handlers/user_msg_handler.go b/handlers/user_msg_handler.go index e2e250f..8558526 100644 --- a/handlers/user_msg_handler.go +++ b/handlers/user_msg_handler.go @@ -1,9 +1,13 @@ package handlers import ( - "github.com/869413421/wechatbot/gtp" + "errors" + "fmt" "github.com/eatmoreapple/openwechat" - "log" + "github.com/qingconglaixueit/wechatbot/config" + "github.com/qingconglaixueit/wechatbot/gpt" + "github.com/qingconglaixueit/wechatbot/pkg/logger" + "github.com/qingconglaixueit/wechatbot/service" "strings" ) @@ -11,46 +15,137 @@ var _ MessageHandlerInterface = (*UserMessageHandler)(nil) // UserMessageHandler 私聊消息处理 type UserMessageHandler struct { + // 接收到消息 + msg *openwechat.Message + // 发送的用户 + sender *openwechat.User + // 实现的用户业务 + service service.UserServiceInterface } -// handle 处理消息 -func (g *UserMessageHandler) handle(msg *openwechat.Message) error { - if msg.IsText() { - return g.ReplyText(msg) +func UserMessageContextHandler() func(ctx *openwechat.MessageContext) { + return func(ctx *openwechat.MessageContext) { + msg := ctx.Message + handler, err := NewUserMessageHandler(msg) + if err != nil { + logger.Warning(fmt.Sprintf("init user message handler error: %s", err)) + } + + // 处理用户消息 + err = handler.handle() + if err != nil { + logger.Warning(fmt.Sprintf("handle user message error: %s", err)) + } } - return nil } // NewUserMessageHandler 创建私聊处理器 -func NewUserMessageHandler() MessageHandlerInterface { - return &UserMessageHandler{} +func NewUserMessageHandler(message *openwechat.Message) (MessageHandlerInterface, error) { + sender, err := message.Sender() + if err != nil { + return nil, err + } + userService := service.NewUserService(c, sender) + handler := &UserMessageHandler{ + msg: message, + sender: sender, + service: userService, + } + + return handler, nil +} + +// handle 处理消息 +func (h *UserMessageHandler) handle() error { + if h.msg.IsText() { + return h.ReplyText() + } + return nil } // ReplyText 发送文本消息到群 -func (g *UserMessageHandler) ReplyText(msg *openwechat.Message) error { - // 接收私聊消息 - sender, err := msg.Sender() - log.Printf("Received User %v Text Msg : %v", sender.NickName, msg.Content) - - // 向GPT发起请求 - requestText := strings.TrimSpace(msg.Content) - requestText = strings.Trim(msg.Content, "\n") - reply, err := gtp.Completions(requestText) +func (h *UserMessageHandler) ReplyText() error { + logger.Info(fmt.Sprintf("Received User %v Text Msg : %v", h.sender.NickName, h.msg.Content)) + var ( + reply string + err error + ) + // 1.获取上下文,如果字符串为空不处理 + requestText := h.getRequestText() + if requestText == "" { + logger.Info("user message is null") + return nil + } + logger.Info(fmt.Sprintf("h.sender.NickName == %+v", h.sender.NickName)) + // 2.向GPT发起请求,如果回复文本等于空,不回复 + reply, err = gpt.Completions(h.getRequestText()) if err != nil { - log.Printf("gtp request error: %v \n", err) - msg.ReplyText("机器人神了,我一会发现了就去修。") + // 2.1 将GPT请求失败信息输出给用户,省得整天来问又不知道日志在哪里。 + errMsg := fmt.Sprintf("gpt request error: %v", err) + _, err = h.msg.ReplyText(errMsg) + if err != nil { + return errors.New(fmt.Sprintf("response user error: %v ", err)) + } return err } - if reply == "" { - return nil - } - // 回复用户 - reply = strings.TrimSpace(reply) - reply = strings.Trim(reply, "\n") - _, err = msg.ReplyText(reply) + // 2.设置上下文,回复用户 + h.service.SetUserSessionContext(requestText, reply) + _, err = h.msg.ReplyText(buildUserReply(reply)) if err != nil { - log.Printf("response user error: %v \n", err) + return errors.New(fmt.Sprintf("response user error: %v ", err)) } + + // 3.返回错误 return err } + +// getRequestText 获取请求接口的文本,要做一些清晰 +func (h *UserMessageHandler) getRequestText() string { + // 1.去除空格以及换行 + requestText := strings.TrimSpace(h.msg.Content) + requestText = strings.Trim(h.msg.Content, "\n") + + // 2.获取上下文,拼接在一起,如果字符长度超出4000,截取为4000。(GPT按字符长度算),达芬奇3最大为4068,也许后续为了适应要动态进行判断。 + sessionText := h.service.GetUserSessionContext() + if sessionText != "" { + requestText = sessionText + "\n" + requestText + } + if len(requestText) >= 4000 { + requestText = requestText[:4000] + } + + // 3.检查用户发送文本是否包含结束标点符号 + punctuation := ",.;!?,。!?、…" + runeRequestText := []rune(requestText) + lastChar := string(runeRequestText[len(runeRequestText)-1:]) + if strings.Index(punctuation, lastChar) < 0 { + requestText = requestText + "?" // 判断最后字符是否加了标点,没有的话加上句号,避免openai自动补齐引起混乱。 + } + + // 4.返回请求文本 + return requestText +} + +// buildUserReply 构建用户回复 +func buildUserReply(reply string) string { + // 1.去除空格问号以及换行号,如果为空,返回一个默认值提醒用户 + textSplit := strings.Split(reply, "\n\n") + if len(textSplit) > 1 { + trimText := textSplit[0] + reply = strings.Trim(reply, trimText) + } + reply = strings.TrimSpace(reply) + + reply = strings.TrimSpace(reply) + if reply == "" { + return "请求得不到任何有意义的回复,请具体提出问题。" + } + + // 2.如果用户有配置前缀,加上前缀 + reply = config.LoadConfig().ReplyPrefix + "\n" + reply + reply = strings.Trim(reply, "\n") + + // 3.返回拼接好的字符串 + return reply +} diff --git a/main.go b/main.go index 0e18cf1..17d0fbc 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,7 @@ package main import ( - "github.com/869413421/wechatbot/bootstrap" + "github.com/qingconglaixueit/wechatbot/bootstrap" ) func main() { diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 0000000..bdc0b4b --- /dev/null +++ b/pkg/logger/logger.go @@ -0,0 +1,40 @@ +package logger + +import ( + "log" + "os" + "sync" +) + +var Logger *log.Logger +var once sync.Once + +func init() { + once.Do(func() { + Logger = log.New(os.Stdout, "INFO", log.LstdFlags|log.Llongfile) + }) +} + +// Info 详情 +func Info(args ...interface{}) { + Logger.SetPrefix("[INFO]") + Logger.Println(args...) +} + +// Danger 错误 为什么不命名为 error?避免和 error 类型重名 +func Danger(args ...interface{}) { + Logger.SetPrefix("[ERROR]") + Logger.Fatal(args...) +} + +// Warning 警告 +func Warning(args ...interface{}) { + Logger.SetPrefix("[WARNING]") + Logger.Println(args...) +} + +// DeBug debug +func DeBug(args ...interface{}) { + Logger.SetPrefix("[DeBug]") + Logger.Println(args...) +} diff --git a/rule/rule.go b/rule/rule.go new file mode 100644 index 0000000..f9f3608 --- /dev/null +++ b/rule/rule.go @@ -0,0 +1,56 @@ +package rule + +import ( + "sync" + "time" +) + +const ( + STARTTIME = 9 + ENDTIME = 21 +) + +type Rule struct{} + +var isWork = true +var Grule = &Rule{} +var lock sync.Mutex + +func (r *Rule) SetWork(work bool) { + lock.Lock() + defer lock.Unlock() + isWork = work + return +} +func (r *Rule) GetWork() bool { + lock.Lock() + defer lock.Unlock() + return isWork +} + +// 判断时间在今天的早上 9点到 晚上 9 点区间内 +func (r *Rule) IsWorkTime(s int, e int) bool { + if s < 0 || s > 24 { + s = STARTTIME + } + if e < 0 || e > 24 || e <= s { + e = ENDTIME + } + t := time.Now() + startTime := time.Date(t.Year(), t.Month(), t.Day(), s, 0, 0, 0, time.Local) + endTime := time.Date(t.Year(), t.Month(), t.Day(), e, 0, 0, 0, time.Local) + // 判断当前时间是否在当天的 STARTTIME -- ENDTIME + if t.Unix() > startTime.Unix() && t.Unix() < endTime.Unix() { + return true + } + return false +} + +func (r *Rule) InSlice(str string, sli []string) bool { + for _, v := range sli { + if v == str { + return true + } + } + return false +} diff --git a/service/user.go b/service/user.go new file mode 100644 index 0000000..a128aad --- /dev/null +++ b/service/user.go @@ -0,0 +1,62 @@ +package service + +import ( + "github.com/qingconglaixueit/wechatbot/config" + "github.com/eatmoreapple/openwechat" + "github.com/patrickmn/go-cache" + "time" +) + +// UserServiceInterface 用户业务接口 +type UserServiceInterface interface { + GetUserSessionContext() string + SetUserSessionContext(question, reply string) + ClearUserSessionContext() +} + +var _ UserServiceInterface = (*UserService)(nil) + +// UserService 用戶业务 +type UserService struct { + // 缓存 + cache *cache.Cache + // 用户 + user *openwechat.User +} + +// NewUserService 创建新的业务层 +func NewUserService(cache *cache.Cache, user *openwechat.User) UserServiceInterface { + return &UserService{ + cache: cache, + user: user, + } +} + +// ClearUserSessionContext 清空GTP上下文,接收文本中包含`我要问下一个问题`,并且Unicode 字符数量不超过20就清空 +func (s *UserService) ClearUserSessionContext() { + s.cache.Delete(s.user.ID()) +} + +// GetUserSessionContext 获取用户会话上下文文本 +func (s *UserService) GetUserSessionContext() string { + // 1.获取上次会话信息,如果没有直接返回空字符串 + sessionContext, ok := s.cache.Get(s.user.ID()) + if !ok { + return "" + } + + // 2.如果字符长度超过等于4000,强制清空会话(超过GPT会报错)。 + contextText := sessionContext.(string) + if len(contextText) >= 4000 { + s.cache.Delete(s.user.ID()) + } + + // 3.返回上文 + return contextText +} + +// SetUserSessionContext 设置用户会话上下文文本,question用户提问内容,GTP回复内容 +func (s *UserService) SetUserSessionContext(question, reply string) { + value := question + "\n" + reply + s.cache.Set(s.user.ID(), value, time.Second*config.LoadConfig().SessionTimeout) +} diff --git a/supervisord.conf b/supervisord.conf new file mode 100644 index 0000000..dba32a9 --- /dev/null +++ b/supervisord.conf @@ -0,0 +1,13 @@ +[supervisord] +nodaemon=true + +[program:wechatbot] ; 程序名称,在 supervisorctl 中通过这个值来对程序进行一系列的操作 +autorestart=True ; 程序异常退出后自动重启 +autostart=True ; 在 supervisord 启动的时候也自动启动 +redirect_stderr=True ; 把 stderr 重定向到 stdout,默认 false +command=/app/wechatbot ; 启动命令,与手动在命令行启动的命令是一样的 +user=root ; 用哪个用户启动 +stdout_logfile_maxbytes = 20MB ; stdout 日志文件大小,默认 50MB +stdout_logfile_backups = 20 ; stdout 日志文件备份数 +; stdout 日志文件,需要注意当指定目录不存在时无法正常启动,所以需要手动创建目录(supervisord 会自动创建日志文件) +stdout_logfile = /app/run.log \ No newline at end of file