Browse Source

⚗️ 项目初始完成

eryajf 2 years ago
parent
commit
c0e78b34bd
12 changed files with 619 additions and 0 deletions
  1. 61 0
      .github/workflows/docker-image.yml
  2. 20 0
      .gitignore
  3. 16 0
      Dockerfile
  4. 135 0
      README.md
  5. 4 0
      config-dev.json
  6. 58 0
      config/config.go
  7. 8 0
      go.mod
  8. 4 0
      go.sum
  9. 101 0
      gtp/gtp.go
  10. 94 0
      main.go
  11. 64 0
      public/base.go
  12. 54 0
      service/user.go

+ 61 - 0
.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 }}/chatgpt-dingtalk:${{ steps.date.outputs.today }}
+            ${{ secrets.DOCKERHUB_USERNAME }}/chatgpt-dingtalk:latest

+ 20 - 0
.gitignore

@@ -0,0 +1,20 @@
+# Binaries for programs and plugins
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+.idea/
+.vscode/
+
+chatgpt-dingtalk
+
+# Test binary, built with `go test -c`
+*.test
+
+# Output of the go coverage tool, specifically when used with LiteIDE
+*.out
+
+# Dependency directories (remove the comment below to include it)
+# vendor/
+config.json

+ 16 - 0
Dockerfile

@@ -0,0 +1,16 @@
+FROM golang:1.17.10 AS builder
+
+# ENV GOPROXY      https://goproxy.io
+
+RUN mkdir /app
+ADD . /app/
+WORKDIR /app
+RUN go build -o chatgpt-dingtalk .
+
+FROM centos:centos7
+RUN mkdir /app
+WORKDIR /app
+COPY --from=builder /app/ .
+RUN chmod +x chatgpt-dingtalk && cp config.dev.json config.json && yum -y install vim net-tools telnet wget curl && yum clean all
+
+CMD ./chatgpt-dingtalk

+ 135 - 0
README.md

@@ -0,0 +1,135 @@
+<div align="center">
+<h1>Chatgpt Dingtalk</h1>
+
+[![Auth](https://img.shields.io/badge/Auth-eryajf-ff69b4)](https://github.com/eryajf)
+[![Go Version](https://img.shields.io/github/go-mod/go-version/eryajf-world/chatgpt-dingtalk)](https://github.com/eryajf/chatgpt-dingtalk)
+[![Gin Version](https://img.shields.io/badge/Gin-1.6.3-brightgreen)](https://github.com/eryajf/chatgpt-dingtalk)
+[![Gorm Version](https://img.shields.io/badge/Gorm-1.20.12-brightgreen)](https://github.com/eryajf/chatgpt-dingtalk)
+[![GitHub Issues](https://img.shields.io/github/issues/eryajf/chatgpt-dingtalk.svg)](https://github.com/eryajf/chatgpt-dingtalk/issues)
+[![GitHub Pull Requests](https://img.shields.io/github/issues-pr/eryajf/chatgpt-dingtalk)](https://github.com/eryajf/chatgpt-dingtalk/pulls)
+[![GitHub Pull Requests](https://img.shields.io/github/stars/eryajf/chatgpt-dingtalk)](https://github.com/eryajf/chatgpt-dingtalk/stargazers)
+[![HitCount](https://views.whatilearened.today/views/github/eryajf/chatgpt-dingtalk.svg)](https://github.com/eryajf/chatgpt-dingtalk)
+[![GitHub license](https://img.shields.io/github/license/eryajf/chatgpt-dingtalk)](https://github.com/eryajf/chatgpt-dingtalk/blob/main/LICENSE)
+
+<p> 🌉 在钉钉群聊中添加chatGPT机器人 🌉</p>
+
+<img src="https://camo.githubusercontent.com/82291b0fe831bfc6781e07fc5090cbd0a8b912bb8b8d4fec0696c881834f81ac/68747470733a2f2f70726f626f742e6d656469612f394575424971676170492e676966" width="800"  height="3">
+</div><br>
+
+
+
+## 前言
+> 最近chatGPT异常火爆,本项目可以将GPT机器人集成到钉钉群聊中。
+> `注意:`这个项目借鉴了[wechatbot](https://github.com/869413421/wechatbot.git),wechatbot是一个能够集成到个人微信的GPT机器人。
+
+### 功能简介
+ * 支持在钉钉群聊中添加机器人,通过@机器人进行聊天交互。
+ * 提问增加上下文(可能不太理想),更接近官网效果。
+
+## 使用前提
+> * 有openai账号,并且创建好api_key,注册相关事项可以参考[此文章](https://juejin.cn/post/7173447848292253704) 。访问[这里](https://beta.openai.com/account/api-keys),申请个人秘钥。
+> * 在钉钉开发者后台创建机器人,配置应用程序回调。
+
+## 使用教程
+
+### 第一步,先创建机器人
+
+创建步骤参考文档:[企业内部开发机器人](https://open.dingtalk.com/document/robots/enterprise-created-chatbot),或者根据如下步骤进行配置。
+
+1. 创建机器人。
+   ![](https://t.eryajf.net/imgs/2022/12/39583c58f1954374.png)
+
+   步骤比较简单,这里就不赘述了。
+
+2. 配置机器人回调接口。
+   ![](https://t.eryajf.net/imgs/2022/12/0227aea3e0688f00.png)
+
+   创建完毕之后,点击机器人开发管理,然后配置将要部署的服务所在服务器的出口IP,以及将要给服务配置的域名。
+
+3. 发布机器人。
+   ![](https://t.eryajf.net/imgs/2022/12/30cd2374c1ce3788.png)
+
+   点击版本管理与发布,然后点击上线,这个时候就能在钉钉的群里中添加这个机器人了。
+
+4. 群聊添加机器人。
+
+   ![](https://t.eryajf.net/imgs/2022/12/a5ee1425a9286fbf.png)
+
+### 第二步,部署应用
+
+你可以使用docker快速运行本项目。
+
+`第一种:基于环境变量运行`
+
+```sh
+# 运行项目
+$ docker run -itd --name chatgpt -e ApiKey=xxxx -e SessionTimeout=60s --restart=always docker.mirrors.sjtug.sjtu.edu.cn/eryajf/chatgpt-dingtalk:latest
+```
+
+运行命令中映射的配置文件参考下边的配置文件说明。
+
+`第二种:基于配置文件挂载运行`
+
+```sh
+# 复制配置文件,根据自己实际情况,调整配置里的内容
+$ cp config.dev.json config.json  # 其中 config.dev.json 从项目的根目录获取
+
+# 运行项目
+docker run -itd --name chatgpt -v ./config.json:/app/config.json --restart=always docker.mirrors.sjtug.sjtu.edu.cn/eryajf/chatgpt-dingtalk:latest
+```
+
+其中配置文件参考下边的配置文件说明。
+
+部署完成之后,通过Nginx代理本服务:
+
+```nginx
+server {
+    listen       80;
+    server_name  chat.eryajf.net;
+  
+    client_header_timeout 120s;
+    client_body_timeout 120s;
+  
+    location / {
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_set_header X-Forwarded-For $remote_addr;
+        proxy_pass http://localhost:8090;
+    }
+}
+```
+
+部署完成之后,就可以在群里艾特机器人进行体验了。
+
+效果如下:
+
+![](https://t.eryajf.net/imgs/2022/12/2ad746f6fce04369.png)
+
+## 本地开发
+
+````sh
+# 获取项目
+$ git clone https://github.com/eryajf/chatgpt-dingtalk.git
+
+# 进入项目目录
+$ cd chatgpt-dingtalk
+
+# 复制配置文件,根据个人实际情况进行配置
+$ cp config.dev.json config.json
+
+# 启动项目
+$ go run main.go
+````
+
+## 配置文件说明
+````json
+{
+    "api_key": "xxxxxxxxx",  // openai api_key
+    "session_timeout": 60    // 会话超时时间,默认60秒,在会话时间内所有发送给机器人的信息会作为上下文
+}
+````
+
+## 感谢
+
+- [wechatbot](https://github.com/869413421/wechatbot.git)

+ 4 - 0
config-dev.json

@@ -0,0 +1,4 @@
+{
+    "api_key": "xxxxxxxxx",
+    "session_timeout": 60
+}

+ 58 - 0
config/config.go

@@ -0,0 +1,58 @@
+package config
+
+import (
+	"encoding/json"
+	"log"
+	"os"
+	"sync"
+	"time"
+)
+
+// Configuration 项目配置
+type Configuration struct {
+	// gtp apikey
+	ApiKey string `json:"api_key"`
+	// 会话超时时间
+	SessionTimeout time.Duration `json:"session_timeout"`
+}
+
+var config *Configuration
+var once sync.Once
+
+// LoadConfig 加载配置
+func LoadConfig() *Configuration {
+	once.Do(func() {
+		// 从文件中读取
+		config = &Configuration{
+			SessionTimeout: 1,
+		}
+		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")
+		SessionTimeout := os.Getenv("SessionTimeout")
+		if ApiKey != "" {
+			config.ApiKey = ApiKey
+		}
+		if SessionTimeout != "" {
+			duration, err := time.ParseDuration(SessionTimeout)
+			if err != nil {
+				log.Fatalf("config decode session timeout err: %v ,get is %v", err, SessionTimeout)
+				return
+			}
+			config.SessionTimeout = duration
+		}
+	})
+	return config
+}

+ 8 - 0
go.mod

@@ -0,0 +1,8 @@
+module github.com/eryajf/chatgpt-dingtalk
+
+go 1.17
+
+require (
+	github.com/eatmoreapple/openwechat v1.2.3
+	github.com/patrickmn/go-cache v2.1.0+incompatible
+)

+ 4 - 0
go.sum

@@ -0,0 +1,4 @@
+github.com/eatmoreapple/openwechat v1.2.3 h1:8AO+nvXwHVTM/7Gk7y6IZ2/hjnILTLQztWmJnPhPB+k=
+github.com/eatmoreapple/openwechat v1.2.3/go.mod h1:61HOzTyvLobGdgWhL68jfGNwTJEv0mhQ1miCXQrvWU8=
+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=

+ 101 - 0
gtp/gtp.go

@@ -0,0 +1,101 @@
+package gtp
+
+import (
+	"bytes"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"log"
+	"net/http"
+
+	"github.com/eryajf/chatgpt-dingtalk/config"
+)
+
+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 []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"`
+	TopP             int     `json:"top_p"`
+	FrequencyPenalty int     `json:"frequency_penalty"`
+	PresencePenalty  int     `json:"presence_penalty"`
+}
+
+// Completions gtp文本模型回复
+//curl https://api.openai.com/v1/completions
+//-H "Content-Type: application/json"
+//-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) {
+	requestBody := ChatGPTRequestBody{
+		Model:            "text-davinci-003",
+		Prompt:           msg,
+		MaxTokens:        1024,
+		Temperature:      0.7,
+		TopP:             1,
+		FrequencyPenalty: 0,
+		PresencePenalty:  0,
+	}
+	requestData, err := json.Marshal(requestBody)
+
+	if err != nil {
+		return "", err
+	}
+	log.Printf("request gtp json string : %v", string(requestData))
+	req, err := http.NewRequest("POST", BASEURL+"completions", bytes.NewBuffer(requestData))
+	if err != nil {
+		return "", err
+	}
+
+	apiKey := config.LoadConfig().ApiKey
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("Authorization", "Bearer "+apiKey)
+	client := &http.Client{}
+	response, err := client.Do(req)
+	if err != nil {
+		return "", err
+	}
+	defer response.Body.Close()
+	if response.StatusCode != 200 {
+		return "", errors.New(fmt.Sprintf("gtp api status code not equals 200,code is %d", response.StatusCode))
+	}
+	body, err := ioutil.ReadAll(response.Body)
+	if err != nil {
+		return "", err
+	}
+
+	gptResponseBody := &ChatGPTResponseBody{}
+	log.Println(string(body))
+	err = json.Unmarshal(body, gptResponseBody)
+	if err != nil {
+		return "", err
+	}
+
+	var reply string
+	if len(gptResponseBody.Choices) > 0 {
+		reply = gptResponseBody.Choices[0].Text
+	}
+	log.Printf("gpt response text: %s \n", reply)
+	return reply, nil
+}

+ 94 - 0
main.go

@@ -0,0 +1,94 @@
+package main
+
+import (
+	"encoding/json"
+	"io/ioutil"
+	"log"
+	"net/http"
+	"strings"
+
+	"github.com/eryajf/chatgpt-dingtalk/gtp"
+	"github.com/eryajf/chatgpt-dingtalk/public"
+	"github.com/eryajf/chatgpt-dingtalk/service"
+)
+
+var UserService service.UserServiceInterface
+
+func init() {
+	UserService = service.NewUserService()
+}
+
+func main() {
+	// 定义一个处理器函数
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		data, err := ioutil.ReadAll(r.Body)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+		// TODO: 校验请求
+		// fmt.Println(r.Header)
+
+		var msgObj = new(public.ReceiveMsg)
+		err = json.Unmarshal(data, &msgObj)
+		if err != nil {
+			log.Printf("unmarshal request body failed: %v\n", err)
+		}
+		err = ProcessRequest(*msgObj)
+		if err != nil {
+			log.Printf("process request failed: %v\n", err)
+		}
+
+	}
+
+	// 创建一个新的 HTTP 服务器
+	server := &http.Server{
+		Addr:    ":8090",
+		Handler: http.HandlerFunc(handler),
+	}
+
+	// 启动服务器
+	log.Print("Start Listen On ", server.Addr)
+	err := server.ListenAndServe()
+	if err != nil {
+		log.Fatal(err)
+	}
+}
+
+func ProcessRequest(rmsg public.ReceiveMsg) error {
+	// 获取问题的答案
+	reply, err := gtp.Completions(rmsg.Text.Content)
+	if err != nil {
+		log.Printf("gtp request error: %v \n", err)
+		_, err = rmsg.ReplyText("机器人太累了,让她休息会儿,过一会儿再来请求。")
+		if err != nil {
+			log.Printf("send message error: %v \n", err)
+			return err
+		}
+		log.Printf("request openai error: %v\n", err)
+		return err
+	}
+	if reply == "" {
+		return nil
+	}
+	// 回复@我的用户
+	reply = strings.TrimSpace(reply)
+	reply = strings.Trim(reply, "\n")
+	atText := "@" + rmsg.SenderNick + "\n" + " "
+	// 设置上下文
+	if UserService.ClearUserSessionContext(rmsg.SenderID, rmsg.Text.Content) {
+		_, err = rmsg.ReplyText(atText + "上下文已经清空了,你可以问下一个问题啦。")
+		if err != nil {
+			log.Printf("response user error: %v \n", err)
+			return err
+		}
+	}
+	UserService.SetUserSessionContext(rmsg.SenderID, rmsg.Text.Content, reply)
+	replyText := atText + reply
+	_, err = rmsg.ReplyText(replyText)
+	if err != nil {
+		log.Printf("send message error: %v \n", err)
+		return err
+	}
+	return nil
+}

+ 64 - 0
public/base.go

@@ -0,0 +1,64 @@
+package public
+
+import (
+	"bytes"
+	"encoding/json"
+	"net/http"
+)
+
+// 接收的消息体
+type ReceiveMsg struct {
+	ConversationID string `json:"conversationId"`
+	AtUsers        []struct {
+		DingtalkID string `json:"dingtalkId"`
+	} `json:"atUsers"`
+	ChatbotUserID             string `json:"chatbotUserId"`
+	MsgID                     string `json:"msgId"`
+	SenderNick                string `json:"senderNick"`
+	IsAdmin                   bool   `json:"isAdmin"`
+	SessionWebhookExpiredTime int64  `json:"sessionWebhookExpiredTime"`
+	CreateAt                  int64  `json:"createAt"`
+	ConversationType          string `json:"conversationType"`
+	SenderID                  string `json:"senderId"`
+	ConversationTitle         string `json:"conversationTitle"`
+	IsInAtList                bool   `json:"isInAtList"`
+	SessionWebhook            string `json:"sessionWebhook"`
+	Text                      Text   `json:"text"`
+	RobotCode                 string `json:"robotCode"`
+	Msgtype                   string `json:"msgtype"`
+}
+
+// 发送的消息体
+type SendMsg struct {
+	Text    Text   `json:"text"`
+	Msgtype string `json:"msgtype"`
+}
+
+// 消息内容
+type Text struct {
+	Content string `json:"content"`
+}
+
+// 发消息给钉钉
+func (r ReceiveMsg) ReplyText(msg string) (statuscode int, err error) {
+	// 定义消息
+	msgtmp := &SendMsg{Text: Text{Content: msg}, Msgtype: "text"}
+	data, err := json.Marshal(msgtmp)
+	if err != nil {
+		return 0, err
+	}
+
+	req, err := http.NewRequest("POST", r.SessionWebhook, bytes.NewBuffer(data))
+	if err != nil {
+		return 0, err
+	}
+	req.Header.Add("Accept", "*/*")
+	req.Header.Add("Content-Type", "application/json")
+	client := &http.Client{}
+	resp, err := client.Do(req)
+	if err != nil {
+		return 0, err
+	}
+	defer resp.Body.Close()
+	return resp.StatusCode, nil
+}

+ 54 - 0
service/user.go

@@ -0,0 +1,54 @@
+package service
+
+import (
+	"strings"
+	"time"
+	"unicode/utf8"
+
+	"github.com/eryajf/chatgpt-dingtalk/config"
+	"github.com/patrickmn/go-cache"
+)
+
+// UserServiceInterface 用户业务接口
+type UserServiceInterface interface {
+	GetUserSessionContext(userId string) string
+	SetUserSessionContext(userId string, question, reply string)
+	ClearUserSessionContext(userId string, msg string) bool
+}
+
+var _ UserServiceInterface = (*UserService)(nil)
+
+// UserService 用戶业务
+type UserService struct {
+	// 缓存
+	cache *cache.Cache
+}
+
+// ClearUserSessionContext 清空GTP上下文,接收文本中包含`我要问下一个问题`,并且Unicode 字符数量不超过20就清空
+func (s *UserService) ClearUserSessionContext(userId string, msg string) bool {
+	if strings.Contains(msg, "我要问下一个问题") && utf8.RuneCountInString(msg) < 20 {
+		s.cache.Delete(userId)
+		return true
+	}
+	return false
+}
+
+// NewUserService 创建新的业务层
+func NewUserService() UserServiceInterface {
+	return &UserService{cache: cache.New(time.Second*config.LoadConfig().SessionTimeout, time.Minute*10)}
+}
+
+// GetUserSessionContext 获取用户会话上下文文本
+func (s *UserService) GetUserSessionContext(userId string) string {
+	sessionContext, ok := s.cache.Get(userId)
+	if !ok {
+		return ""
+	}
+	return sessionContext.(string)
+}
+
+// SetUserSessionContext 设置用户会话上下文文本,question用户提问内容,GTP回复内容
+func (s *UserService) SetUserSessionContext(userId string, question, reply string) {
+	value := question + "\n" + reply
+	s.cache.Set(userId, value, time.Second*config.LoadConfig().SessionTimeout)
+}