├── .env.sample ├── .github └── workflows │ └── release.yaml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── go.mod ├── go.sum ├── goreleaser.yaml ├── main.go └── pkg ├── gchat ├── models.go └── verify_jwt.go ├── openai ├── client.go ├── models.go └── respond.go └── server ├── app.go └── handlers.go /.env.sample: -------------------------------------------------------------------------------- 1 | OPENAI_API_KEY=sk-foo 2 | GOOGLE_PROJECT_NUMBER=123 3 | ADDRESS=0.0.0.0:2323 4 | OPENAI_MODEL=gpt-3.5-turbo -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" # Will trigger only if tag is pushed matching pattern `v*` (Eg: `v0.1.0`) 7 | 8 | jobs: 9 | goreleaser: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v3 19 | 20 | - name: Run GoReleaser 21 | uses: goreleaser/goreleaser-action@v4 22 | with: 23 | distribution: goreleaser 24 | version: latest 25 | args: release --clean --parallelism 1 --skip-validate 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | gchatgpt.bin -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Start by building the application. 2 | FROM golang:1.19 as build 3 | 4 | WORKDIR /go/src/app 5 | COPY . . 6 | 7 | RUN go mod download 8 | RUN CGO_ENABLED=0 go build -o /go/bin/app 9 | 10 | # Now copy it into our base image. 11 | FROM gcr.io/distroless/static-debian11 12 | COPY --from=build /go/bin/app / 13 | CMD ["/app"] 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2023 Kailash Nadh, https://nadh.in 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## GChat bot for OpenAI ChatGPT. 4 | 5 | `go build -o gchatgpt.bin main.go` to build or download the latest release. 6 | 7 | 8 | ## Running 9 | - Create a new GChat app in Google Cloud Console. Copy its "Project number (app ID)" 10 | - Build the binary `go build -ldflags="-s -w" -o gchatgpt.bin *.go` or downoad the latest release. 11 | - Run `./gchatgpt.bin`. 12 | 13 | Run the bot: 14 | ```shell 15 | OPENAI_API_KEY="sk-xxxx" GOOGLE_PROJECT_NUMBER="123456789" ADDRESS=":8080" OPENAI_MODEL="" PREPROMPT="" ./gchatgpt.bin 16 | ``` 17 | 18 | ### Environment variables 19 | - `OPENAI_API_KEY`: OpenAI API key. Get one from https://beta.openai.com/account/api-keys 20 | - `GOOGLE_PROJECT_NUMBER`: Project number (app ID) of the GChat app. 21 | - `ADDRESS`: Address to listen on. Defaults to `:1234` 22 | - `OPENAI_MODEL`: OpenAI model to use. Defaults to `gpt-3.5-turbo` 23 | - `PREPROMPT`: Preprompt to use. Defaults to `"You are a chat bot in a thread with multiple users. You will receive messages in the format : . Respond in a non-verbose and to-the-point manner."` 24 | 25 | 26 | The bot should be listening to an https endpoing exposed to the internet. This URL should be configured in the GChat app config in the Google Cloud Console. 27 | 28 | ## Usage 29 | On GChat, speak to the bot by tagging it. `@gchatgpt What is the answer to life, universe, and everything?` 30 | 31 | License: MIT 32 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/zerodhatech/gchatgpt 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/golang-jwt/jwt v3.2.2+incompatible 7 | github.com/lestrrat-go/jwx/v2 v2.0.9 8 | ) 9 | 10 | require ( 11 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 // indirect 12 | github.com/goccy/go-json v0.10.2 // indirect 13 | github.com/lestrrat-go/blackmagic v1.0.1 // indirect 14 | github.com/lestrrat-go/httpcc v1.0.1 // indirect 15 | github.com/lestrrat-go/httprc v1.0.4 // indirect 16 | github.com/lestrrat-go/iter v1.0.2 // indirect 17 | github.com/lestrrat-go/option v1.0.1 // indirect 18 | golang.org/x/crypto v0.7.0 // indirect 19 | ) 20 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= 5 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 h1:HbphB4TFFXpv7MNrT52FGrrgVXF1owhMVTHFZIlnvd4= 6 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0/go.mod h1:DZGJHZMqrU4JJqFAWUS2UO1+lbSKsdiOoYi9Zzey7Fc= 7 | github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 8 | github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 9 | github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 10 | github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 11 | github.com/lestrrat-go/blackmagic v1.0.1 h1:lS5Zts+5HIC/8og6cGHb0uCcNCa3OUt1ygh3Qz2Fe80= 12 | github.com/lestrrat-go/blackmagic v1.0.1/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= 13 | github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= 14 | github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= 15 | github.com/lestrrat-go/httprc v1.0.4 h1:bAZymwoZQb+Oq8MEbyipag7iSq6YIga8Wj6GOiJGdI8= 16 | github.com/lestrrat-go/httprc v1.0.4/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= 17 | github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= 18 | github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= 19 | github.com/lestrrat-go/jwx/v2 v2.0.9 h1:TRX4Q630UXxPVLvP5vGaqVJO7S+0PE6msRZUsFSBoC8= 20 | github.com/lestrrat-go/jwx/v2 v2.0.9/go.mod h1:K68euYaR95FnL0hIQB8VvzL70vB7pSifbJUydCTPmgM= 21 | github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 22 | github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 23 | github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 24 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 25 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 26 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 27 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 28 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 29 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 30 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 31 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 32 | github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= 33 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 34 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 35 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 36 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 37 | golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= 38 | golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= 39 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 40 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 41 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 42 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 43 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 44 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 45 | golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= 46 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 47 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 48 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 49 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 50 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 51 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 52 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 53 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 54 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 55 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 56 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 57 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 58 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 59 | golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= 60 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 61 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 62 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 63 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 64 | golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 65 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 66 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 67 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 68 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 69 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 70 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 71 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 72 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 73 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 74 | -------------------------------------------------------------------------------- /goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # .goreleaser.yml 2 | 3 | env: 4 | - GO111MODULE=on 5 | - CGO_ENABLED=0 6 | 7 | builds: 8 | - binary: gchatgpt.bin 9 | id: gchatgpt 10 | main: ./ 11 | goos: 12 | - linux 13 | goarch: 14 | - amd64 15 | ldflags: 16 | - -s -w 17 | archives: 18 | - format: tar.gz 19 | files: 20 | - README.md 21 | - LICENSE 22 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "os" 7 | "time" 8 | 9 | "github.com/zerodhatech/gchatgpt/pkg/gchat" 10 | "github.com/zerodhatech/gchatgpt/pkg/openai" 11 | "github.com/zerodhatech/gchatgpt/pkg/server" 12 | ) 13 | 14 | var ( 15 | apiOpenAIKey = os.Getenv("OPENAI_API_KEY") 16 | botAppID = os.Getenv("GOOGLE_PROJECT_NUMBER") 17 | address = os.Getenv("ADDRESS") 18 | gptModel = os.Getenv("GPT_MODEL") 19 | prePrompt = os.Getenv("PRE_PROMPT") 20 | ) 21 | 22 | func main() { 23 | // TODO: setup koanf to read flags and other ways to configure 24 | 25 | // TODO: setup http client 26 | httpClient := &http.Client{} 27 | 28 | // Setup openai client 29 | client, err := openai.NewClient(openai.ClientConfig{ 30 | HTTPClient: httpClient, 31 | APIKey: apiOpenAIKey, 32 | RootURL: openai.APIURLv1, 33 | }) 34 | if err != nil { 35 | log.Fatalf("error creating openai client: %v", err) 36 | } 37 | 38 | // Setup openai store. TODO: make configurable 39 | ttl := int64(24 * time.Hour) // 1 day 40 | maxHistory := 20 // store only 10 41 | 42 | store := openai.NewStore(ttl, maxHistory) 43 | 44 | // Setup openai responder utility 45 | openAI := openai.NewResponder(openai.ResponderOptions{ 46 | Client: client, 47 | Store: store, 48 | Model: gptModel, 49 | }) 50 | 51 | // Setup jwk verifier 52 | jwkVerifier, err := gchat.NewJWKVerifier() 53 | if err != nil { 54 | log.Fatalf("error creating jwk verifier: %v", err) 55 | } 56 | 57 | cfg := server.Options{ 58 | OpenAIKey: apiOpenAIKey, 59 | BotAppID: botAppID, 60 | Address: address, 61 | OpenAI: openAI, 62 | JWKVerifier: jwkVerifier, 63 | PrePrompt: prePrompt, 64 | } 65 | 66 | app := server.New(cfg) 67 | 68 | log.Fatal(app.Run()) 69 | } 70 | -------------------------------------------------------------------------------- /pkg/gchat/models.go: -------------------------------------------------------------------------------- 1 | package gchat 2 | 3 | const ( 4 | SpaceTypeDM = "DM" 5 | SpaceTypeRoom = "ROOM" 6 | ) 7 | 8 | // Event model corresponds to the event sent by Google Chat. 9 | type Event struct { 10 | Type string `json:"type"` 11 | Message struct { 12 | Text string `json:"text"` 13 | ArgumentText string `json:"argumentText"` 14 | Sender struct { 15 | DisplayName string `json:"displayName"` 16 | } `json:"sender"` 17 | Thread struct { 18 | Name string `json:"name"` 19 | } `json:"thread"` 20 | Space struct { 21 | Name string `json:"name"` 22 | Type string `json:"type"` 23 | } `json:"space"` 24 | } `json:"message"` 25 | } 26 | 27 | // Response model corresponds to the response sent by the bot. 28 | type Response struct { 29 | Text string `json:"text"` 30 | } 31 | -------------------------------------------------------------------------------- /pkg/gchat/verify_jwt.go: -------------------------------------------------------------------------------- 1 | package gchat 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/golang-jwt/jwt" 10 | "github.com/lestrrat-go/jwx/v2/jwk" 11 | ) 12 | 13 | const ( 14 | jwkRoot string = "https://www.googleapis.com/service_accounts/v1/jwk/" 15 | chatIssuer string = "chat@system.gserviceaccount.com" 16 | ) 17 | 18 | var ( 19 | refreshInterval = 30 * time.Minute 20 | ) 21 | 22 | // JWKVerifier is a wrapper around the jwk.Cache. 23 | type JWKVerifier struct { 24 | c *jwk.Cache 25 | url string 26 | } 27 | 28 | // NewJWKVerifier creates a new JWKVerifier. 29 | func NewJWKVerifier() (*JWKVerifier, error) { 30 | ctx := context.Background() 31 | c := jwk.NewCache(ctx) 32 | 33 | if err := c.Register(jwkRoot+chatIssuer, 34 | jwk.WithRefreshInterval(refreshInterval)); err != nil { 35 | return nil, err 36 | } 37 | 38 | _, err := c.Refresh(ctx, jwkRoot+chatIssuer) 39 | if err != nil { 40 | return nil, fmt.Errorf("failed to refresh google JWKS: %s\n", err) 41 | } 42 | 43 | return &JWKVerifier{ 44 | c: c, 45 | url: jwkRoot + chatIssuer, 46 | }, nil 47 | } 48 | 49 | // VerifyJWT verifies the JWT token. 50 | func (jv *JWKVerifier) VerifyJWT(audience, tokenRaw string) error { 51 | token, err := jwt.Parse(tokenRaw, func(token *jwt.Token) (interface{}, error) { 52 | kid, ok := token.Header["kid"].(string) 53 | if !ok { 54 | return nil, errors.New("missing kid") 55 | } 56 | 57 | ctx := context.Background() 58 | jwSet, err := jv.c.Get(ctx, jv.url) 59 | if err != nil { 60 | return nil, fmt.Errorf("failed to get google JWKS: %s\n", err) 61 | } 62 | 63 | if key, ok := jwSet.LookupKeyID(kid); ok { 64 | var pubkey interface{} 65 | if err := key.Raw(&pubkey); err != nil { 66 | return nil, err 67 | } 68 | 69 | return pubkey, nil 70 | } 71 | 72 | return nil, errors.New("failed to find key") 73 | }) 74 | 75 | if err != nil { 76 | return err 77 | } 78 | 79 | claims, ok := token.Claims.(jwt.MapClaims) 80 | if !ok { 81 | return errors.New("failed to parse claims") 82 | } 83 | 84 | for key, value := range claims { 85 | if key == "aud" && value == audience { 86 | return nil 87 | } 88 | } 89 | 90 | return errors.New("failed to authenticate token") 91 | } 92 | -------------------------------------------------------------------------------- /pkg/openai/client.go: -------------------------------------------------------------------------------- 1 | package openai 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | ) 9 | 10 | // Client is the OpenAI API client. 11 | type Client struct { 12 | hc *http.Client 13 | cfg ClientConfig 14 | } 15 | 16 | // ClientConfig is the configuration for the OpenAI API client. 17 | type ClientConfig struct { 18 | HTTPClient *http.Client 19 | APIKey string 20 | RootURL string 21 | } 22 | 23 | // NewClient creates a new OpenAI API client. 24 | func NewClient(cfg ClientConfig) (*Client, error) { 25 | hc := cfg.HTTPClient 26 | if hc == nil { 27 | hc = http.DefaultClient 28 | } 29 | 30 | if cfg.RootURL == "" { 31 | cfg.RootURL = APIURLv1 32 | } 33 | 34 | return &Client{ 35 | hc: cfg.HTTPClient, 36 | cfg: cfg, 37 | }, nil 38 | } 39 | 40 | // ChatCompletionRequest is the request for the chat completion endpoint. 41 | func (c *Client) ChatCompletion(req ChatCompletionRequest) (*ChatCompletionResponse, error) { 42 | b, err := json.Marshal(req) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | hr, err := http.NewRequest(http.MethodPost, 48 | c.cfg.RootURL+chatCompletionEndpoint, bytes.NewReader(b)) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | hr.Header.Add("Content-Type", "application/json") 54 | hr.Header.Add("Authorization", "Bearer "+c.cfg.APIKey) 55 | 56 | resp, err := c.hc.Do(hr) 57 | if err != nil { 58 | return nil, err 59 | } 60 | defer resp.Body.Close() 61 | 62 | if resp.StatusCode != http.StatusOK { 63 | return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 64 | } 65 | 66 | var data ChatCompletionResponse 67 | if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { 68 | return nil, err 69 | } 70 | 71 | return &data, nil 72 | } 73 | -------------------------------------------------------------------------------- /pkg/openai/models.go: -------------------------------------------------------------------------------- 1 | package openai 2 | 3 | const ( 4 | APIURLv1 = "https://api.openai.com/v1" 5 | chatCompletionEndpoint = "/chat/completions" 6 | 7 | RoleSystem = "system" 8 | RoleUser = "user" 9 | RoleAssistant = "assistant" 10 | ) 11 | 12 | // ChatCompletionMessage is a message in a chat completion request. 13 | type ChatCompletionMessage struct { 14 | Role string `json:"role"` 15 | Content string `json:"content"` 16 | } 17 | 18 | // ChatCompletionRequest is the request for the chat completion endpoint. 19 | type ChatCompletionRequest struct { 20 | Model string `json:"model"` 21 | Messages []ChatCompletionMessage `json:"messages"` 22 | } 23 | 24 | // ChatCompletionResponse is the response from the chat completion endpoint. 25 | type ChatCompletionResponse struct { 26 | Choices []struct { 27 | Message ChatCompletionMessage `json:"message"` 28 | } `json:"choices"` 29 | } 30 | -------------------------------------------------------------------------------- /pkg/openai/respond.go: -------------------------------------------------------------------------------- 1 | package openai 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | ) 7 | 8 | const ( 9 | defaultModel = "gpt-3.5-turbo" 10 | ) 11 | 12 | // Store is a thread-safe map with a TTL. 13 | type Store struct { 14 | cache sync.Map 15 | ttl int64 16 | maxHistory int 17 | } 18 | 19 | // StoreValue is the value stored in the Store. 20 | type StoreValue struct { 21 | Messages []ChatCompletionMessage 22 | TTL int64 23 | } 24 | 25 | // NewStore creates a new Store. 26 | func NewStore(ttl int64, maxHistory int) *Store { 27 | s := &Store{ 28 | ttl: ttl, 29 | maxHistory: maxHistory, 30 | } 31 | 32 | go func() { 33 | for { 34 | s.cleanup() 35 | time.Sleep(time.Hour * 1) // TODO: make configurable, hardcoded to 1 hour 36 | } 37 | }() 38 | 39 | return s 40 | } 41 | 42 | func (s *Store) cleanup() { 43 | s.cache.Range(func(key, val interface{}) bool { 44 | v, ok := val.(StoreValue) 45 | if !ok { 46 | return true 47 | } 48 | 49 | if v.TTL < time.Now().Unix() { 50 | s.cache.Delete(key) 51 | } 52 | 53 | return true 54 | }) 55 | } 56 | 57 | // Get gets the value from the Store. 58 | func (s *Store) Get(key string) ([]ChatCompletionMessage, bool) { 59 | val, ok := s.cache.Load(key) 60 | if !ok { 61 | return nil, false 62 | } 63 | 64 | v, ok := val.(StoreValue) 65 | if !ok { 66 | return nil, false 67 | } 68 | 69 | if v.TTL < time.Now().Unix() { 70 | s.cache.Delete(key) 71 | return nil, false 72 | } 73 | 74 | return v.Messages, true 75 | } 76 | 77 | // Set sets the value in the Store. 78 | func (s *Store) Set(key string, val []ChatCompletionMessage) { 79 | s.cache.Store(key, StoreValue{ 80 | Messages: val, 81 | TTL: time.Now().Unix() + s.ttl, 82 | }) 83 | } 84 | 85 | // ResponderOptions are the options for the Responder. 86 | type ResponderOptions struct { 87 | Client *Client 88 | Store *Store 89 | 90 | Model string 91 | } 92 | 93 | // Responder is a wrapper around the OpenAI API. 94 | type Responder struct { 95 | client *Client 96 | store *Store 97 | model string 98 | } 99 | 100 | // NewResponder creates a new Responder. 101 | func NewResponder(opt ResponderOptions) *Responder { 102 | if opt.Model == "" { 103 | opt.Model = defaultModel 104 | } 105 | 106 | return &Responder{ 107 | client: opt.Client, 108 | store: opt.Store, 109 | model: opt.Model, 110 | } 111 | } 112 | 113 | // Respond responds to the prompts. InteractionKey is used to cache the 114 | // responses. 115 | func (r *Responder) Respond(interactionKey string, prompts []ChatCompletionMessage) (string, error) { 116 | // check if we have a cached response 117 | msgs, ok := r.store.Get(interactionKey) 118 | if ok { 119 | prompts = append(msgs, prompts...) 120 | } 121 | 122 | resp, err := r.client.ChatCompletion(ChatCompletionRequest{ 123 | Model: r.model, 124 | Messages: prompts, 125 | }) 126 | if err != nil { 127 | return "", err 128 | } 129 | 130 | // cache the response 131 | msgsRecv := []ChatCompletionMessage{} 132 | for _, msg := range resp.Choices { 133 | msgsRecv = append(msgsRecv, msg.Message) 134 | } 135 | 136 | prompts = append(prompts, msgsRecv...) 137 | 138 | // if cache size is greater than maxHistory, remove the oldest message 139 | // but keep the first message as that is the pre-prompt 140 | if len(prompts) > r.store.maxHistory { 141 | prompts = append(prompts[:1], prompts[2:]...) 142 | } 143 | 144 | r.store.Set(interactionKey, prompts) 145 | 146 | return resp.Choices[0].Message.Content, nil 147 | } 148 | 149 | // RespondWithPrompt responds to the prompts. InteractionKey is used to cache the 150 | // responses. It is a utility function that adds the prompt to the prompts. 151 | func (r *Responder) RespondWithPrompt(interactionKey, prePrompt, prompt string) (string, error) { 152 | prompts := []ChatCompletionMessage{} 153 | 154 | // If there is a cached response, don't send the pre-prompt again. 155 | if _, ok := r.store.Get(interactionKey); !ok { 156 | if prePrompt != "" { 157 | prompts = append(prompts, ChatCompletionMessage{ 158 | Role: RoleSystem, 159 | Content: prePrompt, 160 | }) 161 | } 162 | } 163 | 164 | prompts = append(prompts, ChatCompletionMessage{ 165 | Role: RoleUser, 166 | Content: prompt, 167 | }) 168 | 169 | return r.Respond(interactionKey, prompts) 170 | } 171 | -------------------------------------------------------------------------------- /pkg/server/app.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | 7 | "github.com/zerodhatech/gchatgpt/pkg/gchat" 8 | "github.com/zerodhatech/gchatgpt/pkg/openai" 9 | ) 10 | 11 | const ( 12 | defaultPrePrompt = "You are a chat bot in a thread with multiple users. You will receive messages in the format :. Respond in a non-verbose and to-the-point manner." 13 | ) 14 | 15 | // Options contains the configuration for the server. 16 | type Options struct { 17 | OpenAIKey string 18 | BotAppID string 19 | PrePrompt string 20 | 21 | Address string 22 | 23 | OpenAI *openai.Responder 24 | JWKVerifier *gchat.JWKVerifier 25 | } 26 | 27 | // App is the server. 28 | type App struct { 29 | cfg Options 30 | } 31 | 32 | // New creates a new server. 33 | func New(cfg Options) *App { 34 | if cfg.Address == "" { 35 | cfg.Address = ":1234" 36 | } 37 | 38 | if cfg.PrePrompt == "" { 39 | cfg.PrePrompt = defaultPrePrompt 40 | } 41 | 42 | return &App{ 43 | cfg: cfg, 44 | } 45 | } 46 | 47 | // Run starts the server. 48 | func (app *App) Run() error { 49 | http.HandleFunc("/", app.HandleGChat) 50 | 51 | log.Println("Starting server on ", app.cfg.Address) 52 | 53 | return http.ListenAndServe(app.cfg.Address, nil) 54 | } 55 | -------------------------------------------------------------------------------- /pkg/server/handlers.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "math/rand" 8 | "net/http" 9 | "strings" 10 | "time" 11 | 12 | "github.com/zerodhatech/gchatgpt/pkg/gchat" 13 | ) 14 | 15 | // HandleGChat handles the Google Chat webhook. 16 | func (app *App) HandleGChat(w http.ResponseWriter, r *http.Request) { 17 | if r.Method != http.MethodPost { 18 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 19 | return 20 | } 21 | 22 | bearerToken := r.Header.Get("Authorization") 23 | token := strings.Split(bearerToken, " ") 24 | err := app.cfg.JWKVerifier.VerifyJWT(app.cfg.BotAppID, token[1]) 25 | if len(token) != 2 || err != nil { 26 | log.Println("Error verifying JWT: ", err) 27 | http.Error(w, "Unauthorized", http.StatusForbidden) 28 | return 29 | } 30 | 31 | message := &gchat.Event{} 32 | err = json.NewDecoder(r.Body).Decode(&message) 33 | if err != nil { 34 | log.Println("Error decoding message: ", err) 35 | response := gchat.Response{Text: "Sorry, I couldn't decode your message."} 36 | json.NewEncoder(w).Encode(response) 37 | return 38 | } 39 | 40 | // If the bot is added to a space, respond with a welcome message. 41 | if message.Type == "ADDED_TO_SPACE" { 42 | response := gchat.Response{Text: "Thanks for adding me!"} 43 | json.NewEncoder(w).Encode(response) 44 | return 45 | } 46 | 47 | // If the message type is not a message, ignore it. 48 | if message.Type != "MESSAGE" { 49 | response := gchat.Response{Text: "Sorry, I didn't understand your message."} 50 | json.NewEncoder(w).Encode(response) 51 | return 52 | } 53 | 54 | userName := message.Message.Sender.DisplayName 55 | 56 | // Cleanup the prompt message. Argument text has a leading space usually. 57 | prompt := fmt.Sprintf("%s: %s", userName, strings.TrimSpace(message.Message.ArgumentText)) 58 | 59 | interactionKey := getInteractionKey(message) 60 | 61 | // Send the prompt to OpenAI and get a response. 62 | response, err := app.cfg.OpenAI.RespondWithPrompt( 63 | interactionKey, 64 | app.cfg.PrePrompt, 65 | prompt, 66 | ) 67 | 68 | if err != nil { 69 | log.Println("Error getting response from OpenAI: ", err) 70 | response := gchat.Response{Text: "Sorry, I couldn't communicate your message to OpenAI."} 71 | json.NewEncoder(w).Encode(response) 72 | return 73 | } 74 | 75 | out := gchat.Response{Text: response} 76 | if err := json.NewEncoder(w).Encode(out); err != nil { 77 | log.Println("Error encoding response: ", err) 78 | response := gchat.Response{Text: "Sorry, I couldn't encode the response to you correctly."} 79 | json.NewEncoder(w).Encode(response) 80 | return 81 | } 82 | } 83 | 84 | // getInteractionKey returns the interaction key for the message. TODO: This is 85 | // a temporary implementation. We need to come up with a better way to 86 | // identify the interaction. 87 | func getInteractionKey(message *gchat.Event) string { 88 | switch message.Message.Space.Type { 89 | case gchat.SpaceTypeDM: 90 | return message.Message.Space.Name 91 | case gchat.SpaceTypeRoom: 92 | return message.Message.Thread.Name 93 | default: 94 | // Return a generated random string for now. 95 | return randomString(10) 96 | } 97 | } 98 | 99 | func randomString(length int) string { 100 | // Define the character set from which to generate the random string 101 | const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 102 | 103 | // Seed the random number generator 104 | rand.Seed(time.Now().UnixNano()) 105 | 106 | // Create a byte slice of the specified length 107 | randomBytes := make([]byte, length) 108 | 109 | // Fill the byte slice with random characters from the character set 110 | for i := 0; i < length; i++ { 111 | randomBytes[i] = charset[rand.Intn(len(charset))] 112 | } 113 | 114 | // Convert the byte slice to a string and return it 115 | return string(randomBytes) 116 | } 117 | --------------------------------------------------------------------------------