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