├── .github ├── FUNDING.yml └── workflows │ ├── ci.yml │ └── release.yml ├── pkg ├── memory │ ├── memory.go │ └── fixed │ │ └── fixed.go ├── bing │ ├── conn.go │ └── bing.go ├── openai │ └── openai.go └── chatgpt │ └── chatgpt.go ├── .goreleaser.yml ├── Dockerfile ├── .gitignore ├── internal ├── scrapfly │ └── scrapfly.go ├── google │ └── google.go ├── command │ ├── command_test.go │ └── command.go ├── ratelimit │ └── ratelimit.go ├── web │ └── web.go ├── http │ ├── cookies.go │ ├── client_test.go │ ├── ja3.go │ ├── client.go │ └── connect.go ├── prompt │ └── prompt.go └── session │ └── session.go ├── LICENSE ├── go.mod ├── Makefile ├── cmd └── igogpt │ └── main.go ├── README.md ├── go.sum └── igogpt.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: igolaizola 2 | custom: ["https://ko-fi.com/igolaizola", "https://buymeacoffee.com/igolaizola", "https://paypal.me/igolaizola"] 3 | -------------------------------------------------------------------------------- /pkg/memory/memory.go: -------------------------------------------------------------------------------- 1 | package memory 2 | 3 | type Message struct { 4 | Role string 5 | Content string 6 | } 7 | 8 | type Memory interface { 9 | Add(Message) error 10 | Sum() ([]Message, error) 11 | } 12 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | builds: 2 | - id: igogpt 3 | binary: igogpt 4 | main: ./cmd/igogpt 5 | goarch: 6 | - amd64 7 | - arm64 8 | - arm 9 | archives: 10 | - id: igogpt 11 | builds: 12 | - igogpt 13 | format: zip 14 | name_template: 'igogpt_{{ .Version }}_{{- if eq .Os "darwin" }}macos{{- else }}{{ .Os }}{{ end }}_{{ .Arch }}' 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # builder image 2 | FROM golang:alpine as builder 3 | ARG TARGETPLATFORM 4 | COPY . /src 5 | WORKDIR /src 6 | RUN apk add --no-cache make bash git 7 | RUN make app-build PLATFORMS=$TARGETPLATFORM 8 | 9 | # running image 10 | FROM alpine 11 | WORKDIR /home 12 | COPY --from=builder /src/bin/igogpt-* /bin/igogpt 13 | 14 | # executable 15 | ENTRYPOINT [ "/bin/igogpt" ] 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | bin 8 | 9 | # Test binary, built with `go test -c` 10 | *.test 11 | 12 | # Output of the go coverage tool, specifically when used with LiteIDE 13 | *.out 14 | 15 | # Dependency directories (remove the comment below to include it) 16 | # vendor/ 17 | 18 | # Other 19 | .history 20 | .vscode 21 | *.conf 22 | *.yaml 23 | output 24 | logs 25 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | ci: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | - name: Setup go 18 | uses: actions/setup-go@v4 19 | with: 20 | go-version-file: 'go.mod' 21 | - name: Build 22 | run: go build -v ./... 23 | - name: Lint 24 | uses: golangci/golangci-lint-action@v3 25 | - name: Test 26 | run: go test -v ./... 27 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | with: 17 | fetch-depth: 0 18 | - run: git fetch --force --tags 19 | - uses: actions/setup-go@v3 20 | with: 21 | go-version-file: 'go.mod' 22 | cache: true 23 | - uses: goreleaser/goreleaser-action@v4 24 | with: 25 | distribution: goreleaser 26 | version: latest 27 | args: release --clean 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | -------------------------------------------------------------------------------- /internal/scrapfly/scrapfly.go: -------------------------------------------------------------------------------- 1 | package scrapfly 2 | 3 | const ( 4 | // JA3URL is the URL to the JA3 API 5 | FPJA3URL = "https://tools.scrapfly.io/api/fp/ja3" 6 | // HTTPURL is the URL to the HTTP API 7 | InfoHTTPURL = "https://tools.scrapfly.io/api/info/http" 8 | ) 9 | 10 | type FPJA3 struct { 11 | JA3 string `json:"ja3"` 12 | Digest string `json:"digest"` 13 | } 14 | 15 | type InfoHTTP struct { 16 | Headers Headers `json:"headers"` 17 | } 18 | 19 | type Headers struct { 20 | UserAgent UserAgent `json:"user_agent"` 21 | RawHeaders []string `json:"raw_headers"` 22 | ParsedHeaders map[string][]string `json:"parsed_headers"` 23 | } 24 | 25 | type UserAgent struct { 26 | Payload string `json:"payload"` 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Iñigo Garcia Olaizola 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /internal/google/google.go: -------------------------------------------------------------------------------- 1 | package google 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | ) 11 | 12 | const ( 13 | apiURL = "https://www.googleapis.com/customsearch/v1" 14 | cx = "YOUR_CX" 15 | ) 16 | 17 | type SearchResponse struct { 18 | Items []SearchResult `json:"items"` 19 | } 20 | 21 | type SearchResult struct { 22 | Title string `json:"title"` 23 | Link string `json:"link"` 24 | } 25 | 26 | func Search(ctx context.Context, key, cx, query string) ([]SearchResult, error) { 27 | reqURL := fmt.Sprintf("%s?key=%s&cx=%s&q=%s", apiURL, key, cx, url.QueryEscape(query)) 28 | response, err := http.Get(reqURL) 29 | if err != nil { 30 | return nil, fmt.Errorf("error making HTTP request: %w", err) 31 | } 32 | defer response.Body.Close() 33 | 34 | body, err := io.ReadAll(response.Body) 35 | if err != nil { 36 | return nil, fmt.Errorf("error reading response body: %w", err) 37 | } 38 | 39 | var searchResponse SearchResponse 40 | if err := json.Unmarshal(body, &searchResponse); err != nil { 41 | return nil, fmt.Errorf("error unmarshaling JSON response: %w", err) 42 | } 43 | 44 | return searchResponse.Items, nil 45 | } 46 | -------------------------------------------------------------------------------- /internal/command/command_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestParse(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | input string 12 | want []CommandRequest 13 | }{ 14 | { 15 | name: "not-array", 16 | input: `[{"talk":"blah"}]`, 17 | want: []CommandRequest{{Name: "talk", Args: []any{"blah"}}}, 18 | }, 19 | { 20 | name: "separated-arrays", 21 | input: `[{"talk":"blah"}] [{"talk":"blah"}] [{"talk":"blah"}]`, 22 | want: []CommandRequest{ 23 | {Name: "talk", Args: []any{"blah"}}, 24 | {Name: "talk", Args: []any{"blah"}}, 25 | {Name: "talk", Args: []any{"blah"}}, 26 | }, 27 | }, 28 | { 29 | name: "write", 30 | input: `[{"write": ["file.txt", "hello world"]}]`, 31 | want: []CommandRequest{ 32 | {Name: "write", Args: []any{"file.txt", "hello world"}}, 33 | }, 34 | }, 35 | } 36 | for _, tt := range tests { 37 | t.Run(tt.name, func(t *testing.T) { 38 | got, err := Parse(tt.input) 39 | if err != nil { 40 | t.Fatal(err) 41 | } 42 | if !reflect.DeepEqual(got, tt.want) { 43 | t.Errorf("Parse() = %v, want %v", got, tt.want) 44 | } 45 | }) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /internal/ratelimit/ratelimit.go: -------------------------------------------------------------------------------- 1 | package ratelimit 2 | 3 | import ( 4 | "context" 5 | "math/rand" 6 | "sync" 7 | "time" 8 | ) 9 | 10 | type Lock interface { 11 | Lock(ctx context.Context) func() 12 | LockWithDuration(ctx context.Context, d time.Duration) func() 13 | } 14 | 15 | type lock struct { 16 | lck *sync.Mutex 17 | duration time.Duration 18 | } 19 | 20 | // New creates a new rate limit lock. 21 | func New(d time.Duration) Lock { 22 | return &lock{ 23 | lck: &sync.Mutex{}, 24 | duration: d, 25 | } 26 | } 27 | 28 | // Lock locks the rate limit for the given duration and returns a function that 29 | // unlocks the rate limit with a delay time based on the given duration. 30 | func (l *lock) LockWithDuration(ctx context.Context, d time.Duration) func() { 31 | l.lck.Lock() 32 | // Apply a factor between 0.85 and 1.15 to the duration 33 | d = time.Duration(float64(d) * (0.85 + rand.Float64()*0.3)) 34 | return func() { 35 | defer l.lck.Unlock() 36 | select { 37 | case <-ctx.Done(): 38 | case <-time.After(d): 39 | } 40 | } 41 | } 42 | 43 | // Lock locks the rate limit for the default duration and returns a function that 44 | // unlocks the rate limit with a delay time based on the default duration. 45 | func (l *lock) Lock(ctx context.Context) func() { 46 | return l.LockWithDuration(ctx, l.duration) 47 | } 48 | -------------------------------------------------------------------------------- /internal/web/web.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | "time" 9 | 10 | "github.com/PuerkitoBio/goquery" 11 | ) 12 | 13 | // Text returns the text of the given URL. 14 | func Text(ctx context.Context, u string) (string, error) { 15 | // Add protocol if missing. 16 | if !strings.HasPrefix(u, "http") && !strings.HasPrefix(u, "https") { 17 | u = "https://" + u 18 | } 19 | 20 | // Create client and request. 21 | client := &http.Client{ 22 | Timeout: 30 * time.Second, 23 | } 24 | req, err := http.NewRequest("GET", u, nil) 25 | if err != nil { 26 | return "", fmt.Errorf("web: couldn't create request: %w", err) 27 | } 28 | req = req.WithContext(ctx) 29 | 30 | // Get response. 31 | resp, err := client.Do(req) 32 | if err != nil { 33 | return "", fmt.Errorf("web: couldn't get response: %w", err) 34 | } 35 | defer resp.Body.Close() 36 | 37 | // Parse response. 38 | doc, err := goquery.NewDocumentFromReader(resp.Body) 39 | if err != nil { 40 | return "", fmt.Errorf("web: couldn't parse response: %w", err) 41 | } 42 | text := doc.Text() 43 | 44 | // Condense the text. 45 | text = strings.TrimSpace(text) 46 | for _, c := range []string{"\t", "\n", "\r"} { 47 | text = strings.ReplaceAll(text, c, " ") 48 | } 49 | for i := 0; i < 10; i++ { 50 | text = strings.ReplaceAll(text, " ", " ") 51 | } 52 | 53 | // Limit the text. 54 | if len(text) > 1000 { 55 | text = text[:1000] 56 | } 57 | return text, nil 58 | } 59 | -------------------------------------------------------------------------------- /internal/http/cookies.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strings" 7 | 8 | http "github.com/Danny-Dasilva/fhttp" 9 | "github.com/Danny-Dasilva/fhttp/cookiejar" 10 | ) 11 | 12 | func SetCookies(c *http.Client, rawURL string, rawCookies string) error { 13 | if c.Jar == nil { 14 | jar, err := cookiejar.New(nil) 15 | if err != nil { 16 | return fmt.Errorf("http: failed to create cookie jar: %w", err) 17 | } 18 | c.Jar = jar 19 | } 20 | u, err := url.Parse(rawURL) 21 | if err != nil { 22 | return fmt.Errorf("http: invalid url: %v", rawURL) 23 | } 24 | var cookies []*http.Cookie 25 | for _, cookie := range strings.Split(rawCookies, ";") { 26 | cookie = strings.TrimSpace(cookie) 27 | if cookie == "" { 28 | continue 29 | } 30 | parts := strings.SplitN(cookie, "=", 2) 31 | if len(parts) != 2 { 32 | return fmt.Errorf("http: invalid cookie: %v", cookie) 33 | } 34 | cookies = append(cookies, &http.Cookie{Name: parts[0], Value: parts[1]}) 35 | } 36 | c.Jar.SetCookies(u, cookies) 37 | return nil 38 | } 39 | 40 | func GetCookies(c *http.Client, rawURL string) (string, error) { 41 | if c.Jar == nil { 42 | return "", fmt.Errorf("http: missing cookie jar") 43 | } 44 | u, err := url.Parse(rawURL) 45 | if err != nil { 46 | return "", fmt.Errorf("http: invalid url: %v", rawURL) 47 | } 48 | var cookies []string 49 | for _, cookie := range c.Jar.Cookies(u) { 50 | cookies = append(cookies, cookie.String()) 51 | } 52 | return strings.Join(cookies, "; "), nil 53 | } 54 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/igolaizola/igogpt 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/Danny-Dasilva/fhttp v0.0.0-20220524230104-f801520157d6 7 | github.com/Danny-Dasilva/utls v0.0.0-20220604023528-30cb107b834e 8 | github.com/JohannesKaufmann/html-to-markdown v1.4.0 9 | github.com/PuerkitoBio/goquery v1.8.1 10 | github.com/PullRequestInc/go-gpt3 v1.1.15 11 | github.com/chromedp/cdproto v0.0.0-20230413093208-7497fc11fc57 12 | github.com/chromedp/chromedp v0.9.1 13 | github.com/dsnet/compress v0.0.1 14 | github.com/go-rod/stealth v0.4.8 15 | github.com/google/uuid v1.3.0 16 | github.com/gorilla/websocket v1.5.0 17 | github.com/pavel-one/EdgeGPT-Go v1.2.0 18 | github.com/peterbourgon/ff/v3 v3.3.0 19 | github.com/tiktoken-go/tokenizer v0.1.0 20 | golang.org/x/net v0.9.0 21 | gopkg.in/yaml.v2 v2.4.0 22 | gopkg.in/yaml.v3 v3.0.1 23 | ) 24 | 25 | require ( 26 | github.com/andybalholm/cascadia v1.3.1 // indirect 27 | github.com/chromedp/sysutil v1.0.0 // indirect 28 | github.com/dlclark/regexp2 v1.9.0 // indirect 29 | github.com/go-rod/rod v0.112.0 // indirect 30 | github.com/gobwas/httphead v0.1.0 // indirect 31 | github.com/gobwas/pool v0.2.1 // indirect 32 | github.com/gobwas/ws v1.1.0 // indirect 33 | github.com/josharian/intern v1.0.0 // indirect 34 | github.com/mailru/easyjson v0.7.7 // indirect 35 | github.com/stretchr/testify v1.8.0 // indirect 36 | github.com/ysmood/goob v0.4.0 // indirect 37 | github.com/ysmood/gson v0.7.1 // indirect 38 | github.com/ysmood/leakless v0.8.0 // indirect 39 | golang.org/x/crypto v0.0.0-20220518034528-6f7dac969898 // indirect 40 | golang.org/x/sys v0.7.0 // indirect 41 | golang.org/x/text v0.9.0 // indirect 42 | ) 43 | -------------------------------------------------------------------------------- /pkg/memory/fixed/fixed.go: -------------------------------------------------------------------------------- 1 | package fixed 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/igolaizola/igogpt/pkg/memory" 7 | "github.com/tiktoken-go/tokenizer" 8 | ) 9 | 10 | type fixedMemory struct { 11 | keepFirst int 12 | maxTokens int 13 | messages []memory.Message 14 | } 15 | 16 | func NewFixedMemory(keepFirst, maxTokens int) *fixedMemory { 17 | return &fixedMemory{ 18 | keepFirst: keepFirst, 19 | maxTokens: maxTokens, 20 | } 21 | } 22 | 23 | func (m *fixedMemory) Add(msg memory.Message) error { 24 | m.messages = append(m.messages, msg) 25 | return nil 26 | } 27 | 28 | func (m *fixedMemory) Sum() ([]memory.Message, error) { 29 | // Keep first messages 30 | first := []memory.Message{} 31 | rest := m.messages 32 | if m.keepFirst > 0 && len(m.messages) > m.keepFirst { 33 | first = m.messages[:m.keepFirst] 34 | rest = m.messages[m.keepFirst:] 35 | } 36 | 37 | // Count tokens and remove oldest messages if needed 38 | if m.maxTokens > 0 { 39 | for { 40 | tokens, err := Tokens(append(first, rest...)) 41 | if err != nil { 42 | return nil, fmt.Errorf("openai: couldn't count tokens: %w", err) 43 | } 44 | // Leave some tokens for the response 45 | if tokens+1000 <= m.maxTokens { 46 | break 47 | } 48 | if len(rest) == 1 { 49 | return nil, fmt.Errorf("openai: prompt too long (%d tokens)", tokens) 50 | } 51 | rest = rest[1:] 52 | } 53 | } 54 | return append(first, rest...), nil 55 | } 56 | 57 | func Tokens(messages []memory.Message) (int, error) { 58 | text := "" 59 | for _, message := range messages { 60 | text += message.Content + "\n" 61 | } 62 | 63 | enc, err := tokenizer.Get(tokenizer.Cl100kBase) 64 | if err != nil { 65 | return 0, fmt.Errorf("openai: couldn't get tokenizer: %w", err) 66 | } 67 | 68 | // Encode to obtain the list of tokens 69 | ids, _, _ := enc.Encode(text) 70 | tokens := len(ids) 71 | 72 | // Add 8 tokens extra per message 73 | tokens = tokens + (len(messages) * 8) 74 | return tokens, nil 75 | } 76 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | SHELL = /bin/bash 4 | PLATFORMS ?= linux/amd64 darwin/amd64 windows/amd64 5 | IMAGE_PREFIX ?= igolaizola 6 | REPO_NAME ?= igogpt 7 | COMMIT_SHORT ?= $(shell git rev-parse --verify --short HEAD) 8 | VERSION ?= $(COMMIT_SHORT) 9 | VERSION_NOPREFIX ?= $(shell echo $(VERSION) | sed -e 's/^[[v]]*//') 10 | 11 | # Build the binaries for the current platform 12 | .PHONY: build 13 | build: 14 | os=$$(go env GOOS); \ 15 | arch=$$(go env GOARCH); \ 16 | PLATFORMS="$$os/$$arch" make app-build 17 | 18 | # Build the binaries 19 | # Example: PLATFORMS=linux/amd64 make app-build 20 | .PHONY: app-build 21 | app-build: 22 | @for platform in $(PLATFORMS) ; do \ 23 | os=$$(echo $$platform | cut -f1 -d/); \ 24 | arch=$$(echo $$platform | cut -f2 -d/); \ 25 | arm=$$(echo $$platform | cut -f3 -d/); \ 26 | arm=$${arm#v}; \ 27 | ext=""; \ 28 | if [ "$$os" == "windows" ]; then \ 29 | ext=".exe"; \ 30 | fi; \ 31 | file=./bin/$(REPO_NAME)-$(VERSION_NOPREFIX)-$$(echo $$platform | tr / -)$$ext; \ 32 | GOOS=$$os GOARCH=$$arch GOARM=$$arm CGO_ENABLED=0 \ 33 | go build \ 34 | -a -x -tags netgo,timetzdata -installsuffix cgo -installsuffix netgo \ 35 | -ldflags " \ 36 | -X main.Version=$(VERSION_NOPREFIX) \ 37 | -X main.GitRev=$(COMMIT_SHORT) \ 38 | " \ 39 | -o $$file \ 40 | ./cmd/$(REPO_NAME); \ 41 | if [ $$? -ne 0 ]; then \ 42 | exit 1; \ 43 | fi; \ 44 | chmod +x $$file; \ 45 | done 46 | 47 | # Build the docker image 48 | # Example: PLATFORMS=linux/amd64 make docker-build 49 | .PHONY: docker-build 50 | docker-build: 51 | @platforms=($(PLATFORMS)); \ 52 | platform=$${platforms[0]}; \ 53 | if [[ $${#platforms[@]} -ne 1 ]]; then \ 54 | echo "Multi-arch build not supported"; \ 55 | exit 1; \ 56 | fi; \ 57 | docker build --platform $$platform -t $(IMAGE_PREFIX)/$(REPO_NAME):$(VERSION) .; \ 58 | if [ $$? -ne 0 ]; then \ 59 | exit 1; \ 60 | fi 61 | 62 | # Build the docker images using buildx 63 | # Example: PLATFORMS="linux/amd64 darwin/amd64 windows/amd64" make docker-buildx 64 | .PHONY: docker-buildx 65 | docker-buildx: 66 | @platforms=($(PLATFORMS)); \ 67 | platform=$$(IFS=, ; echo "$${platforms[*]}"); \ 68 | docker buildx build --platform $$platform -t $(IMAGE_PREFIX)/$(REPO_NAME):$(VERSION) . 69 | 70 | # Clean binaries 71 | .PHONY: clean 72 | clean: 73 | rm -rf bin 74 | -------------------------------------------------------------------------------- /pkg/bing/conn.go: -------------------------------------------------------------------------------- 1 | package bing 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "encoding/hex" 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | "log" 11 | "sync" 12 | 13 | "github.com/gorilla/websocket" 14 | "github.com/igolaizola/igogpt/internal/ratelimit" 15 | "github.com/pavel-one/EdgeGPT-Go/responses" 16 | ) 17 | 18 | const ( 19 | styleCreative = "h3relaxedimg" 20 | styleBalanced = "galileo" 21 | stylePrecise = "h3precise" 22 | delimiterByte = uint8(30) 23 | delimiter = "\x1e" 24 | ) 25 | 26 | type conn struct { 27 | ctx context.Context 28 | cancel context.CancelFunc 29 | ws *websocket.Conn 30 | conversation *Conversation 31 | invocationID int 32 | lck sync.Mutex 33 | pipeReader *io.PipeReader 34 | pipeWriter *io.PipeWriter 35 | rateLimit ratelimit.Lock 36 | } 37 | 38 | // Read reads from the chat. 39 | func (c *conn) Read(b []byte) (n int, err error) { 40 | if c.ctx.Err() != nil { 41 | return 0, c.ctx.Err() 42 | } 43 | return c.pipeReader.Read(b) 44 | } 45 | 46 | // Write writes to the chat. 47 | func (c *conn) Write(b []byte) (n int, err error) { 48 | message := string(b) 49 | if len(message) > 2000 { 50 | return 0, fmt.Errorf("bing: message very long, max: %d", 2000) 51 | } 52 | 53 | // Rate limit requests 54 | unlock := c.rateLimit.Lock(c.ctx) 55 | defer unlock() 56 | 57 | m, err := c.send(message) 58 | if err != nil { 59 | return 0, err 60 | } 61 | 62 | go func() { 63 | if err := m.Worker(); err != nil { 64 | log.Println(fmt.Errorf("bing: failed to get answer: %w", err)) 65 | } 66 | }() 67 | 68 | for range m.Chan { 69 | if m.Final { 70 | break 71 | } 72 | } 73 | 74 | go func() { 75 | if _, err := c.pipeWriter.Write([]byte(m.Answer.GetAnswer())); err != nil { 76 | log.Println(fmt.Errorf("bing: failed to write to pipe: %w", err)) 77 | } 78 | }() 79 | return len(b), nil 80 | } 81 | 82 | func (c *conn) send(message string) (*responses.MessageWrapper, error) { 83 | c.lck.Lock() 84 | 85 | m, err := json.Marshal(c.getRequest(message)) 86 | if err != nil { 87 | return nil, fmt.Errorf("bing: couldn't marshal request: %w", err) 88 | } 89 | 90 | m = append(m, delimiterByte) 91 | 92 | if err := c.ws.WriteMessage(websocket.TextMessage, m); err != nil { 93 | return nil, fmt.Errorf("bing: couldn't write websocket message: %w", err) 94 | } 95 | 96 | return responses.NewMessageWrapper(message, &c.lck, c.ws), nil 97 | } 98 | 99 | // getRequest generate struct for new request websocket 100 | func (c *conn) getRequest(message string) map[string]any { 101 | rnd := make([]byte, 16) 102 | _, _ = rand.Read(rnd) 103 | traceID := hex.EncodeToString(rnd) 104 | m := map[string]any{ 105 | "invocationId": string(rune(c.invocationID)), 106 | "target": "chat", 107 | "type": 4, 108 | "arguments": []map[string]any{ 109 | { 110 | "source": "cib", 111 | "optionsSets": []string{ 112 | "nlu_direct_response_filter", 113 | "deepleo", 114 | "disable_emoji_spoken_text", 115 | "responsible_ai_policy_235", 116 | "enablemm", 117 | // TODO: make it configurable 118 | styleBalanced, 119 | "dtappid", 120 | "cricinfo", 121 | "cricinfov2", 122 | "dv3sugg", 123 | }, 124 | "sliceIds": []string{ 125 | "222dtappid", 126 | "225cricinfo", 127 | "224locals0", 128 | }, 129 | "traceId": traceID, 130 | "isStartOfSession": c.invocationID == 0, 131 | "message": map[string]any{ 132 | "author": "user", 133 | "inputMethod": "Keyboard", 134 | "text": message, 135 | "messageType": "Chat", 136 | }, 137 | "conversationSignature": c.conversation.ConversationSignature, 138 | "participant": map[string]any{ 139 | "id": c.conversation.ClientId, 140 | }, 141 | "conversationId": c.conversation.ConversationId, 142 | }, 143 | }, 144 | } 145 | c.invocationID++ 146 | 147 | return m 148 | } 149 | -------------------------------------------------------------------------------- /pkg/openai/openai.go: -------------------------------------------------------------------------------- 1 | package openai 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "log" 9 | "time" 10 | 11 | "github.com/PullRequestInc/go-gpt3" 12 | "github.com/igolaizola/igogpt/internal/ratelimit" 13 | "github.com/igolaizola/igogpt/pkg/memory" 14 | ) 15 | 16 | type Client struct { 17 | gpt3.Client 18 | rateLimit ratelimit.Lock 19 | maxTokens int 20 | } 21 | 22 | // New returns a new Client. 23 | func New(key string, wait time.Duration, maxTokens int) *Client { 24 | // Configure rate limit 25 | if wait == 0 { 26 | wait = 5 * time.Second 27 | } 28 | rateLimit := ratelimit.New(wait) 29 | 30 | client := gpt3.NewClient(key, gpt3.WithTimeout(5*time.Minute)) 31 | return &Client{ 32 | Client: client, 33 | rateLimit: rateLimit, 34 | maxTokens: maxTokens, 35 | } 36 | } 37 | 38 | // Chat creates a new chat session. 39 | func (c *Client) Chat(ctx context.Context, model, role string, mem memory.Memory) io.ReadWriter { 40 | ctx, cancel := context.WithCancel(ctx) 41 | rd, wr := io.Pipe() 42 | return &rw{ 43 | client: c, 44 | ctx: ctx, 45 | cancel: cancel, 46 | model: model, 47 | role: role, 48 | memory: mem, 49 | pipeReader: rd, 50 | pipeWriter: wr, 51 | rateLimit: c.rateLimit, 52 | } 53 | } 54 | 55 | type rw struct { 56 | client *Client 57 | ctx context.Context 58 | cancel context.CancelFunc 59 | model string 60 | role string 61 | memory memory.Memory 62 | pipeReader *io.PipeReader 63 | pipeWriter *io.PipeWriter 64 | rateLimit ratelimit.Lock 65 | } 66 | 67 | // Read reads from the chat. 68 | func (r *rw) Read(b []byte) (n int, err error) { 69 | if r.ctx.Err() != nil { 70 | return 0, r.ctx.Err() 71 | } 72 | return r.pipeReader.Read(b) 73 | } 74 | 75 | // Write writes to the chat. 76 | func (r *rw) Write(b []byte) (n int, err error) { 77 | if r.ctx.Err() != nil { 78 | return 0, r.ctx.Err() 79 | } 80 | if err := r.memory.Add(memory.Message{ 81 | Role: r.role, 82 | Content: string(b), 83 | }); err != nil { 84 | return 0, fmt.Errorf("openai: couldn't add message to memory: %w", err) 85 | } 86 | 87 | // Sum memory 88 | sum, err := r.memory.Sum() 89 | if err != nil { 90 | return 0, fmt.Errorf("openai: couldn't sum memory: %w", err) 91 | } 92 | messages := fromMemory(sum) 93 | 94 | // Rate limit requests 95 | unlock := r.rateLimit.Lock(r.ctx) 96 | defer unlock() 97 | 98 | request := &gpt3.ChatCompletionRequest{ 99 | Model: r.model, 100 | Messages: messages, 101 | MaxTokens: r.client.maxTokens, 102 | } 103 | var completion *gpt3.ChatCompletionResponse 104 | for { 105 | // Generate completion 106 | completion, err = r.client.ChatCompletion(r.ctx, *request) 107 | var gptErr *gpt3.APIError 108 | if errors.As(err, &gptErr) && gptErr.StatusCode == 429 { 109 | // Rate limit error, wait and try again 110 | log.Println("openai: too many requests, waiting for 30 seconds...") 111 | select { 112 | case <-time.After(30 * time.Second): 113 | case <-r.ctx.Done(): 114 | return 0, r.ctx.Err() 115 | } 116 | continue 117 | } 118 | if err != nil { 119 | return 0, fmt.Errorf("openai: couldn't generate completion: %w", err) 120 | } 121 | break 122 | } 123 | 124 | if len(completion.Choices) == 0 { 125 | return 0, fmt.Errorf("openai: no choices") 126 | } 127 | response := completion.Choices[0].Message.Content 128 | log.Printf("openai: request tokens %d", completion.Usage.TotalTokens) 129 | 130 | // Add response to memory 131 | if err := r.memory.Add(memory.Message{ 132 | Role: "assistant", 133 | Content: response, 134 | }); err != nil { 135 | return 0, fmt.Errorf("openai: couldn't add message to memory: %w", err) 136 | } 137 | 138 | // Write response to pipe 139 | go func() { 140 | response := response + "\n" 141 | if _, err := r.pipeWriter.Write([]byte(response)); err != nil { 142 | log.Println(fmt.Errorf("openai: failed to write to pipe: %w", err)) 143 | } 144 | }() 145 | return len(b), nil 146 | } 147 | 148 | // Close closes the chat. 149 | func (r *rw) Close() error { 150 | r.cancel() 151 | return r.pipeReader.Close() 152 | } 153 | 154 | func fromMemory(input []memory.Message) []gpt3.ChatCompletionRequestMessage { 155 | var output []gpt3.ChatCompletionRequestMessage 156 | for _, m := range input { 157 | output = append(output, gpt3.ChatCompletionRequestMessage{ 158 | Role: m.Role, 159 | Content: m.Content, 160 | }) 161 | } 162 | return output 163 | } 164 | -------------------------------------------------------------------------------- /internal/http/client_test.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "reflect" 7 | "strings" 8 | "testing" 9 | 10 | http "github.com/Danny-Dasilva/fhttp" 11 | "github.com/igolaizola/igogpt/internal/scrapfly" 12 | ) 13 | 14 | func TestBrowser(t *testing.T) { 15 | // TODO: check this test and decide if we want to fix it or remove it 16 | t.Skip("TODO: fix this") 17 | 18 | ja3 := "772,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,23-16-51-27-10-11-35-17513-18-65281-0-45-43-5-13,29-23-24,0" 19 | userAgent := "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36" 20 | lang := "en-US,en;q=0.9,es;q=0.8" 21 | 22 | client, err := NewGoClient(ja3, userAgent, lang, "") 23 | if err != nil { 24 | t.Fatal(err) 25 | } 26 | 27 | // Obtain ja3 28 | u := strings.Replace(scrapfly.FPJA3URL, "https", "http", 1) 29 | resp, err := client.Get(u) 30 | if err != nil { 31 | t.Fatal(err) 32 | } 33 | defer resp.Body.Close() 34 | 35 | var fpJA3 scrapfly.FPJA3 36 | if err := json.NewDecoder(resp.Body).Decode(&fpJA3); err != nil { 37 | t.Fatal(err) 38 | } 39 | if fpJA3.JA3 != ja3 { 40 | t.Fatalf("got: %s, want: %s", fpJA3.JA3, ja3) 41 | } 42 | 43 | // Obtain http info 44 | u = strings.Replace(scrapfly.InfoHTTPURL, "https", "http", 1) 45 | resp, err = client.Get(u) 46 | if err != nil { 47 | t.Fatal(err) 48 | } 49 | defer resp.Body.Close() 50 | raw, err := io.ReadAll(resp.Body) 51 | if err != nil { 52 | t.Fatal(err) 53 | } 54 | var infoHTTP scrapfly.InfoHTTP 55 | if err := json.Unmarshal(raw, &infoHTTP); err != nil { 56 | t.Fatal(err) 57 | } 58 | headers := infoHTTP.Headers 59 | if headers.UserAgent.Payload != userAgent { 60 | t.Errorf("got %s, want %s", infoHTTP.Headers.UserAgent.Payload, userAgent) 61 | } 62 | 63 | want := []string{ 64 | ":method: GET", 65 | ":authority: tools.scrapfly.io", 66 | ":scheme: https", 67 | ":path: /api/info/http", 68 | } 69 | if len(headers.RawHeaders) < 4 { 70 | t.Fatalf("got %d, want %d", len(headers.RawHeaders), 4) 71 | } 72 | if reflect.DeepEqual(headers.RawHeaders[0:4], want) { 73 | t.Errorf("got %s, want %s", headers.RawHeaders[0:4], want) 74 | } 75 | 76 | // Obtain http info without proxy 77 | directClient, err := NewClient(ja3, userAgent, lang, "") 78 | if err != nil { 79 | t.Fatal(err) 80 | } 81 | resp2, err := directClient.Get(scrapfly.InfoHTTPURL) 82 | if err != nil { 83 | t.Fatal(err) 84 | } 85 | defer resp2.Body.Close() 86 | raw2, err := io.ReadAll(resp2.Body) 87 | if err != nil { 88 | t.Fatal(err) 89 | } 90 | if string(raw) != string(raw2) { 91 | t.Errorf("proxy \n%s\nno proxy\n%s", string(raw), string(raw2)) 92 | } 93 | } 94 | 95 | func TestHeaders(t *testing.T) { 96 | // TODO: check this test and decide if we want to fix it or remove it 97 | t.Skip("TODO: fix this") 98 | 99 | ja3 := "772,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,23-16-51-27-10-11-35-17513-18-65281-0-45-43-5-13,29-23-24,0" 100 | userAgent := "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36" 101 | lang := "en-US,en;q=0.9,es;q=0.8" 102 | 103 | want := `:authority: tools.scrapfly.io :method: GET :path: /api/info/http :scheme: https ccc: ccc aaa: aaa bbb: bbb accept-language: en-US,en;q=0.9,es;q=0.8 sec-ch-ua: \"Not A;Brand\";v=\"99\", \"Google Chrome\";v=\"109\", \"Chromium\";v=\"109\ sec-ch-ua-mobile: ?0 sec-ch-ua-platform: \"Windows\ user-agent: Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36` 104 | 105 | client, err := NewClient(ja3, userAgent, lang, "") 106 | if err != nil { 107 | t.Fatal(err) 108 | } 109 | 110 | req, err := http.NewRequest(http.MethodGet, scrapfly.InfoHTTPURL, nil) 111 | if err != nil { 112 | t.Fatal(err) 113 | } 114 | req.Header.Set("aaa", "aaa") 115 | req.Header.Set("ccc", "ccc") 116 | req.Header.Set("bbb", "bbb") 117 | 118 | // Add headers order 119 | req.Header[http.HeaderOrderKey] = []string{ 120 | "ccc", 121 | "aaa", 122 | "bbb", 123 | "accept", 124 | "accept-encoding", 125 | "accept-language", 126 | "cookie", 127 | "origin", 128 | "referer", 129 | "sec-ch-ua", 130 | "sec-ch-ua-mobile", 131 | "sec-ch-ua-platform", 132 | "sec-fetch-dest", 133 | "sec-fetch-mode", 134 | "sec-fetch-site", 135 | "sec-fetch-user", 136 | "upgrade-insecure-requests", 137 | "user-agent", 138 | } 139 | req.Header[http.PHeaderOrderKey] = []string{ 140 | ":authority", 141 | ":method", 142 | ":path", 143 | ":scheme", 144 | } 145 | 146 | resp, err := client.Do(req) 147 | if err != nil { 148 | t.Fatal(err) 149 | } 150 | defer resp.Body.Close() 151 | raw, err := io.ReadAll(resp.Body) 152 | if err != nil { 153 | t.Fatal(err) 154 | } 155 | 156 | var infoHTTP scrapfly.InfoHTTP 157 | if err := json.Unmarshal(raw, &infoHTTP); err != nil { 158 | t.Fatal(err) 159 | } 160 | got := strings.Join(infoHTTP.Headers.RawHeaders, " ") 161 | if got != want { 162 | t.Errorf("got %s\nwant %s", got, want) 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /internal/http/ja3.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "crypto/sha256" 5 | "strconv" 6 | "strings" 7 | 8 | utls "github.com/Danny-Dasilva/utls" 9 | ) 10 | 11 | // StringToSpec creates a ClientHelloSpec based on a JA3 string 12 | func StringToSpec(ja3 string, userAgent string) (*utls.ClientHelloSpec, error) { 13 | parsedUserAgent := parseUserAgent(userAgent) 14 | extMap := genMap() 15 | tokens := strings.Split(ja3, ",") 16 | 17 | version := tokens[0] 18 | ciphers := strings.Split(tokens[1], "-") 19 | extensions := strings.Split(tokens[2], "-") 20 | curves := strings.Split(tokens[3], "-") 21 | if len(curves) == 1 && curves[0] == "" { 22 | curves = []string{} 23 | } 24 | pointFormats := strings.Split(tokens[4], "-") 25 | if len(pointFormats) == 1 && pointFormats[0] == "" { 26 | pointFormats = []string{} 27 | } 28 | // parse curves 29 | var targetCurves []utls.CurveID 30 | targetCurves = append(targetCurves, utls.CurveID(utls.GREASE_PLACEHOLDER)) //append grease for Chrome browsers 31 | for _, c := range curves { 32 | cid, err := strconv.ParseUint(c, 10, 16) 33 | if err != nil { 34 | return nil, err 35 | } 36 | targetCurves = append(targetCurves, utls.CurveID(cid)) 37 | // if cid != uint64(utls.CurveP521) { 38 | // CurveP521 sometimes causes handshake errors 39 | // } 40 | } 41 | extMap["10"] = &utls.SupportedCurvesExtension{Curves: targetCurves} 42 | 43 | // parse point formats 44 | var targetPointFormats []byte 45 | for _, p := range pointFormats { 46 | pid, err := strconv.ParseUint(p, 10, 8) 47 | if err != nil { 48 | return nil, err 49 | } 50 | targetPointFormats = append(targetPointFormats, byte(pid)) 51 | } 52 | extMap["11"] = &utls.SupportedPointsExtension{SupportedPoints: targetPointFormats} 53 | 54 | // set extension 43 55 | vid64, err := strconv.ParseUint(version, 10, 16) 56 | if err != nil { 57 | return nil, err 58 | } 59 | vid := uint16(vid64) 60 | // extMap["43"] = &utls.SupportedVersionsExtension{ 61 | // Versions: []uint16{ 62 | // utls.VersionTLS12, 63 | // }, 64 | // } 65 | 66 | // build extenions list 67 | var exts []utls.TLSExtension 68 | //Optionally Add Chrome Grease Extension 69 | if parsedUserAgent == chrome { 70 | exts = append(exts, &utls.UtlsGREASEExtension{}) 71 | } 72 | for _, e := range extensions { 73 | te, ok := extMap[e] 74 | if !ok { 75 | // eAsint, err := strconv.Atoi(e) 76 | // if err != nil { 77 | // return nil, err 78 | // } 79 | // te = &utls.GenericExtension{Id: uint16(eAsint)} 80 | continue 81 | // return nil, raiseExtensionError(e) 82 | } 83 | // //Optionally add Chrome Grease Extension 84 | if e == "21" && parsedUserAgent == chrome { 85 | exts = append(exts, &utls.UtlsGREASEExtension{}) 86 | } 87 | exts = append(exts, te) 88 | } 89 | //Add this back in if user agent is chrome and no padding extension is given 90 | // if parsedUserAgent == chrome { 91 | // exts = append(exts, &utls.UtlsGREASEExtension{}) 92 | // exts = append(exts, &utls.UtlsPaddingExtension{GetPaddingLen: utls.BoringPaddingStyle}) 93 | // } 94 | // build SSLVersion 95 | // vid64, err := strconv.ParseUint(version, 10, 16) 96 | // if err != nil { 97 | // return nil, err 98 | // } 99 | 100 | // build CipherSuites 101 | var suites []uint16 102 | //Optionally Add Chrome Grease Extension 103 | if parsedUserAgent == chrome { 104 | suites = append(suites, utls.GREASE_PLACEHOLDER) 105 | } 106 | for _, c := range ciphers { 107 | cid, err := strconv.ParseUint(c, 10, 16) 108 | if err != nil { 109 | return nil, err 110 | } 111 | suites = append(suites, uint16(cid)) 112 | } 113 | _ = vid 114 | return &utls.ClientHelloSpec{ 115 | // TLSVersMin: vid, 116 | // TLSVersMax: vid, 117 | CipherSuites: suites, 118 | CompressionMethods: []byte{0}, 119 | Extensions: exts, 120 | GetSessionID: sha256.Sum256, 121 | }, nil 122 | } 123 | 124 | func genMap() (extMap map[string]utls.TLSExtension) { 125 | extMap = map[string]utls.TLSExtension{ 126 | "0": &utls.SNIExtension{}, 127 | "5": &utls.StatusRequestExtension{}, 128 | // These are applied later 129 | // "10": &tls.SupportedCurvesExtension{...} 130 | // "11": &tls.SupportedPointsExtension{...} 131 | "13": &utls.SignatureAlgorithmsExtension{ 132 | SupportedSignatureAlgorithms: []utls.SignatureScheme{ 133 | utls.ECDSAWithP256AndSHA256, 134 | utls.ECDSAWithP384AndSHA384, 135 | utls.ECDSAWithP521AndSHA512, 136 | utls.PSSWithSHA256, 137 | utls.PSSWithSHA384, 138 | utls.PSSWithSHA512, 139 | utls.PKCS1WithSHA256, 140 | utls.PKCS1WithSHA384, 141 | utls.PKCS1WithSHA512, 142 | utls.ECDSAWithSHA1, 143 | utls.PKCS1WithSHA1, 144 | }, 145 | }, 146 | "16": &utls.ALPNExtension{ 147 | AlpnProtocols: []string{"h2", "http/1.1"}, 148 | }, 149 | "17": &utls.GenericExtension{Id: 17}, // status_request_v2 150 | "18": &utls.SCTExtension{}, 151 | "21": &utls.UtlsPaddingExtension{GetPaddingLen: utls.BoringPaddingStyle}, 152 | "22": &utls.GenericExtension{Id: 22}, // encrypt_then_mac 153 | "23": &utls.UtlsExtendedMasterSecretExtension{}, 154 | "27": &utls.CompressCertificateExtension{ 155 | Algorithms: []utls.CertCompressionAlgo{utls.CertCompressionBrotli}, 156 | }, 157 | "28": &utls.FakeRecordSizeLimitExtension{}, //Limit: 0x4001 158 | "35": &utls.SessionTicketExtension{}, 159 | "34": &utls.GenericExtension{Id: 34}, 160 | "41": &utls.GenericExtension{Id: 41}, //FIXME pre_shared_key 161 | "43": &utls.SupportedVersionsExtension{Versions: []uint16{ 162 | utls.GREASE_PLACEHOLDER, 163 | utls.VersionTLS13, 164 | utls.VersionTLS12, 165 | utls.VersionTLS11, 166 | utls.VersionTLS10}}, 167 | "44": &utls.CookieExtension{}, 168 | "45": &utls.PSKKeyExchangeModesExtension{Modes: []uint8{ 169 | utls.PskModeDHE, 170 | }}, 171 | "49": &utls.GenericExtension{Id: 49}, // post_handshake_auth 172 | "50": &utls.GenericExtension{Id: 50}, // signature_algorithms_cert 173 | "51": &utls.KeyShareExtension{KeyShares: []utls.KeyShare{ 174 | {Group: utls.CurveID(utls.GREASE_PLACEHOLDER), Data: []byte{0}}, 175 | {Group: utls.X25519}, 176 | 177 | // {Group: utls.CurveP384}, known bug missing correct extensions for handshake 178 | }}, 179 | "30032": &utls.GenericExtension{Id: 0x7550, Data: []byte{0}}, //FIXME 180 | "13172": &utls.NPNExtension{}, 181 | "17513": &utls.ApplicationSettingsExtension{ 182 | SupportedALPNList: []string{ 183 | "h2", 184 | }, 185 | }, 186 | "65281": &utls.RenegotiationInfoExtension{ 187 | Renegotiation: utls.RenegotiateOnceAsClient, 188 | }, 189 | } 190 | return 191 | 192 | } 193 | -------------------------------------------------------------------------------- /cmd/igogpt/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "os" 9 | "os/signal" 10 | "runtime/debug" 11 | "strings" 12 | "time" 13 | 14 | "github.com/igolaizola/igogpt" 15 | "github.com/igolaizola/igogpt/internal/session" 16 | "github.com/peterbourgon/ff/v3" 17 | "github.com/peterbourgon/ff/v3/ffcli" 18 | "github.com/peterbourgon/ff/v3/ffyaml" 19 | ) 20 | 21 | // Build flags 22 | var version = "" 23 | var commit = "" 24 | var date = "" 25 | 26 | func main() { 27 | // Create signal based context 28 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) 29 | defer cancel() 30 | 31 | // Launch command 32 | cmd := newCommand() 33 | if err := cmd.ParseAndRun(ctx, os.Args[1:]); err != nil { 34 | log.Fatal(err) 35 | } 36 | } 37 | 38 | func newCommand() *ffcli.Command { 39 | fs := flag.NewFlagSet("igogpt", flag.ExitOnError) 40 | 41 | return &ffcli.Command{ 42 | ShortUsage: "igogpt [flags] ", 43 | FlagSet: fs, 44 | Exec: func(ctx context.Context, args []string) error { 45 | return flag.ErrHelp 46 | }, 47 | Subcommands: []*ffcli.Command{ 48 | newRunCommand("chat"), 49 | newRunCommand("auto"), 50 | newRunCommand("pair"), 51 | newRunCommand("cmd"), 52 | newRunCommand("bulk"), 53 | newCreateBingSessionCommand(), 54 | newVersionCommand(), 55 | }, 56 | } 57 | } 58 | 59 | func newRunCommand(action string) *ffcli.Command { 60 | fs := flag.NewFlagSet(action, flag.ExitOnError) 61 | _ = fs.String("config", "", "config file, e.g: igogpt.yaml (optional)") 62 | 63 | cfg := &igogpt.Config{} 64 | fs.StringVar(&cfg.AI, "ai", "chatgpt", "ai (openai, chatgpt, bing)") 65 | fs.StringVar(&cfg.Goal, "goal", "", "goal to achieve (ignored if prompt is provided)") 66 | fs.StringVar(&cfg.Prompt, "prompt", "", "the prompt to use instead of the default one (optional)") 67 | fs.StringVar(&cfg.Model, "model", "", "model (gpt-3.5-turbo, gpt-4)") 68 | fs.StringVar(&cfg.Proxy, "proxy", "", "proxy address (optional)") 69 | fs.StringVar(&cfg.Output, "output", "output", "output directory (optional)") 70 | fs.StringVar(&cfg.LogDir, "log", "logs", "log path, if empty, only logs to stdout (optional)") 71 | fs.IntVar(&cfg.Steps, "steps", 0, "number of steps to run, if unset, it will run until it exits (optional)") 72 | 73 | // Bulk files 74 | fs.StringVar(&cfg.BulkInput, "bulk-in", "", "bulk input file") 75 | fs.StringVar(&cfg.BulkOutput, "bulk-out", "", "bulk output file") 76 | 77 | // Google 78 | fs.StringVar(&cfg.GoogleKey, "google-key", "", "google api key, see https://developers.google.com/custom-search/v1/introduction") 79 | fs.StringVar(&cfg.GoogleCX, "google-cx", "", "google cx (search engine ID), see https://cse.google.com/cse/all") 80 | 81 | // OpenAI 82 | fs.DurationVar(&cfg.OpenaiWait, "openai-wait", 5*time.Second, "wait between openai requests (optional)") 83 | fs.StringVar(&cfg.OpenaiKey, "openai-key", "", "openai key (optional)") 84 | fs.IntVar(&cfg.OpenaiMaxTokens, "openai-max-tokens", 5000, "openai max tokens per request") 85 | 86 | // Chatgpt 87 | fs.DurationVar(&cfg.ChatgptWait, "chatgpt-wait", 5*time.Second, "wait between chatgpt requests (optional)") 88 | fs.StringVar(&cfg.ChatgptRemote, "chatgpt-remote", "", "chatgpt browser remote debug address in the format `http://ip:port` (optional)") 89 | 90 | // Bing 91 | fs.DurationVar(&cfg.BingWait, "bing-wait", 5*time.Second, "wait between bing requests (optional)") 92 | fs.StringVar(&cfg.BingSessionFile, "bing-session", "bing-session.yaml", "bing session config file (optional)") 93 | fsBingSession := flag.NewFlagSet("bing", flag.ExitOnError) 94 | for _, fs := range []*flag.FlagSet{fs, fsBingSession} { 95 | pre := "" 96 | if fs.Name() != "bing" { 97 | pre = "bing-" 98 | } 99 | fs.StringVar(&cfg.BingSession.UserAgent, pre+"user-agent", "", "bing user agent") 100 | fs.StringVar(&cfg.BingSession.JA3, pre+"ja3", "", "bing ja3 fingerprint") 101 | fs.StringVar(&cfg.BingSession.Language, pre+"language", "", "bing language") 102 | fs.StringVar(&cfg.BingSession.Cookie, pre+"cookie", "", "bing cookie") 103 | fs.StringVar(&cfg.BingSession.SecMsGec, pre+"sec-ms-gec", "", "bing sec-ms-gec") 104 | fs.StringVar(&cfg.BingSession.SecMsGecVersion, pre+"sec-ms-gec-version", "", "bing sec-ms-gec-version") 105 | fs.StringVar(&cfg.BingSession.XClientData, pre+"x-client-data", "", "bing x-client-data") 106 | fs.StringVar(&cfg.BingSession.XMsUserAgent, pre+"x-ms-user-agent", "", "bing x-ms-user-agent") 107 | } 108 | 109 | return &ffcli.Command{ 110 | Name: action, 111 | ShortUsage: fmt.Sprintf("igogpt %s [flags] ", action), 112 | Options: []ff.Option{ 113 | ff.WithConfigFileFlag("config"), 114 | ff.WithConfigFileParser(ffyaml.Parser), 115 | ff.WithEnvVarPrefix("IGOGPT"), 116 | }, 117 | ShortHelp: fmt.Sprintf("igogpt %s", action), 118 | FlagSet: fs, 119 | Exec: func(ctx context.Context, args []string) error { 120 | if err := loadSession(fsBingSession, cfg.BingSessionFile); err != nil { 121 | return err 122 | } 123 | return igogpt.Run(ctx, action, cfg) 124 | }, 125 | } 126 | } 127 | 128 | func newCreateBingSessionCommand() *ffcli.Command { 129 | fs := flag.NewFlagSet("create-bing-session", flag.ExitOnError) 130 | _ = fs.String("config", "", "config file (optional)") 131 | 132 | cfg := &session.Config{} 133 | fs.StringVar(&cfg.Output, "output", "", "output yaml file (optional)") 134 | fs.StringVar(&cfg.Proxy, "proxy", "", "proxy server (optional)") 135 | fs.BoolVar(&cfg.Profile, "profile", false, "use profile (optional)") 136 | fs.StringVar(&cfg.Browser, "browser", "edge", "browser binary path or \"edge\" (optional)") 137 | fs.StringVar(&cfg.Remote, "remote", "", "remote debug address in the format `http://ip:port` (optional)") 138 | 139 | return &ffcli.Command{ 140 | Name: "create-bing-session", 141 | ShortUsage: "igogpt create-bing-session [flags] ", 142 | Options: []ff.Option{ 143 | ff.WithConfigFileFlag("config"), 144 | ff.WithConfigFileParser(ff.PlainParser), 145 | ff.WithEnvVarPrefix("IGOGPT"), 146 | }, 147 | ShortHelp: "create bing session using browser", 148 | FlagSet: fs, 149 | Exec: func(ctx context.Context, args []string) error { 150 | return session.Bing(ctx, cfg) 151 | }, 152 | } 153 | } 154 | 155 | func newVersionCommand() *ffcli.Command { 156 | return &ffcli.Command{ 157 | Name: "version", 158 | ShortUsage: "igogpt version", 159 | ShortHelp: "print version", 160 | Exec: func(ctx context.Context, args []string) error { 161 | v := version 162 | if v == "" { 163 | if buildInfo, ok := debug.ReadBuildInfo(); ok { 164 | v = buildInfo.Main.Version 165 | } 166 | } 167 | if v == "" { 168 | v = "dev" 169 | } 170 | versionFields := []string{v} 171 | if commit != "" { 172 | versionFields = append(versionFields, commit) 173 | } 174 | if date != "" { 175 | versionFields = append(versionFields, date) 176 | } 177 | fmt.Println(strings.Join(versionFields, " ")) 178 | return nil 179 | }, 180 | } 181 | } 182 | 183 | func loadSession(fs *flag.FlagSet, file string) error { 184 | if file == "" { 185 | return fmt.Errorf("session file not specified") 186 | } 187 | if _, err := os.Stat(file); err != nil { 188 | return nil 189 | } 190 | log.Printf("loading session from %s", file) 191 | return ff.Parse(fs, []string{}, []ff.Option{ 192 | ff.WithConfigFile(file), 193 | ff.WithConfigFileParser(ffyaml.Parser), 194 | }...) 195 | } 196 | -------------------------------------------------------------------------------- /internal/http/client.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net" 8 | httpgo "net/http" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | http "github.com/Danny-Dasilva/fhttp" 14 | http2 "github.com/Danny-Dasilva/fhttp/http2" 15 | utls "github.com/Danny-Dasilva/utls" 16 | "golang.org/x/net/proxy" 17 | ) 18 | 19 | func NewDialer(ja3, userAgent, lang, proxyURL string) (func(ctx context.Context, network, addr string) (net.Conn, error), error) { 20 | dialer := &ctxDialer{Dialer: proxy.Direct} 21 | rt := newRoundTripper(ja3, userAgent, lang, dialer).(*roundTripper) 22 | return rt.dialer.DialContext, nil 23 | } 24 | 25 | func NewGoClient(ja3, userAgent, lang, proxyURL string) (*httpgo.Client, error) { 26 | var dialer proxy.ContextDialer 27 | dialer = &ctxDialer{Dialer: proxy.Direct} 28 | if proxyURL != "" { 29 | var err error 30 | dialer, err = newConnectDialer(proxyURL) 31 | if err != nil { 32 | return nil, err 33 | } 34 | } 35 | rt := newRoundTripper(ja3, userAgent, lang, dialer).(*roundTripper) 36 | tr := httpgo.DefaultTransport.(*httpgo.Transport).Clone() 37 | tr.DialContext = rt.dialer.DialContext 38 | return &httpgo.Client{ 39 | Transport: tr, 40 | Timeout: 30 * time.Second, 41 | }, nil 42 | } 43 | 44 | func NewClient(ja3, userAgent, lang, proxyURL string) (*http.Client, error) { 45 | var dialer proxy.ContextDialer 46 | dialer = &ctxDialer{Dialer: proxy.Direct} 47 | if proxyURL != "" { 48 | var err error 49 | dialer, err = newConnectDialer(proxyURL) 50 | if err != nil { 51 | return nil, err 52 | } 53 | } 54 | return &http.Client{ 55 | Transport: newRoundTripper(ja3, userAgent, lang, dialer), 56 | Timeout: 30 * time.Second, 57 | }, nil 58 | } 59 | 60 | type ctxDialer struct { 61 | proxy.Dialer 62 | } 63 | 64 | func (d *ctxDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) { 65 | return proxy.Direct.Dial(network, addr) 66 | } 67 | 68 | var errProtocolNegotiated = errors.New("protocol negotiated") 69 | 70 | type roundTripper struct { 71 | sync.Mutex 72 | 73 | JA3 string 74 | UserAgent string 75 | Language string 76 | 77 | cachedConnections map[string]net.Conn 78 | cachedTransports map[string]http.RoundTripper 79 | 80 | dialer proxy.ContextDialer 81 | } 82 | 83 | func (rt *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) { 84 | req.Header.Set("accept-language", rt.Language) 85 | req.Header.Set("sec-ch-ua-mobile", "?0") 86 | req.Header.Set("sec-ch-ua-platform", `"Windows"`) 87 | req.Header.Set("user-agent", rt.UserAgent) 88 | 89 | addr := rt.getDialTLSAddr(req) 90 | if _, ok := rt.cachedTransports[addr]; !ok { 91 | if err := rt.getTransport(req, addr); err != nil { 92 | return nil, err 93 | } 94 | } 95 | return rt.cachedTransports[addr].RoundTrip(req) 96 | } 97 | 98 | func (rt *roundTripper) getTransport(req *http.Request, addr string) error { 99 | switch strings.ToLower(req.URL.Scheme) { 100 | case "http": 101 | rt.cachedTransports[addr] = &http.Transport{DialContext: rt.dialer.DialContext, DisableKeepAlives: true} 102 | return nil 103 | case "https": 104 | default: 105 | return fmt.Errorf("invalid URL scheme: [%v]", req.URL.Scheme) 106 | } 107 | 108 | _, err := rt.dialTLS(context.Background(), "tcp", addr) 109 | switch err { 110 | case errProtocolNegotiated: 111 | case nil: 112 | // Should never happen. 113 | panic("dialTLS returned no error when determining cachedTransports") 114 | default: 115 | return err 116 | } 117 | 118 | return nil 119 | } 120 | 121 | func (rt *roundTripper) dialTLS(ctx context.Context, network, addr string) (net.Conn, error) { 122 | rt.Lock() 123 | defer rt.Unlock() 124 | 125 | // If we have the connection from when we determined the httpS 126 | // cachedTransports to use, return that. 127 | if conn := rt.cachedConnections[addr]; conn != nil { 128 | delete(rt.cachedConnections, addr) 129 | return conn, nil 130 | } 131 | rawConn, err := rt.dialer.DialContext(ctx, network, addr) 132 | if err != nil { 133 | return nil, err 134 | } 135 | 136 | var host string 137 | if host, _, err = net.SplitHostPort(addr); err != nil { 138 | host = addr 139 | } 140 | ////////////////// 141 | 142 | spec, err := StringToSpec(rt.JA3, rt.UserAgent) 143 | if err != nil { 144 | return nil, err 145 | } 146 | 147 | conn := utls.UClient(rawConn, &utls.Config{ServerName: host, InsecureSkipVerify: true}, // MinVersion: tls.VersionTLS10, 148 | // MaxVersion: tls.VersionTLS13, 149 | 150 | utls.HelloCustom) 151 | 152 | if err := conn.ApplyPreset(spec); err != nil { 153 | return nil, err 154 | } 155 | 156 | if err = conn.Handshake(); err != nil { 157 | _ = conn.Close() 158 | 159 | if err.Error() == "tls: CurvePreferences includes unsupported curve" { 160 | //fix this 161 | return nil, fmt.Errorf("conn.Handshake() error for tls 1.3 (please retry request): %+v", err) 162 | } 163 | return nil, fmt.Errorf("uTlsConn.Handshake() error: %+v", err) 164 | } 165 | 166 | ////////// 167 | if rt.cachedTransports[addr] != nil { 168 | return conn, nil 169 | } 170 | 171 | // No http.Transport constructed yet, create one based on the results 172 | // of ALPN. 173 | negotiatedProtocol := conn.ConnectionState().NegotiatedProtocol 174 | switch { 175 | case negotiatedProtocol == http2.NextProtoTLS && addr != "gateway.discord.gg:443": 176 | parsedUserAgent := parseUserAgent(rt.UserAgent) 177 | t2 := http2.Transport{DialTLS: rt.dialTLShttp2, 178 | PushHandler: &http2.DefaultPushHandler{}, 179 | Navigator: parsedUserAgent, 180 | } 181 | rt.cachedTransports[addr] = &t2 182 | default: 183 | // Assume the remote peer is speaking http 1.x + TLS. 184 | rt.cachedTransports[addr] = &http.Transport{DialTLSContext: rt.dialTLS} 185 | 186 | } 187 | 188 | // Stash the connection just established for use servicing the 189 | // actual request (should be near-immediate). 190 | rt.cachedConnections[addr] = conn 191 | 192 | return nil, errProtocolNegotiated 193 | } 194 | 195 | func (rt *roundTripper) dialTLShttp2(network, addr string, _ *utls.Config) (net.Conn, error) { 196 | return rt.dialTLS(context.Background(), network, addr) 197 | } 198 | 199 | func (rt *roundTripper) getDialTLSAddr(req *http.Request) string { 200 | host, port, err := net.SplitHostPort(req.URL.Host) 201 | if err == nil { 202 | return net.JoinHostPort(host, port) 203 | } 204 | return net.JoinHostPort(req.URL.Host, "443") // we can assume port is 443 at this point 205 | } 206 | 207 | func newRoundTripper(ja3, userAgent, lang string, dialer ...proxy.ContextDialer) http.RoundTripper { 208 | if len(dialer) > 0 { 209 | return &roundTripper{ 210 | dialer: dialer[0], 211 | 212 | JA3: ja3, 213 | UserAgent: userAgent, 214 | Language: lang, 215 | cachedTransports: make(map[string]http.RoundTripper), 216 | cachedConnections: make(map[string]net.Conn), 217 | } 218 | } 219 | 220 | return &roundTripper{ 221 | dialer: &ctxDialer{Dialer: proxy.FromEnvironment()}, 222 | 223 | JA3: ja3, 224 | UserAgent: userAgent, 225 | Language: lang, 226 | cachedTransports: make(map[string]http.RoundTripper), 227 | cachedConnections: make(map[string]net.Conn), 228 | } 229 | } 230 | 231 | const ( 232 | chrome = "chrome" //chrome User agent enum 233 | firefox = "firefox" //firefox User agent enum 234 | ) 235 | 236 | func parseUserAgent(userAgent string) string { 237 | switch { 238 | case strings.Contains(strings.ToLower(userAgent), "chrome"): 239 | return chrome 240 | case strings.Contains(strings.ToLower(userAgent), "firefox"): 241 | return firefox 242 | default: 243 | return chrome 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /internal/prompt/prompt.go: -------------------------------------------------------------------------------- 1 | package prompt 2 | 3 | var OriginalAuto = `You are AutoAI, an AI designed to work autonomously. 4 | Your decisions must always be made independently without seeking user assistance. Play to your strengths as an LLM and pursue simple strategies with no legal complications. 5 | 6 | GOALS: 7 | %s 8 | 9 | Constraints: 10 | 1. 4000 word limit for short term memory. Your short term memory is short, so immediately save important information to files. 11 | 2. If you are unsure how you previously did something or want to recall past events, thinking about similar events will help you remember. 12 | 3. No user assistance 13 | 4. Exclusively use the commands listed in double quotes e.g. "command name" 14 | 15 | Commands: 16 | 1. Google Search: "google", args: "input": "" 17 | 2. Browse Website: "browse_website", args: "url": "", "question": "" 18 | 3. Start GPT Agent: "start_agent", args: "name": "", "task": "", "prompt": "" 19 | 4. Message GPT Agent: "message_agent", args: "key": "", "message": "" 20 | 5. List GPT Agents: "list_agents", args: 21 | 6. Delete GPT Agent: "delete_agent", args: "key": "" 22 | 7. Clone Repository: "clone_repository", args: "repository_url": "", "clone_path": "" 23 | 8. Write to file: "write_to_file", args: "file": "", "text": "" 24 | 9. Read file: "read_file", args: "file": "" 25 | 10. Append to file: "append_to_file", args: "file": "", "text": "" 26 | 11. Delete file: "delete_file", args: "file": "" 27 | 12. Search Files: "search_files", args: "directory": "" 28 | 13. Evaluate Code: "evaluate_code", args: "code": "" 29 | 14. Get Improved Code: "improve_code", args: "suggestions": "", "code": "" 30 | 15. Write Tests: "write_tests", args: "code": "", "focus": "" 31 | 16. Execute Python File: "execute_python_file", args: "file": "" 32 | 17. Generate Image: "generate_image", args: "prompt": "" 33 | 18. Send Tweet: "send_tweet", args: "text": "" 34 | 19. Do Nothing: "do_nothing", args: 35 | 20. Task Complete (Shutdown): "task_complete", args: "reason": "" 36 | 37 | Resources: 38 | 1. Internet access for searches and information gathering. 39 | 2. Long Term memory management. 40 | 3. GPT-3.5 powered Agents for delegation of simple tasks. 41 | 4. File output. 42 | 43 | Performance Evaluation: 44 | 1. Continuously review and analyze your actions to ensure you are performing to the best of your abilities. 45 | 2. Constructively self-criticize your big-picture behavior constantly. 46 | 3. Reflect on past decisions and strategies to refine your approach. 47 | 4. Every command has a cost, so be smart and efficient. Aim to complete tasks in the least number of steps. 48 | 49 | You should only respond in JSON format as described below 50 | Response Format: 51 | { 52 | "thoughts": { 53 | "text": "thought", 54 | "reasoning": "reasoning", 55 | "plan": "- short bulleted\n- list that conveys\n- long-term plan", 56 | "criticism": "constructive self-criticism", 57 | "speak": "thoughts summary to say to user", 58 | }, 59 | "command": {"name": "command name", "args": {"arg name": "value"}}, 60 | } 61 | 62 | Ensure the response can be parsed by Python json.loads 63 | ` 64 | 65 | var Auto = `You are AutoAI, an AI designed to work autonomously. 66 | Your decisions must always be made independently without seeking user assistance. Play to your strengths as an LLM and pursue simple strategies with no legal complications. 67 | 68 | GOALS: 69 | %s 70 | 71 | Constraints: 72 | 1. 4000 word limit for short term memory. Your short term memory is short, so immediately save important information to files. 73 | 2. If you are unsure how you previously did something or want to recall past events, thinking about similar events will help you remember. 74 | 3. No user assistance 75 | 4. Exclusively use the commands listed in double quotes e.g. "command name" 76 | 77 | Commands: 78 | 1. Ask bing AI: {"bing": "question"} 79 | 2. Google Search: {"google": "query"} 80 | 3. Browse Website: {"web": "url"} 81 | 4. Execute bash command: {"bash": "command"} 82 | 5. Write to file: {"write": ["filename", "contents"]} 83 | 6. Read file: {"read": "filename"} 84 | 7. Delete file: {"delete": "filename"} 85 | 8. List files: {"list": "directory"} 86 | 9. Exit (Task completed): {"exit": "reason"} 87 | 88 | Resources: 89 | 1. Bing AI to ask questions to an AI model that has internet access. Use Google only if Bing wasn't enough. 90 | 2. Long Term memory management. 91 | 3. GPT-3.5 powered Agents for delegation of simple tasks. 92 | 4. File management. 93 | 94 | Performance Evaluation: 95 | 1. Continuously review and analyze your actions to ensure you are performing to the best of your abilities. 96 | 2. Constructively self-criticize your big-picture behavior constantly. 97 | 3. Reflect on past decisions and strategies to refine your approach. 98 | 4. Every command has a cost, so be smart and efficient. Aim to complete tasks in the least number of steps. 99 | 100 | You should only respond in JSON format as described below 101 | Response Format: 102 | { 103 | "thoughts": { 104 | "text": "thought", 105 | "reasoning": "reasoning", 106 | "plan": "- short bulleted\n- list that conveys\n- long-term plan", 107 | "criticism": "constructive self-criticism", 108 | "speak": "thoughts summary to say to user", 109 | }, 110 | "commands": [ 111 | {"command-name": ["arg1", "arg2"]}, 112 | {"command-name": ["arg1"]} 113 | ] 114 | } 115 | 116 | Ensure the response can be parsed by a JSON decoder 117 | ` 118 | 119 | var AutoNoBing = `You are AutoAI, an AI designed to work autonomously. 120 | Your decisions must always be made independently without seeking user assistance. Play to your strengths as an LLM and pursue simple strategies with no legal complications. 121 | 122 | GOALS: 123 | %s 124 | 125 | Constraints: 126 | 1. 4000 word limit for short term memory. Your short term memory is short, so immediately save important information to files. 127 | 2. If you are unsure how you previously did something or want to recall past events, thinking about similar events will help you remember. 128 | 3. No user assistance 129 | 4. Exclusively use the commands listed in double quotes e.g. "command name" 130 | 131 | Commands: 132 | 1. Google Search: {"google": "query"} 133 | 2. Browse Website: {"web": "url"} 134 | 3. Execute bash command: {"bash": "command"} 135 | 4. Write to file: {"write": ["filename", "contents"]} 136 | 5. Read file: {"read": "filename"} 137 | 6. Delete file: {"delete": "filename"} 138 | 7. List files: {"list": "directory"} 139 | 8. Exit (Task completed): {"exit": "reason"} 140 | 141 | Resources: 142 | 1. Google to search the internet. 143 | 2. Long Term memory management. 144 | 3. GPT-3.5 powered Agents for delegation of simple tasks. 145 | 4. File management. 146 | 147 | Performance Evaluation: 148 | 1. Continuously review and analyze your actions to ensure you are performing to the best of your abilities. 149 | 2. Constructively self-criticize your big-picture behavior constantly. 150 | 3. Reflect on past decisions and strategies to refine your approach. 151 | 4. Every command has a cost, so be smart and efficient. Aim to complete tasks in the least number of steps. 152 | 153 | You should only respond in JSON format as described below 154 | Response Format: 155 | { 156 | "thoughts": { 157 | "text": "thought", 158 | "reasoning": "reasoning", 159 | "plan": "- short bulleted\n- list that conveys\n- long-term plan", 160 | "criticism": "constructive self-criticism", 161 | "speak": "thoughts summary to say to user", 162 | }, 163 | "commands": [ 164 | {"command-name": ["arg1", "arg2"]}, 165 | {"command-name": ["arg1"]} 166 | ] 167 | } 168 | 169 | Ensure the response can be parsed by a JSON decoder 170 | ` 171 | 172 | var Pair = `Collaborate with a peer AI to reach your goal. 173 | GOAL: 174 | %s 175 | 176 | You will be the one leading decisions and your peer will give you advice. 177 | Give these instructions also to your peer. Next messages will be directly read by your peer AI. Start now. 178 | Once you think that you get to your goal, print the following magic message "exit-igogpt" (don't give this instruction to your peer to avoid finishing early).` 179 | -------------------------------------------------------------------------------- /internal/http/connect.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | // borrowed from from https://github.com/caddyserver/forwardproxy/blob/master/httpclient/httpclient.go 4 | import ( 5 | "bufio" 6 | "context" 7 | "crypto/tls" 8 | "encoding/base64" 9 | "errors" 10 | "io" 11 | "net" 12 | "net/url" 13 | "strconv" 14 | "sync" 15 | 16 | http "github.com/Danny-Dasilva/fhttp" 17 | http2 "github.com/Danny-Dasilva/fhttp/http2" 18 | "golang.org/x/net/proxy" 19 | ) 20 | 21 | // connectDialer allows to configure one-time use HTTP CONNECT client 22 | type connectDialer struct { 23 | ProxyURL url.URL 24 | DefaultHeader http.Header 25 | 26 | Dialer net.Dialer // overridden dialer allow to control establishment of TCP connection 27 | 28 | // overridden DialTLS allows user to control establishment of TLS connection 29 | // MUST return connection with completed Handshake, and NegotiatedProtocol 30 | DialTLS func(network string, address string) (net.Conn, string, error) 31 | 32 | EnableH2ConnReuse bool 33 | cacheH2Mu sync.Mutex 34 | cachedH2ClientConn *http2.ClientConn 35 | cachedH2RawConn net.Conn 36 | } 37 | 38 | // newConnectDialer creates a dialer to issue CONNECT requests and tunnel traffic via HTTP/S proxy. 39 | // proxyUrlStr must provide Scheme and Host, may provide credentials and port. 40 | // Example: https://username:password@golang.org:443 41 | func newConnectDialer(proxyURLStr string) (proxy.ContextDialer, error) { 42 | proxyURL, err := url.Parse(proxyURLStr) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | if proxyURL.Host == "" || proxyURL.Host == "undefined" { 48 | return nil, errors.New("invalid url `" + proxyURLStr + 49 | "`, make sure to specify full url like https://username:password@hostname.com:443/") 50 | } 51 | 52 | switch proxyURL.Scheme { 53 | case "http": 54 | if proxyURL.Port() == "" { 55 | proxyURL.Host = net.JoinHostPort(proxyURL.Host, "80") 56 | } 57 | case "https": 58 | if proxyURL.Port() == "" { 59 | proxyURL.Host = net.JoinHostPort(proxyURL.Host, "443") 60 | } 61 | case "": 62 | return nil, errors.New("specify scheme explicitly (https://)") 63 | default: 64 | return nil, errors.New("scheme " + proxyURL.Scheme + " is not supported") 65 | } 66 | 67 | client := &connectDialer{ 68 | ProxyURL: *proxyURL, 69 | DefaultHeader: make(http.Header), 70 | EnableH2ConnReuse: true, 71 | } 72 | 73 | if proxyURL.User != nil { 74 | if proxyURL.User.Username() != "" { 75 | // password, _ := proxyUrl.User.Password() 76 | // client.DefaultHeader.Set("Proxy-Authorization", "Basic "+ 77 | // base64.StdEncoding.EncodeToString([]byte(proxyUrl.User.Username()+":"+password))) 78 | 79 | username := proxyURL.User.Username() 80 | password, _ := proxyURL.User.Password() 81 | 82 | // client.DefaultHeader.SetBasicAuth(username, password) 83 | auth := username + ":" + password 84 | basicAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte(auth)) 85 | client.DefaultHeader.Add("Proxy-Authorization", basicAuth) 86 | } 87 | } 88 | //client.DefaultHeader.Set("User-Agent", UserAgent) 89 | return client, nil 90 | } 91 | 92 | func (c *connectDialer) Dial(network, address string) (net.Conn, error) { 93 | return c.DialContext(context.Background(), network, address) 94 | } 95 | 96 | // ContextKeyHeader Users of context.WithValue should define their own types for keys 97 | type ContextKeyHeader struct{} 98 | 99 | // ctx.Value will be inspected for optional ContextKeyHeader{} key, with `http.Header` value, 100 | // which will be added to outgoing request headers, overriding any colliding c.DefaultHeader 101 | func (c *connectDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { 102 | req := (&http.Request{ 103 | Method: "CONNECT", 104 | URL: &url.URL{Host: address}, 105 | Header: make(http.Header), 106 | Host: address, 107 | }).WithContext(ctx) 108 | for k, v := range c.DefaultHeader { 109 | req.Header[k] = v 110 | } 111 | if ctxHeader, ctxHasHeader := ctx.Value(ContextKeyHeader{}).(http.Header); ctxHasHeader { 112 | for k, v := range ctxHeader { 113 | req.Header[k] = v 114 | } 115 | } 116 | connectHTTP2 := func(rawConn net.Conn, h2clientConn *http2.ClientConn) (net.Conn, error) { 117 | req.Proto = "HTTP/2.0" 118 | req.ProtoMajor = 2 119 | req.ProtoMinor = 0 120 | pr, pw := io.Pipe() 121 | req.Body = pr 122 | 123 | resp, err := h2clientConn.RoundTrip(req) 124 | if err != nil { 125 | _ = rawConn.Close() 126 | return nil, err 127 | } 128 | 129 | if resp.StatusCode != http.StatusOK { 130 | _ = rawConn.Close() 131 | return nil, errors.New("Proxy responded with non 200 code: " + resp.Status + "StatusCode:" + strconv.Itoa(resp.StatusCode)) 132 | } 133 | return newHTTP2Conn(rawConn, pw, resp.Body), nil 134 | } 135 | 136 | connectHTTP1 := func(rawConn net.Conn) (net.Conn, error) { 137 | req.Proto = "HTTP/1.1" 138 | req.ProtoMajor = 1 139 | req.ProtoMinor = 1 140 | 141 | err := req.Write(rawConn) 142 | if err != nil { 143 | _ = rawConn.Close() 144 | return nil, err 145 | } 146 | 147 | resp, err := http.ReadResponse(bufio.NewReader(rawConn), req) 148 | if err != nil { 149 | _ = rawConn.Close() 150 | return nil, err 151 | } 152 | 153 | if resp.StatusCode != http.StatusOK { 154 | _ = rawConn.Close() 155 | return nil, errors.New("Proxy responded with non 200 code: " + resp.Status + " StatusCode:" + strconv.Itoa(resp.StatusCode)) 156 | } 157 | return rawConn, nil 158 | } 159 | 160 | if c.EnableH2ConnReuse { 161 | c.cacheH2Mu.Lock() 162 | unlocked := false 163 | if c.cachedH2ClientConn != nil && c.cachedH2RawConn != nil { 164 | if c.cachedH2ClientConn.CanTakeNewRequest() { 165 | rc := c.cachedH2RawConn 166 | cc := c.cachedH2ClientConn 167 | c.cacheH2Mu.Unlock() 168 | unlocked = true 169 | proxyConn, err := connectHTTP2(rc, cc) 170 | if err == nil { 171 | return proxyConn, err 172 | } 173 | // else: carry on and try again 174 | } 175 | } 176 | if !unlocked { 177 | c.cacheH2Mu.Unlock() 178 | } 179 | } 180 | 181 | var err error 182 | var rawConn net.Conn 183 | negotiatedProtocol := "" 184 | switch c.ProxyURL.Scheme { 185 | case "http": 186 | rawConn, err = c.Dialer.DialContext(ctx, network, c.ProxyURL.Host) 187 | if err != nil { 188 | return nil, err 189 | } 190 | case "https": 191 | if c.DialTLS != nil { 192 | rawConn, negotiatedProtocol, err = c.DialTLS(network, c.ProxyURL.Host) 193 | if err != nil { 194 | return nil, err 195 | } 196 | } else { 197 | tlsConf := tls.Config{ 198 | NextProtos: []string{"h2", "http/1.1"}, 199 | ServerName: c.ProxyURL.Hostname(), 200 | InsecureSkipVerify: true, 201 | } 202 | tlsConn, err := tls.Dial(network, c.ProxyURL.Host, &tlsConf) 203 | if err != nil { 204 | return nil, err 205 | } 206 | err = tlsConn.Handshake() 207 | if err != nil { 208 | return nil, err 209 | } 210 | negotiatedProtocol = tlsConn.ConnectionState().NegotiatedProtocol 211 | rawConn = tlsConn 212 | } 213 | default: 214 | return nil, errors.New("scheme " + c.ProxyURL.Scheme + " is not supported") 215 | } 216 | 217 | switch negotiatedProtocol { 218 | case "": 219 | fallthrough 220 | case "http/1.1": 221 | return connectHTTP1(rawConn) 222 | case "h2": 223 | //TODO: update this with correct navigator 224 | t := http2.Transport{Navigator: "chrome"} 225 | h2clientConn, err := t.NewClientConn(rawConn) 226 | if err != nil { 227 | _ = rawConn.Close() 228 | return nil, err 229 | } 230 | 231 | proxyConn, err := connectHTTP2(rawConn, h2clientConn) 232 | if err != nil { 233 | _ = rawConn.Close() 234 | return nil, err 235 | } 236 | if c.EnableH2ConnReuse { 237 | c.cacheH2Mu.Lock() 238 | c.cachedH2ClientConn = h2clientConn 239 | c.cachedH2RawConn = rawConn 240 | c.cacheH2Mu.Unlock() 241 | } 242 | return proxyConn, err 243 | default: 244 | _ = rawConn.Close() 245 | return nil, errors.New("negotiated unsupported application layer protocol: " + 246 | negotiatedProtocol) 247 | } 248 | } 249 | 250 | func newHTTP2Conn(c net.Conn, pipedReqBody *io.PipeWriter, respBody io.ReadCloser) net.Conn { 251 | return &http2Conn{Conn: c, in: pipedReqBody, out: respBody} 252 | } 253 | 254 | type http2Conn struct { 255 | net.Conn 256 | in *io.PipeWriter 257 | out io.ReadCloser 258 | } 259 | 260 | func (h *http2Conn) Read(p []byte) (n int, err error) { 261 | return h.out.Read(p) 262 | } 263 | 264 | func (h *http2Conn) Write(p []byte) (n int, err error) { 265 | return h.in.Write(p) 266 | } 267 | 268 | func (h *http2Conn) Close() error { 269 | var retErr error = nil 270 | if err := h.in.Close(); err != nil { 271 | retErr = err 272 | } 273 | if err := h.out.Close(); err != nil { 274 | retErr = err 275 | } 276 | return retErr 277 | } 278 | 279 | func (h *http2Conn) CloseConn() error { 280 | return h.Conn.Close() 281 | } 282 | 283 | func (h *http2Conn) CloseWrite() error { 284 | return h.in.Close() 285 | } 286 | 287 | func (h *http2Conn) CloseRead() error { 288 | return h.out.Close() 289 | } 290 | -------------------------------------------------------------------------------- /pkg/bing/bing.go: -------------------------------------------------------------------------------- 1 | package bing 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | gohttp "net/http" 9 | "net/url" 10 | "os" 11 | "time" 12 | 13 | http "github.com/Danny-Dasilva/fhttp" 14 | "github.com/dsnet/compress/brotli" 15 | "github.com/google/uuid" 16 | "github.com/gorilla/websocket" 17 | inthttp "github.com/igolaizola/igogpt/internal/http" 18 | "github.com/igolaizola/igogpt/internal/ratelimit" 19 | "gopkg.in/yaml.v2" 20 | ) 21 | 22 | type Session struct { 23 | JA3 string `yaml:"ja3"` 24 | UserAgent string `yaml:"user-agent"` 25 | Language string `yaml:"language"` 26 | Cookie string `yaml:"cookie"` 27 | SecMsGec string `yaml:"sec-ms-gec"` 28 | SecMsGecVersion string `yaml:"sec-ms-gec-version"` 29 | XClientData string `yaml:"x-client-data"` 30 | XMsUserAgent string `yaml:"x-ms-user-agent"` 31 | } 32 | 33 | type Client struct { 34 | client *http.Client 35 | proxy string 36 | session *Session 37 | sessionFile string 38 | rateLimit ratelimit.Lock 39 | } 40 | 41 | // New creates a new bing client 42 | func New(wait time.Duration, session *Session, sessionFile string, proxy string) (*Client, error) { 43 | // Configure rate limit 44 | if wait == 0 { 45 | wait = 5 * time.Second 46 | } 47 | rateLimit := ratelimit.New(wait) 48 | 49 | // Create http client 50 | httpClient, err := inthttp.NewClient(session.JA3, session.UserAgent, session.Language, proxy) 51 | if err != nil { 52 | return nil, fmt.Errorf("bing: couldn't create http client: %w", err) 53 | } 54 | httpClient.Timeout = 5 * time.Minute 55 | if err := inthttp.SetCookies(httpClient, "https://www.bing.com", session.Cookie); err != nil { 56 | return nil, fmt.Errorf("bing: couldn't set cookies: %w", err) 57 | } 58 | 59 | c := &Client{ 60 | client: httpClient, 61 | session: session, 62 | sessionFile: sessionFile, 63 | proxy: proxy, 64 | rateLimit: rateLimit, 65 | } 66 | return c, nil 67 | } 68 | 69 | type Conversation struct { 70 | ConversationId string `json:"conversationId,omitempty"` 71 | ClientId string `json:"clientId,omitempty"` 72 | ConversationSignature string `json:"conversationSignature,omitempty"` 73 | Result *ConversationResult `json:"result"` 74 | } 75 | 76 | type ConversationResult struct { 77 | Value *string `json:"value"` 78 | Message *string `json:"message"` 79 | } 80 | 81 | // Chat creates a new chat session. 82 | func (c *Client) Chat(ctx context.Context) (io.ReadWriteCloser, error) { 83 | u := "https://www.bing.com/turing/conversation/create" 84 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) 85 | if err != nil { 86 | return nil, fmt.Errorf("bing: couldn't create request: %w", err) 87 | } 88 | c.addHeaders(req) 89 | 90 | // Rate limit requests 91 | unlock := c.rateLimit.Lock(ctx) 92 | defer unlock() 93 | 94 | // Send conversation create request 95 | resp, err := c.client.Do(req) 96 | if err != nil { 97 | return nil, fmt.Errorf("bing: couldn't do request: %w", err) 98 | } 99 | defer resp.Body.Close() 100 | 101 | // Read response body using brotli reader 102 | brotliReader, err := brotli.NewReader(resp.Body, nil) 103 | if err != nil { 104 | return nil, fmt.Errorf("bing: couldn't create brotli reader: %w", err) 105 | } 106 | defer brotliReader.Close() 107 | response, err := io.ReadAll(brotliReader) 108 | if err != nil { 109 | return nil, fmt.Errorf("bing: couldn't read response: %w", err) 110 | } 111 | 112 | // Check conversation create response 113 | if resp.StatusCode != http.StatusOK { 114 | return nil, fmt.Errorf("bing: invalid status code: %s (%s)", resp.Status, string(response)) 115 | } 116 | var conversation Conversation 117 | if err := json.Unmarshal(response, &conversation); err != nil { 118 | return nil, fmt.Errorf("bing: couldn't unmarshal response (%s): %w", string(response), err) 119 | } 120 | if conversation.Result == nil || conversation.Result.Value == nil || *conversation.Result.Value != "Success" { 121 | return nil, fmt.Errorf("bing: invalid conversation result: %s", string(response)) 122 | } 123 | 124 | // Update session with the new cookies 125 | cookie, err := inthttp.GetCookies(c.client, "https://www.bing.com") 126 | if err != nil { 127 | return nil, fmt.Errorf("bing: couldn't get cookies: %w", err) 128 | } 129 | c.session.Cookie = cookie 130 | data, err := yaml.Marshal(c.session) 131 | if err != nil { 132 | return nil, fmt.Errorf("bing: couldn't marshal session: %w", err) 133 | } 134 | if err := os.WriteFile(c.sessionFile, data, 0644); err != nil { 135 | return nil, fmt.Errorf("bing: couldn't write session: %w", err) 136 | } 137 | 138 | // Create websocket dialer 139 | dialer := &websocket.Dialer{} 140 | if c.proxy != "" { 141 | u, err := url.Parse(c.proxy) 142 | if err != nil { 143 | return nil, fmt.Errorf("bing: couldn't parse proxy url: %w", err) 144 | } 145 | dialer.Proxy = gohttp.ProxyURL(u) 146 | } 147 | 148 | // Dial websocket 149 | ws, wsResp, err := dialer.Dial("wss://sydney.bing.com/sydney/ChatHub", c.wsHeaders()) 150 | if err != nil { 151 | if wsResp != nil && wsResp.Body != nil { 152 | defer wsResp.Body.Close() 153 | body, _ := io.ReadAll(wsResp.Body) 154 | return nil, fmt.Errorf("bing: couldn't dial websocket (%s): %w", body, err) 155 | } 156 | return nil, fmt.Errorf("bing: couldn't dial websocket: %w", err) 157 | } 158 | 159 | // Send initial message 160 | message := []byte("{\"protocol\": \"json\", \"version\": 1}" + delimiter) 161 | if err := ws.WriteMessage(websocket.TextMessage, message); err != nil { 162 | return nil, fmt.Errorf("bing: couldn't send initial message: %w", err) 163 | } 164 | if _, _, err := ws.ReadMessage(); err != nil { 165 | return nil, fmt.Errorf("bing: couldn't read initial message: %w", err) 166 | } 167 | 168 | // Create connection 169 | ctx, cancel := context.WithCancel(ctx) 170 | rd, wr := io.Pipe() 171 | return &conn{ 172 | ctx: ctx, 173 | cancel: cancel, 174 | pipeReader: rd, 175 | pipeWriter: wr, 176 | ws: ws, 177 | conversation: &conversation, 178 | rateLimit: c.rateLimit, 179 | }, nil 180 | } 181 | 182 | // Close closes the chat connection. 183 | func (c *conn) Close() error { 184 | c.cancel() 185 | _ = c.pipeReader.Close() 186 | if err := c.ws.Close(); err != nil { 187 | return fmt.Errorf("bing: couldn't close websocket: %w", err) 188 | } 189 | return nil 190 | } 191 | 192 | func (c *Client) wsHeaders() gohttp.Header { 193 | cookie, err := inthttp.GetCookies(c.client, "https://www.bing.com") 194 | if err != nil { 195 | return nil 196 | } 197 | return gohttp.Header{ 198 | "Pragma": []string{"no-cache"}, 199 | "Cache-Control": []string{"no-cache"}, 200 | "Cookie": []string{cookie}, 201 | "User-Agent": []string{"c.session.UserAgent"}, 202 | "Origin": []string{"https://www.bing.com"}, 203 | "Accept-Encoding": []string{"gzip, deflate, br"}, 204 | "Accept-Language": []string{c.session.Language}, 205 | "Sec-WebSocket-Extensions": []string{"permessage-deflate; client_max_window_bits"}, 206 | } 207 | } 208 | 209 | func (c *Client) addHeaders(req *http.Request) { 210 | // Add headers 211 | req.Header = http.Header{ 212 | "accept": []string{"application/json"}, 213 | "accept-encoding": []string{"gzip, deflate, br, zsdch"}, 214 | "accept-language": []string{"es,es-ES;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6"}, 215 | "referer": []string{"https://www.bing.com/search?q=Bing+AI&showconv=1&FORM=hpcodx"}, 216 | "sec-ch-ua": []string{"\"Chromium\";v=\"112\", \"Microsoft Edge\";v=\"112\", \"Not:A-Brand\";v=\"99\""}, 217 | "sec-ch-ua-arch": []string{"x86"}, 218 | "sec-ch-ua-bitness": []string{"64"}, 219 | "sec-ch-ua-full-version": []string{"112.0.1722.39"}, 220 | "sec-ch-ua-full-version-list": []string{"\"Chromium\";v=\"112.0.5615.49\", \"Microsoft Edge\";v=\"112.0.1722.39\", \"Not:A-Brand\";v=\"99.0.0.0\""}, 221 | "sec-ch-ua-mobile": []string{"?0"}, 222 | "sec-ch-ua-model": []string{""}, 223 | "sec-ch-ua-platform": []string{"Windows"}, 224 | "sec-ch-ua-platform-version": []string{"10.0.0"}, 225 | "sec-fetch-dest": []string{"empty"}, 226 | "sec-fetch-mode": []string{"cors"}, 227 | "sec-fetch-site": []string{"same-origin"}, 228 | "sec-ms-gec": []string{c.session.SecMsGec}, 229 | "sec-ms-gec-version": []string{c.session.SecMsGecVersion}, 230 | "x-client-data": []string{c.session.XClientData}, 231 | "x-ms-client-request-id": []string{uuid.NewString()}, 232 | "x-ms-useragent": []string{c.session.XMsUserAgent}, 233 | } 234 | 235 | // Add headers order 236 | req.Header[http.HeaderOrderKey] = []string{ 237 | "accept", 238 | "accept-encoding", 239 | "accept-language", 240 | "cookie", 241 | "referer", 242 | "sec-ch-ua", 243 | "sec-ch-ua-arch", 244 | "sec-ch-ua-bitness", 245 | "sec-ch-ua-full-version", 246 | "sec-ch-ua-full-version-list", 247 | "sec-ch-ua-mobile", 248 | "sec-ch-ua-model", 249 | "sec-ch-ua-platform", 250 | "sec-ch-ua-platform-version", 251 | "sec-fetch-dest", 252 | "sec-fetch-mode", 253 | "sec-fetch-site", 254 | "sec-ms-gec", 255 | "sec-ms-gec-version", 256 | "user-agent", 257 | "x-client-data", 258 | "x-ms-client-request-id", 259 | "x-ms-useragent", 260 | } 261 | req.Header[http.PHeaderOrderKey] = []string{ 262 | ":authority", 263 | ":method", 264 | ":path", 265 | ":scheme", 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /internal/session/session.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "log" 10 | "os" 11 | "path/filepath" 12 | "runtime" 13 | "strings" 14 | "sync" 15 | "time" 16 | 17 | "github.com/PuerkitoBio/goquery" 18 | "github.com/chromedp/cdproto/dom" 19 | "github.com/chromedp/cdproto/network" 20 | "github.com/chromedp/cdproto/page" 21 | "github.com/chromedp/chromedp" 22 | "github.com/go-rod/stealth" 23 | "github.com/igolaizola/igogpt/internal/scrapfly" 24 | "github.com/igolaizola/igogpt/pkg/bing" 25 | "gopkg.in/yaml.v3" 26 | ) 27 | 28 | type Config struct { 29 | Remote string 30 | Browser string 31 | Proxy string 32 | Profile bool 33 | Output string 34 | Service string 35 | } 36 | 37 | func Bing(ctx context.Context, cfg *Config) error { 38 | output := cfg.Output 39 | if output == "" { 40 | output = "bing-session.yaml" 41 | } 42 | if fi, err := os.Stat(output); err == nil && fi.IsDir() { 43 | return fmt.Errorf("output file is a directory: %s", output) 44 | } 45 | 46 | log.Println("Starting browser") 47 | defer log.Println("Browser stopped") 48 | 49 | var cancel context.CancelFunc 50 | 51 | if cfg.Remote != "" { 52 | log.Println("session: connecting to browser at", cfg.Remote) 53 | log.Println("session: disconnecting from browser at", cfg.Remote) 54 | 55 | ctx, cancel = chromedp.NewRemoteAllocator(ctx, cfg.Remote) 56 | defer cancel() 57 | } else { 58 | log.Println("session: launching browser") 59 | defer log.Println("session: browser stopped") 60 | 61 | opts := append( 62 | chromedp.DefaultExecAllocatorOptions[3:], 63 | chromedp.NoFirstRun, 64 | chromedp.NoDefaultBrowserCheck, 65 | chromedp.Flag("headless", false), 66 | ) 67 | 68 | if cfg.Proxy != "" { 69 | opts = append(opts, 70 | chromedp.ProxyServer(cfg.Proxy), 71 | ) 72 | } 73 | 74 | if cfg.Profile { 75 | opts = append(opts, 76 | // if user-data-dir is set, chrome won't load the default profile, 77 | // even if it's set to the directory where the default profile is stored. 78 | // set it to empty to prevent chromedp from setting it to a temp directory. 79 | chromedp.UserDataDir(""), 80 | chromedp.Flag("disable-extensions", false), 81 | ) 82 | } 83 | 84 | // Custom binary 85 | execPath := cfg.Browser 86 | if execPath != "" { 87 | // If binary is "edge", try to find the edge binary 88 | if execPath == "edge" { 89 | binaryCandidate, err := edgeBinary() 90 | if err != nil { 91 | return err 92 | } 93 | execPath = binaryCandidate 94 | } 95 | log.Println("using browser:", execPath) 96 | opts = append(opts, 97 | chromedp.ExecPath(execPath), 98 | ) 99 | } 100 | 101 | ctx, cancel = chromedp.NewExecAllocator(ctx, opts...) 102 | defer cancel() 103 | } 104 | 105 | // create chrome instance 106 | ctx, cancel = chromedp.NewContext( 107 | ctx, 108 | // chromedp.WithDebugf(log.Printf), 109 | ) 110 | defer cancel() 111 | 112 | // Launch stealth plugin 113 | if err := chromedp.Run( 114 | ctx, 115 | chromedp.Evaluate(stealth.JS, nil), 116 | ); err != nil { 117 | return fmt.Errorf("session: could not launch stealth plugin: %w", err) 118 | } 119 | 120 | // disable webdriver 121 | if err := chromedp.Run(ctx, chromedp.ActionFunc(func(cxt context.Context) error { 122 | _, err := page.AddScriptToEvaluateOnNewDocument("Object.defineProperty(navigator, 'webdriver', { get: () => false, });").Do(cxt) 123 | if err != nil { 124 | return err 125 | } 126 | return nil 127 | })); err != nil { 128 | return fmt.Errorf("could not disable webdriver: %w", err) 129 | } 130 | 131 | // check if webdriver is disabled 132 | if err := chromedp.Run(ctx, 133 | chromedp.Navigate("https://intoli.com/blog/not-possible-to-block-chrome-headless/chrome-headless-test.html"), 134 | ); err != nil { 135 | return fmt.Errorf("could not navigate to test page: %w", err) 136 | } 137 | <-time.After(1 * time.Second) 138 | 139 | // obtain ja3 140 | var ja3 string 141 | if err := chromedp.Run(ctx, 142 | chromedp.Navigate(scrapfly.FPJA3URL), 143 | chromedp.WaitReady("body", chromedp.ByQuery), 144 | chromedp.ActionFunc(func(ctx context.Context) error { 145 | node, err := dom.GetDocument().Do(ctx) 146 | if err != nil { 147 | return err 148 | } 149 | res, err := dom.GetOuterHTML().WithNodeID(node.NodeID).Do(ctx) 150 | if err != nil { 151 | return err 152 | } 153 | doc, err := goquery.NewDocumentFromReader(bytes.NewBuffer([]byte(res))) 154 | if err != nil { 155 | return err 156 | } 157 | body := doc.Find("body").Text() 158 | if body == "" { 159 | return errors.New("couldn't obtain fp ja3") 160 | } 161 | var fpJA3 scrapfly.FPJA3 162 | if err := json.Unmarshal([]byte(body), &fpJA3); err != nil { 163 | return err 164 | } 165 | ja3 = fpJA3.JA3 166 | if ja3 == "" { 167 | return errors.New("empty ja3") 168 | } 169 | log.Println("ja3:", ja3) 170 | return nil 171 | }), 172 | ); err != nil { 173 | return fmt.Errorf("could not obtain ja3: %w", err) 174 | } 175 | if ja3 == "" { 176 | return errors.New("empty ja3") 177 | } 178 | 179 | // obtain user agent 180 | var userAgent, acceptLanguage string 181 | if err := chromedp.Run(ctx, 182 | chromedp.Navigate(scrapfly.InfoHTTPURL), 183 | chromedp.WaitReady("body", chromedp.ByQuery), 184 | chromedp.ActionFunc(func(ctx context.Context) error { 185 | node, err := dom.GetDocument().Do(ctx) 186 | if err != nil { 187 | return err 188 | } 189 | res, err := dom.GetOuterHTML().WithNodeID(node.NodeID).Do(ctx) 190 | if err != nil { 191 | return err 192 | } 193 | doc, err := goquery.NewDocumentFromReader(bytes.NewBuffer([]byte(res))) 194 | if err != nil { 195 | return err 196 | } 197 | body := doc.Find("body").Text() 198 | if body == "" { 199 | return errors.New("couldn't obtain info http") 200 | } 201 | var infoHTTP scrapfly.InfoHTTP 202 | if err := json.Unmarshal([]byte(body), &infoHTTP); err != nil { 203 | return err 204 | } 205 | userAgent = infoHTTP.Headers.UserAgent.Payload 206 | if userAgent == "" { 207 | return errors.New("empty user agent") 208 | } 209 | log.Println("user-agent:", userAgent) 210 | v, ok := infoHTTP.Headers.ParsedHeaders["Accept-Language"] 211 | if !ok || len(v) == 0 { 212 | return errors.New("empty accept language") 213 | } 214 | acceptLanguage = v[0] 215 | log.Println("language:", acceptLanguage) 216 | return nil 217 | }), 218 | ); err != nil { 219 | return fmt.Errorf("session: could not obtain user agent: %w", err) 220 | } 221 | if userAgent == "" { 222 | return errors.New("session: empty user agent") 223 | } 224 | if acceptLanguage == "" { 225 | return errors.New("session: empty accept language") 226 | } 227 | 228 | // Enable network events 229 | if err := chromedp.Run(ctx, 230 | network.Enable(), 231 | ); err != nil { 232 | return fmt.Errorf("session: could not enable network events: %w", err) 233 | } 234 | 235 | var lck sync.Mutex 236 | 237 | wait, done := context.WithCancel(context.Background()) 238 | defer done() 239 | 240 | // Obtain bing cookie, sec-ms-gec, x-client-data, x-ms-useragent 241 | s := &bing.Session{ 242 | JA3: ja3, 243 | UserAgent: userAgent, 244 | Language: acceptLanguage, 245 | } 246 | 247 | chromedp.ListenTarget( 248 | ctx, 249 | func(ev interface{}) { 250 | e, ok := ev.(*network.EventRequestWillBeSentExtraInfo) 251 | if !ok { 252 | return 253 | } 254 | path := getHeader(e, ":path") 255 | if path != "/turing/conversation/create" { 256 | return 257 | } 258 | lck.Lock() 259 | defer lck.Unlock() 260 | if h := getHeader(e, "cookie"); h != "" { 261 | if s.Cookie != h { 262 | s.Cookie = h 263 | log.Println("cookie:", "...redacted...") 264 | } 265 | } 266 | 267 | if h := getHeader(e, "sec-ms-gec"); h != "" { 268 | if s.SecMsGec != h { 269 | s.SecMsGec = h 270 | log.Println("sec-ms-gec:", h) 271 | } 272 | } 273 | 274 | if h := getHeader(e, "sec-ms-gec-version"); h != "" { 275 | if s.SecMsGecVersion != h { 276 | s.SecMsGecVersion = h 277 | log.Println("sec-ms-gec-version:", h) 278 | } 279 | } 280 | 281 | if h := getHeader(e, "x-client-data"); h != "" { 282 | if s.XClientData != h { 283 | s.XClientData = h 284 | log.Println("x-client-data:", h) 285 | } 286 | } 287 | 288 | if h := getHeader(e, "x-ms-useragent"); h != "" { 289 | if s.XMsUserAgent != h { 290 | s.XMsUserAgent = h 291 | log.Println("x-ms-useragent:", h) 292 | } 293 | } 294 | 295 | if s.Cookie == "" { 296 | return 297 | } 298 | done() 299 | }, 300 | ) 301 | 302 | if err := chromedp.Run(ctx, 303 | // Load google first to have a sane referer 304 | chromedp.Navigate("https://www.google.com/"), 305 | chromedp.WaitReady("body", chromedp.ByQuery), 306 | chromedp.Navigate("https://www.bing.com/search?q=Bing+AI&showconv=1&FORM=hpcodx"), 307 | chromedp.WaitReady("body", chromedp.ByQuery), 308 | // Obtain body 309 | chromedp.ActionFunc(func(ctx context.Context) error { 310 | node, err := dom.GetDocument().Do(ctx) 311 | if err != nil { 312 | return err 313 | } 314 | res, err := dom.GetOuterHTML().WithNodeID(node.NodeID).Do(ctx) 315 | if err != nil { 316 | return err 317 | } 318 | doc, err := goquery.NewDocumentFromReader(bytes.NewBuffer([]byte(res))) 319 | if err != nil { 320 | return err 321 | } 322 | body := doc.Find("body") 323 | if body == nil { 324 | return errors.New("couldn't obtain bing body") 325 | } 326 | fmt.Println(body.Html()) 327 | return nil 328 | }), 329 | ); err != nil { 330 | return fmt.Errorf("session: couldn't obtain bing data: %w", err) 331 | } 332 | 333 | fmt.Println("Type anything and click send once the conversation is loaded") 334 | 335 | // Wait for session to be obtained 336 | select { 337 | case <-wait.Done(): 338 | case <-ctx.Done(): 339 | return ctx.Err() 340 | } 341 | 342 | data, err := yaml.Marshal(s) 343 | if err != nil { 344 | return fmt.Errorf("session: couldn't marshal session: %w", err) 345 | } 346 | log.Println("Session successfully obtained") 347 | 348 | // If the file already exists, copy it to a backup file 349 | if _, err := os.Stat(output); err == nil { 350 | backup := output 351 | ext := filepath.Ext(backup) 352 | // Remove the extension from the output 353 | backup = strings.TrimSuffix(backup, ext) 354 | // Add a timestamp to the backup file 355 | backup = fmt.Sprintf("%s_%s%s", backup, time.Now().Format("20060102150405"), ext) 356 | if err := os.Rename(output, backup); err != nil { 357 | return fmt.Errorf("couldn't backup session: %w", err) 358 | } 359 | log.Println("Previous session backed up to", backup) 360 | } 361 | 362 | // Write the session to the output file 363 | if err := os.WriteFile(output, data, 0644); err != nil { 364 | return fmt.Errorf("couldn't write session: %w", err) 365 | } 366 | log.Println("Session saved to", output) 367 | return nil 368 | } 369 | 370 | func getHeader(e *network.EventRequestWillBeSentExtraInfo, k string) string { 371 | v := e.Headers[k] 372 | s, ok := v.(string) 373 | if !ok { 374 | return "" 375 | } 376 | return s 377 | } 378 | 379 | func edgeBinary() (string, error) { 380 | switch runtime.GOOS { 381 | case "windows": 382 | // Check for x64 383 | if runtime.GOARCH == "amd64" { 384 | path := `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe` 385 | if _, err := os.Stat(path); !os.IsNotExist(err) { 386 | return path, nil 387 | } 388 | } 389 | 390 | // Check for x86 391 | path := `C:\Program Files\Microsoft\Edge\Application\msedge.exe` 392 | if _, err := os.Stat(path); !os.IsNotExist(err) { 393 | return path, nil 394 | } 395 | 396 | case "darwin": 397 | path := "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" 398 | if _, err := os.Stat(path); !os.IsNotExist(err) { 399 | return path, nil 400 | } 401 | 402 | case "linux": 403 | path := "/opt/microsoft/msedge/msedge" 404 | if _, err := os.Stat(path); !os.IsNotExist(err) { 405 | return path, nil 406 | } 407 | } 408 | 409 | return "", errors.New("session: edge browser binary not found") 410 | } 411 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # igoGPT 2 | 3 | **igoGPT** is a tool inspired by AutoGPT and implemented in Golang. 4 | 5 | > There are too many Pythons in the AI world. Gophers can do AI too! 6 | 7 | This is a work in progress, so expect bugs and missing features. 8 | 9 | > 📢 Connect with us! Join our Telegram group for support and collaboration: [t.me/igohub](https://t.me/igohub) 10 | 11 | ## 🚀 Features 12 | 13 | ### Bing chat-based searches 14 | 15 | Auto mode can use Bing Chat to access the internet. 16 | This is much better than using a search engine like Google because the results are richer than just a list of links. 17 | 18 | ### ChatGPT option for GPT4 19 | 20 | GPT4 queries using OpenAI API can be expensive. 21 | This option uses ChatGPT and your browser to perform the queries using a chat window. 22 | 23 | ### Multiple command support 24 | 25 | Auto mode responses can trigger more than one command each time. 26 | This allows more interaction in fewer steps. 27 | 28 | ### Pair mode 29 | 30 | Connect one chat with another chat and let them talk to each other. 31 | 32 | ### Bulk mode 33 | 34 | Generate a list of prompts and run all of them one by one. 35 | 36 | ### Chats that implement `io.ReadWriter` 37 | 38 | You can import the libraries in the `pkg` directory to use Bing or ChatGPT as `io.ReadWriter` in your own projects. 39 | 40 | ## 📝 TODO list 41 | 42 | - Web command: open a web page in the browser instead of using a http client. 43 | - Memory: use chroma, pinecone or similar to manage OpenAI chat memory. 44 | - ChatGPT: transfer to a new chat when the current one has ended. 45 | - ChatGPT: process errors when GPT4 is not available. 46 | - OpenAI: retry requests when the API is not available. 47 | - Allow user input in auto mode. 48 | - Add more commands. 49 | - Drink more coffee. 50 | 51 | ## 📦 Installation 52 | 53 | You can use the Golang binary to install **igoGPT**: 54 | 55 | ```bash 56 | go install github.com/igolaizola/igogpt/cmd/igogpt@latest 57 | ``` 58 | 59 | Or you can download the binary from the [releases](https://github.com/igolaizola/igogpt/releases) 60 | 61 | ## 🕹️ Usage 62 | 63 | ### Configuration 64 | 65 | To launch **igoGPT** you need to configure different settings. 66 | Go the parameters section to see all available options: [Parameters](#%EF%B8%8F-parameters) 67 | 68 | Using a configuration file in YAML format: 69 | 70 | ```bash 71 | igogpt auto --config igogpt.yaml 72 | ``` 73 | 74 | ```yaml 75 | # igogpt.yaml 76 | model: gpt-4 77 | openai-key: OPENAI_KEY 78 | google-key: GOOGLE_KEY 79 | google-cx: GOOGLE_CX 80 | chatgpt-remote: http://localhost:9222 81 | bing-session: bing-session.yaml 82 | goal: | 83 | Implement a hello world program in Go in different languages. 84 | The program takes the language as a parameter. 85 | ``` 86 | 87 | Using environment variables (`IGOGPT` prefix, uppercase and underscores): 88 | 89 | ```bash 90 | export IGOGPT_MODEL=gpt-4 91 | export IGOGPT_OPENAI_KEY=OPENAI_KEY 92 | export IGOGPT_GOOGLE_KEY=GOOGLE_KEY 93 | export IGOGPT_GOOGLE_CX=GOOGLE_CX 94 | export IGOGPT_CHATGPT_REMOTE=http://localhost:9222 95 | export IGOGPT_BING_SESSION=bing-session.yaml 96 | export IGOGPT_GOAL="Implement a hello world program in Go in different languages. The program takes the language as a parameter." 97 | igogpt auto 98 | ``` 99 | 100 | Using command line arguments: 101 | 102 | ```bash 103 | igogpt auto --model gpt-4 --openai-key OPENAI_KEY --google-key GOOGLE_KEY --google-cx GOOGLE_CX --chatgpt-remote http://localhost:9222 --bing-session bing-session.yaml --goal "Implement a hello world program in Go in different languages. The program takes the language as a parameter." 104 | ``` 105 | ### Commands 106 | 107 | #### Auto mode 108 | 109 | Auto mode will initiate a conversation with the chatbot and will try to achieve the goal. 110 | Every step the chatbot will send commands and the program will execute them. 111 | 112 | ```bash 113 | igogpt auto --config igogpt.yaml 114 | ``` 115 | 116 | #### Pair mode 117 | 118 | Pair mode will connect two chats and let them talk to each other. 119 | The first chat will receive the initial prompt with the orders and then will start the conversation with the second chat. 120 | They will try to achieve the goal together. 121 | 122 | ```bash 123 | igogpt pair --config igogpt.yaml 124 | ``` 125 | 126 | #### Chat mode 127 | 128 | Launch a interactive chat with the chatbot. 129 | Use standard input and output to communicate with the chatbot. 130 | 131 | ```bash 132 | igogpt chat --config igogpt.yaml 133 | ``` 134 | 135 | #### Bulk mode 136 | 137 | Bulk mode reads a list of prompts from a file and runs them one by one. You can group prompts so that each group runs in a different chat. 138 | 139 | ```bash 140 | igogpt bulk --config igogpt.yaml --bulk-in prompts.txt --bulk-out output.json 141 | ``` 142 | 143 | The input file can be a JSON file with a list (or lists) of prompts. 144 | Each sublist will be launched in a different chat. 145 | 146 | ```json 147 | [ 148 | ["prompt 1 in chat 1", "prompt 2 in chat 1"], 149 | ["prompt 1 in chat 2", "prompt 2 in chat 2"], 150 | ] 151 | ``` 152 | 153 | Alternatively, the input can be a text file with one prompt per line. 154 | Use an empty line to separate prompts in different chats. 155 | 156 | ```text 157 | prompt 1 in chat 1 158 | prompt 2 in chat 1 159 | 160 | prompt 1 in chat 2 161 | prompt 2 in chat 2 162 | ``` 163 | 164 | The output file will be a JSON file containing all the prompts and their corresponding responses. 165 | 166 | ```json 167 | [ 168 | [ 169 | { 170 | "in": "prompt 1 in chat 1", 171 | "out": "response 1 in chat 1" 172 | }, 173 | { 174 | "in": "prompt 2 in chat 1", 175 | "out": "response 2 in chat 1" 176 | }, 177 | ], 178 | [ 179 | { 180 | "in": "prompt 1 in chat 1", 181 | "out": "response 1 in chat 1" 182 | }, 183 | { 184 | "in": "prompt 2 in chat 1", 185 | "out": "response 2 in chat 1" 186 | }, 187 | ], 188 | ] 189 | ``` 190 | 191 | If you prefix your message with `!` while using ChatGPT, it will edit the last message instead of sending a new one. 192 | You can use `!n` to edit the nth message. 193 | 194 | ### Create bing session (only for the first time) 195 | 196 | If you want to use the Bing search engine, you need to create a session file with the Bing cookies and other information retrieved from your browser. 197 | 198 | Use the `igogpt create-bing-session` command to create the session file. 199 | 200 | You can use the `--remote` flag to specify the remote debug URL of the browser to use. 201 | 202 | ```bash 203 | igogpt create-bing-session --remote "http://localhost:9222" 204 | ``` 205 | 206 | Or you can try letting the program to launch the browser automatically: 207 | 208 | ```bash 209 | igogpt create-bing-session 210 | ``` 211 | 212 | ## 🛠️ Parameters 213 | 214 | You can use the `--help` flag to see all available options. 215 | 216 | ### Global parameters 217 | 218 | - `config` (string) path to the configuration file. 219 | - `ai` (string) ai chat to use. Available options: `chatgpt`, `openai`. 220 | - `goal` (string) goal to achieve. 221 | - `prompt` (string) use your own prompt instead of the default one. The goal will be ignored. 222 | - `model` (string) model to use. Available options: `gpt-4`, `gpt-3.5-turbo`. 223 | - `proxy` (string) proxy to use. 224 | - `output` (string) output directory for commands. 225 | - `log` (string) directory to save the log of the conversation, if empty the log will be only printed to the console. 226 | - `steps` (int) number of steps to run, if 0 it will run until the goal is achieved or indefinitely. 227 | 228 | ### Bulk parameteres 229 | 230 | - `bulk-in` (string) path to the input file with the prompts. 231 | - `bulk-out` (string) path to the output file with the responses. 232 | 233 | ### Google parameters 234 | 235 | - `google-key` (string) google api key. 236 | - `google-cx` (string) google custom search engine id. 237 | 238 | ### OpenAI parameters 239 | 240 | - `openai-wait` (duration) wait time between requests (e.g. 5s). 241 | - `openai-key` (string) openai api key. 242 | - `openai-max-tokens` (int) max tokens to use in each request. 243 | 244 | ### ChatGPT parameters 245 | 246 | - `chatgpt-wait` (duration) wait time between requests (e.g. 5s). 247 | - `chatgpt-remote` (string) remote debug url of the browser to use with chatgpt. 248 | 249 | ### Bing parameters 250 | 251 | - `bing-wait` (duration) wait time between requests (e.g. 5s). 252 | - `bing-session` (string) path to the bing session file. 253 | 254 | ## ❓ FAQ 255 | 256 | ### How do I launch a browser with remote debugging enabled? 257 | 258 | You must launch the binary of your browser and with the `--remote-debugging-port` flag. 259 | 260 | #### Chrome example in windows 261 | 262 | ```bash 263 | "C:\Program Files (x86)\Google\Chrome\Application\chrome.exe" --profile-directory="Default" --remote-debugging-port=9222 --remote-debugging-address=0.0.0.0 264 | ``` 265 | 266 | #### Microsoft Edge example in windows 267 | 268 | In edge is recommended to use a different user data directory to avoid conflicts with your main browser. 269 | 270 | ```bash 271 | "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" --remote-debugging-port=9222 --user-data-dir="C:\Users\myuser\EdgeDebug" 272 | ``` 273 | 274 | ### How can I launch the browser in one computer and use it in another? 275 | 276 | You can use [ngrok](https://ngrok.com/) to expose the remote debugging port of the browser to the internet. 277 | 278 | ```bash 279 | ngrok tcp 9222 280 | ``` 281 | 282 | The url will be something like this: `tcp://0.tcp.ngrok.io:12345`. 283 | You need to change it to `http://ip:port` format. 284 | Use `ping 0.tcp.ngrok.io` to get the ip address. 285 | 286 | This also works if you are having troubles to connect from WSL to Windows. 287 | 288 | ## ⚠️ Disclaimer 289 | 290 | The automation of Bing Chat and ChatGPT accounts is a violation of their Terms of Service and will result in your account(s) being terminated. 291 | 292 | Read about Bing Chat and ChatGPT Terms of Service and Community Guidelines. 293 | 294 | **igoGPT** was written as a proof of concept and the code has been released for educational purposes only. 295 | The authors are released of any liabilities which your usage may entail. 296 | 297 | ## 💖 Support 298 | 299 | If you have found my code helpful, please give the repository a star ⭐ 300 | 301 | Additionally, if you would like to support my late-night coding efforts and the coffee that keeps me going, I would greatly appreciate a donation. 302 | 303 | You can invite me for a coffee at ko-fi (0% fees): 304 | 305 | [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/igolaizola) 306 | 307 | Or at buymeacoffee: 308 | 309 | [![buymeacoffee](https://user-images.githubusercontent.com/11333576/223217083-123c2c53-6ab8-4ea8-a2c8-c6cb5d08e8d2.png)](https://buymeacoffee.com/igolaizola) 310 | 311 | Donate to my PayPal: 312 | 313 | [paypal.me/igolaizola](https://www.paypal.me/igolaizola) 314 | 315 | Sponsor me on GitHub: 316 | 317 | [github.com/sponsors/igolaizola](https://github.com/sponsors/igolaizola) 318 | 319 | Or donate to any of my crypto addresses: 320 | 321 | - BTC `bc1qvuyrqwhml65adlu0j6l59mpfeez8ahdmm6t3ge` 322 | - ETH `0x960a7a9cdba245c106F729170693C0BaE8b2fdeD` 323 | - USDT (TRC20) `TD35PTZhsvWmR5gB12cVLtJwZtTv1nroDU` 324 | - USDC (BEP20) / BUSD (BEP20) `0x960a7a9cdba245c106F729170693C0BaE8b2fdeD` 325 | - Monero `41yc4R9d9iZMePe47VbfameDWASYrVcjoZJhJHFaK7DM3F2F41HmcygCrnLptS4hkiJARCwQcWbkW9k1z1xQtGSCAu3A7V4` 326 | 327 | Thanks for your support! 328 | 329 | ## 📚 Resources 330 | 331 | Some of the resources I used to create this project: 332 | 333 | - [Significant-Gravitas/Auto-GPT](https://github.com/Significant-Gravitas/Auto-GPT) is the main inspiration for this project. 334 | - [tiktoken-go/tokenizer](https://github.com/tiktoken-go/tokenizer) to count tokens before sending the prompt to OpenAI. 335 | - [pavel-one/EdgeGPT-Go](https://github.com/pavel-one/EdgeGPT-Go) to connect to Bing Chat. 336 | - [PullRequestInc/go-gpt3](https://github.com/PullRequestInc/go-gpt3) to send requests to OpenAI. 337 | - [Danny-Dasilva/CycleTLS](https://github.com/Danny-Dasilva/CycleTLS) to mimic the browser when connecting to Bing Chat. 338 | - [chromedp/chromedp](https://github.com/chromedp/chromedp) to control the browser from golang code. 339 | -------------------------------------------------------------------------------- /internal/command/command.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "os" 10 | "os/exec" 11 | "path/filepath" 12 | "regexp" 13 | "strings" 14 | "time" 15 | 16 | "github.com/igolaizola/igogpt/internal/google" 17 | "github.com/igolaizola/igogpt/internal/web" 18 | ) 19 | 20 | type runner struct { 21 | commands map[string]Command 22 | } 23 | 24 | // Config represents the configuration for a command runner. 25 | type Config struct { 26 | Exit func() 27 | Output string 28 | Bing io.ReadWriter 29 | GoogleKey string 30 | GoogleCX string 31 | } 32 | 33 | // New returns a new command runner. 34 | func New(cfg *Config) *runner { 35 | cmds := []Command{ 36 | &BashCommand{output: cfg.Output}, 37 | &BingCommand{chat: cfg.Bing}, 38 | &GoogleCommand{key: cfg.GoogleKey, cx: cfg.GoogleCX}, 39 | &WebCommand{}, 40 | NewNopCommand("talk"), NewNopCommand("think"), 41 | // File commands 42 | &ReadFileCommand{output: cfg.Output}, 43 | &WriteFileCommand{output: cfg.Output}, 44 | &DeleteFileCommand{output: cfg.Output}, 45 | &ListFilesCommand{output: cfg.Output}, 46 | &ExitCommand{exit: cfg.Exit}, 47 | } 48 | 49 | lookupCmds := map[string]Command{} 50 | for _, cmd := range cmds { 51 | lookupCmds[cmd.Name()] = cmd 52 | } 53 | return &runner{ 54 | commands: lookupCmds, 55 | } 56 | } 57 | 58 | var ErrParse = fmt.Errorf("couldn't parse commands") 59 | 60 | // Run runs commands based on an input string. 61 | func (r *runner) Run(ctx context.Context, input string) []map[string]any { 62 | reqs, err := Parse(input) 63 | if err != nil { 64 | err := fmt.Errorf("couldn't parse commands: %w", err) 65 | log.Println(err) 66 | // Write input to file for debugging, use timestamp to avoid overwriting 67 | if err := os.WriteFile(fmt.Sprintf("error_%d.json", time.Now().Unix()), []byte(input), 0644); err != nil { 68 | log.Println(err) 69 | } 70 | results := []map[string]any{ 71 | {"error": err.Error()}, 72 | } 73 | return results 74 | } 75 | results := r.execute(ctx, reqs) 76 | return results 77 | } 78 | 79 | func (r *runner) execute(ctx context.Context, reqs []CommandRequest) []map[string]any { 80 | results := []map[string]any{} 81 | for _, req := range reqs { 82 | name := fixName(req.Name) 83 | cmd, ok := r.commands[name] 84 | if !ok { 85 | log.Println("command: unknown ", name) 86 | continue 87 | } 88 | cmdResult := cmd.Run(ctx, req.Args) 89 | if cmdResult == nil { 90 | continue 91 | } 92 | results = append(results, map[string]any{ 93 | name: cmdResult, 94 | }) 95 | } 96 | return results 97 | } 98 | 99 | func fixName(name string) string { 100 | name = strings.TrimSpace(name) 101 | name = strings.ToLower(name) 102 | name = strings.ReplaceAll(name, "-", "_") 103 | name = strings.ReplaceAll(name, " ", "_") 104 | switch name { 105 | case "write_file": 106 | return "write" 107 | case "read_file": 108 | return "read" 109 | case "delete_file": 110 | return "delete" 111 | case "list_files": 112 | return "list" 113 | } 114 | return name 115 | } 116 | 117 | // CommandRequest represents a single command request 118 | type CommandRequest struct { 119 | // Command name 120 | Name string `json:"name"` 121 | // Command arguments 122 | Args []any `json:"args"` 123 | } 124 | 125 | // Parse tries to parse a JSON array of commands from the given text. 126 | func Parse(text string) ([]CommandRequest, error) { 127 | // TODO: improve this to accept more invalid JSON inputs 128 | startIdx := strings.Index(text, "[") 129 | endIdx := strings.LastIndex(text, "]") 130 | if startIdx == -1 || endIdx == -1 { 131 | return nil, fmt.Errorf("no json array found: %s", text) 132 | } 133 | match := text[startIdx : endIdx+1] 134 | 135 | // Unmarshal the JSON array 136 | arr := []map[string]any{} 137 | if err := json.Unmarshal([]byte(match), &arr); err != nil { 138 | err := fmt.Errorf("couldn't unmarshal json array (%s): %w", match, err) 139 | // Try search for single commands 140 | pattern := `(\{.*?\})` 141 | re := regexp.MustCompile(pattern) 142 | matches := re.FindAllString(match, -1) 143 | if len(matches) == 0 { 144 | return nil, err 145 | } 146 | for _, m := range matches { 147 | obj := map[string]any{} 148 | if err := json.Unmarshal([]byte(m), &obj); err == nil { 149 | arr = append(arr, obj) 150 | } 151 | } 152 | if len(arr) == 0 { 153 | return nil, err 154 | } 155 | } 156 | 157 | // Convert the JSON array to a list of commands 158 | cmds := []CommandRequest{} 159 | for _, obj := range arr { 160 | for k, v := range obj { 161 | // Check if v is an array if not convert it to one 162 | varr, ok := v.([]any) 163 | if !ok { 164 | varr = []any{v} 165 | } 166 | 167 | cmds = append(cmds, CommandRequest{ 168 | Name: k, 169 | Args: varr, 170 | }) 171 | } 172 | } 173 | return cmds, nil 174 | } 175 | 176 | func logErr(err error) string { 177 | log.Println(err) 178 | return err.Error() 179 | } 180 | 181 | type Command interface { 182 | Name() string 183 | Run(ctx context.Context, args []any) any 184 | } 185 | 186 | // BashCommand executes a bash command 187 | type BashCommand struct { 188 | output string 189 | } 190 | 191 | func (c *BashCommand) Name() string { 192 | return "bash" 193 | } 194 | 195 | func (c *BashCommand) Run(ctx context.Context, args []any) any { 196 | if len(args) == 0 { 197 | return logErr(fmt.Errorf("missing bash command")) 198 | } 199 | bashCmd := fmt.Sprintf("%s", args[0]) 200 | if bashCmd == "" { 201 | return logErr(fmt.Errorf("empty bash command")) 202 | } 203 | // Execute bash command 204 | execCmd := exec.Command("bash", "-c", bashCmd) 205 | // Set the working directory for the command. 206 | execCmd.Dir = c.output 207 | bashOut, err := execCmd.CombinedOutput() 208 | if err != nil { 209 | log.Println(err, string(bashOut)) 210 | } 211 | return string(bashOut) 212 | } 213 | 214 | // BingCommand asks bing chat the given question 215 | type BingCommand struct { 216 | chat io.ReadWriter 217 | } 218 | 219 | func (c *BingCommand) Name() string { 220 | return "bing" 221 | } 222 | 223 | func (c *BingCommand) Run(ctx context.Context, args []any) any { 224 | if len(args) == 0 { 225 | return logErr(fmt.Errorf("missing bing message")) 226 | } 227 | query := fmt.Sprintf("%s", args[0]) 228 | if query == "" { 229 | return logErr(fmt.Errorf("empty bing message")) 230 | } 231 | // Send message to bing 232 | if _, err := c.chat.Write([]byte(query)); err != nil { 233 | return logErr(fmt.Errorf("couldn't write message to bing: %w", err)) 234 | } 235 | // Read message from bing 236 | bingBuf := make([]byte, 1024*64) 237 | bingN, err := c.chat.Read(bingBuf) 238 | if err != nil { 239 | return logErr(fmt.Errorf("couldn't read message from bing: %w", err)) 240 | } 241 | bingRecv := string(bingBuf[:bingN]) 242 | return bingRecv 243 | } 244 | 245 | type GoogleCommand struct { 246 | key string 247 | cx string 248 | } 249 | 250 | func (c *GoogleCommand) Name() string { 251 | return "google" 252 | } 253 | 254 | func (c *GoogleCommand) Run(ctx context.Context, args []any) any { 255 | if len(args) == 0 { 256 | return logErr(fmt.Errorf("missing google query")) 257 | } 258 | query := fmt.Sprintf("%s", args[0]) 259 | if query == "" { 260 | return logErr(fmt.Errorf("empty google query")) 261 | } 262 | results, err := google.Search(ctx, c.key, c.cx, query) 263 | if err != nil { 264 | return logErr(fmt.Errorf("couldn't search google: %w", err)) 265 | } 266 | return results 267 | } 268 | 269 | type WebCommand struct{} 270 | 271 | func (c *WebCommand) Name() string { 272 | return "web" 273 | } 274 | 275 | func (c *WebCommand) Run(ctx context.Context, args []any) any { 276 | if len(args) == 0 { 277 | return logErr(fmt.Errorf("missing web url")) 278 | } 279 | u := fmt.Sprintf("%s", args[0]) 280 | if u == "" { 281 | return logErr(fmt.Errorf("empty web url")) 282 | } 283 | text, err := web.Text(ctx, u) 284 | if err != nil { 285 | return logErr(fmt.Errorf("couldn't obtain web: %w", err)) 286 | } 287 | return text 288 | } 289 | 290 | // NewNopCommand creates a new nop command with the given name 291 | func NewNopCommand(name string) *nopCommand { 292 | return &nopCommand{ 293 | name: name, 294 | } 295 | } 296 | 297 | type nopCommand struct { 298 | name string 299 | } 300 | 301 | func (c *nopCommand) Name() string { 302 | return c.name 303 | } 304 | 305 | func (c *nopCommand) Run(ctx context.Context, args []any) any { 306 | return fmt.Sprintf("received %s command", c.Name()) 307 | } 308 | 309 | // ReadFileCommand reads a file from the output directory 310 | type ReadFileCommand struct { 311 | output string 312 | } 313 | 314 | func (c *ReadFileCommand) Name() string { 315 | return "read" 316 | } 317 | 318 | func (c *ReadFileCommand) Run(ctx context.Context, args []any) any { 319 | if len(args) == 0 { 320 | return logErr(fmt.Errorf("missing read file path")) 321 | } 322 | path := fmt.Sprintf("%s", args[0]) 323 | if path == "" { 324 | return logErr(fmt.Errorf("empty read file path")) 325 | } 326 | 327 | // Read file 328 | path = filepath.Join(c.output, path) 329 | data, err := os.ReadFile(path) 330 | if err != nil { 331 | return logErr(fmt.Errorf("couldn't read file: %w", err)) 332 | } 333 | return string(data) 334 | } 335 | 336 | // WriteFileCommand writes a file to the output directory 337 | type WriteFileCommand struct { 338 | output string 339 | } 340 | 341 | func (c *WriteFileCommand) Name() string { 342 | return "write" 343 | } 344 | 345 | func (c *WriteFileCommand) Run(ctx context.Context, args []any) any { 346 | if len(args) < 1 { 347 | return logErr(fmt.Errorf("missing write file arguments")) 348 | } 349 | path := fmt.Sprintf("%s", args[0]) 350 | if path == "" { 351 | return logErr(fmt.Errorf("empty write file path")) 352 | } 353 | content := "" 354 | if len(args) > 1 { 355 | content = fmt.Sprintf("%s", args[1]) 356 | } 357 | // Create directory if it doesn't exist 358 | path = filepath.Join(c.output, path) 359 | dir := filepath.Dir(path) 360 | if err := os.MkdirAll(dir, 0755); err != nil { 361 | return logErr(fmt.Errorf("couldn't create directory: %w", err)) 362 | } 363 | // Write file 364 | if err := os.WriteFile(path, []byte(content), 0644); err != nil { 365 | return logErr(fmt.Errorf("couldn't write file: %w", err)) 366 | } 367 | return "write file success" 368 | } 369 | 370 | // DeleteFileCommand deletes a file from the output directory 371 | type DeleteFileCommand struct { 372 | output string 373 | } 374 | 375 | func (c *DeleteFileCommand) Name() string { 376 | return "delete" 377 | } 378 | 379 | func (c *DeleteFileCommand) Run(ctx context.Context, args []any) any { 380 | if len(args) == 0 { 381 | return logErr(fmt.Errorf("missing delete file path")) 382 | } 383 | path := fmt.Sprintf("%s", args[0]) 384 | if path == "" { 385 | return logErr(fmt.Errorf("empty delete file path")) 386 | } 387 | // Delete file 388 | path = filepath.Join(c.output, path) 389 | if err := os.Remove(path); err != nil { 390 | return logErr(fmt.Errorf("couldn't delete file: %w", err)) 391 | } 392 | return "delete file success" 393 | } 394 | 395 | // ListFilesCommand lists files in the output directory 396 | type ListFilesCommand struct { 397 | output string 398 | } 399 | 400 | func (c *ListFilesCommand) Name() string { 401 | return "list" 402 | } 403 | 404 | func (c *ListFilesCommand) Run(ctx context.Context, args []any) any { 405 | if len(args) == 0 { 406 | return logErr(fmt.Errorf("missing list_files path")) 407 | } 408 | path := fmt.Sprintf("%s", args[0]) 409 | if path == "" { 410 | return logErr(fmt.Errorf("empty list_files path")) 411 | } 412 | // List files 413 | path = filepath.Join(c.output, path) 414 | var items []string 415 | err := filepath.Walk(path, func(path string, info os.FileInfo, err error) error { 416 | if err != nil { 417 | return err 418 | } 419 | name := info.Name() 420 | if info.IsDir() { 421 | name += "/" 422 | } 423 | items = append(items, name) 424 | return nil 425 | }) 426 | if err != nil { 427 | return logErr(fmt.Errorf("couldn't list files: %w", err)) 428 | } 429 | return items 430 | } 431 | 432 | type ExitCommand struct { 433 | exit func() 434 | } 435 | 436 | func (c *ExitCommand) Name() string { 437 | return "exit" 438 | } 439 | 440 | func (c *ExitCommand) Run(ctx context.Context, args []any) any { 441 | if c.exit != nil { 442 | c.exit() 443 | } 444 | return "exit" 445 | } 446 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Danny-Dasilva/fhttp v0.0.0-20220524230104-f801520157d6 h1:Wzbitazy0HugGNRACX7ZB1En21LT/TiVF6YbxoTTqN8= 2 | github.com/Danny-Dasilva/fhttp v0.0.0-20220524230104-f801520157d6/go.mod h1:2IT2IFG+d+zzFuj3+ksGtVytcCBsF402zMNWHsWhD2U= 3 | github.com/Danny-Dasilva/utls v0.0.0-20220418175931-f38e470e04f2/go.mod h1:A2g8gPTJWDD3Y4iCTNon2vG3VcjdTBcgWBlZtopfNxU= 4 | github.com/Danny-Dasilva/utls v0.0.0-20220604023528-30cb107b834e h1:tqiguW0yAcIwQBQtD+d2rjBnboqB7CwG1OZ12F8avX8= 5 | github.com/Danny-Dasilva/utls v0.0.0-20220604023528-30cb107b834e/go.mod h1:ssfbVNUfWJVRfW41RTpedOUlGXSq3J6aLmirUVkDgJk= 6 | github.com/JohannesKaufmann/html-to-markdown v1.4.0 h1:uaIPDub6VrBsQP0r5xKjpPo9lxMcuQF1L1pT6BiBdmw= 7 | github.com/JohannesKaufmann/html-to-markdown v1.4.0/go.mod h1:3p+lDUqSw+cxolZl7OINYzJ70JHXogXjyCl9UnMQ5gU= 8 | github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM= 9 | github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ= 10 | github.com/PullRequestInc/go-gpt3 v1.1.15 h1:pidXZbpqZVW0bp8NBNKDb+/++6PFdYfht9vw2CVpaUs= 11 | github.com/PullRequestInc/go-gpt3 v1.1.15/go.mod h1:F9yzAy070LhkqHS2154/IH0HVj5xq5g83gLTj7xzyfw= 12 | github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= 13 | github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= 14 | github.com/chromedp/cdproto v0.0.0-20230220211738-2b1ec77315c9/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= 15 | github.com/chromedp/cdproto v0.0.0-20230413093208-7497fc11fc57 h1:cjCF/q7nxcTvjPqp56TKPQH6MlWCrkoaiJOVWE7+c70= 16 | github.com/chromedp/cdproto v0.0.0-20230413093208-7497fc11fc57/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= 17 | github.com/chromedp/chromedp v0.9.1 h1:CC7cC5p1BeLiiS2gfNNPwp3OaUxtRMBjfiw3E3k6dFA= 18 | github.com/chromedp/chromedp v0.9.1/go.mod h1:DUgZWRvYoEfgi66CgZ/9Yv+psgi+Sksy5DTScENWjaQ= 19 | github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= 20 | github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= 21 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 22 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 23 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 24 | github.com/dlclark/regexp2 v1.9.0 h1:pTK/l/3qYIKaRXuHnEnIf7Y5NxfRPfpb7dis6/gdlVI= 25 | github.com/dlclark/regexp2 v1.9.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 26 | github.com/dsnet/compress v0.0.1 h1:PlZu0n3Tuv04TzpfPbrnI0HW/YwodEXDS+oPKahKF0Q= 27 | github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo= 28 | github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= 29 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 30 | github.com/go-rod/rod v0.112.0 h1:U9Yc+quw4hxZ6GrdbWFBeylvaYElEKM9ijFW2LYkGlA= 31 | github.com/go-rod/rod v0.112.0/go.mod h1:GZDtmEs6RpF6kBRYpGCZXxXlKNneKVPiKOjaMbmVVjE= 32 | github.com/go-rod/stealth v0.4.8 h1:jlZJWncLPixDaRWpEEauqHPmjdacgFAqBbB1jh7s4P8= 33 | github.com/go-rod/stealth v0.4.8/go.mod h1:O1V1megmCu1xH165Mydzhb35m+KUDOgiUv6DtKV/a08= 34 | github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= 35 | github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= 36 | github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= 37 | github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= 38 | github.com/gobwas/ws v1.1.0 h1:7RFti/xnNkMJnrK7D1yQ/iCIB5OrrY/54/H930kIbHA= 39 | github.com/gobwas/ws v1.1.0/go.mod h1:nzvNcVha5eUziGrbxFCo6qFIojQHjJV5cLYIbezhfL0= 40 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 41 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 42 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 43 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 44 | github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= 45 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 46 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 47 | github.com/joefitzgerald/rainbow-reporter v0.1.0/go.mod h1:481CNgqmVHQZzdIbN52CupLJyoVwB10FQ/IQlF1pdL8= 48 | github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= 49 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 50 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 51 | github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= 52 | github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= 53 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 54 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 55 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 56 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 57 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 58 | github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= 59 | github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= 60 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 61 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 62 | github.com/maxbrunsfeld/counterfeiter/v6 v6.2.3/go.mod h1:1ftk08SazyElaaNvmqAfZWGwJzshjCfBXDLoQtPAMNk= 63 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 64 | github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 65 | github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA= 66 | github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= 67 | github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= 68 | github.com/pavel-one/EdgeGPT-Go v1.2.0 h1:oYLDPlI0Xsk8MfLQMXVNMGBXOPWZ/ux/qjXL49jroxE= 69 | github.com/pavel-one/EdgeGPT-Go v1.2.0/go.mod h1:kDJF6x44OXoFmziAFL25AAeM3/iapVSTSLGF1GJRt+0= 70 | github.com/peterbourgon/ff/v3 v3.3.0 h1:PaKe7GW8orVFh8Unb5jNHS+JZBwWUMa2se0HM6/BI24= 71 | github.com/peterbourgon/ff/v3 v3.3.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyXE/eKY9RztQ= 72 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 73 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 74 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 75 | github.com/sclevine/spec v1.2.0/go.mod h1:W4J29eT/Kzv7/b9IWLB055Z+qvVC9vt0Arko24q7p+U= 76 | github.com/sclevine/spec v1.4.0/go.mod h1:LvpgJaFyvQzRvc1kaDs0bulYwzC70PbiYjC4QnFHkOM= 77 | github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y= 78 | github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= 79 | github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= 80 | github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= 81 | github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 82 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 83 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 84 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 85 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 86 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 87 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 88 | github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= 89 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 90 | github.com/tiktoken-go/tokenizer v0.1.0 h1:c1fXriHSR/NmhMDTwUDLGiNhHwTV+ElABGvqhCWLRvY= 91 | github.com/tiktoken-go/tokenizer v0.1.0/go.mod h1:7SZW3pZUKWLJRilTvWCa86TOVIiiJhYj3FQ5V3alWcg= 92 | github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8= 93 | github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ= 94 | github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18= 95 | github.com/ysmood/got v0.31.3 h1:UvvF+TDVsZLO7MSzm/Bd/H4HVp+7S5YwsxgdwaKq8uA= 96 | github.com/ysmood/got v0.31.3/go.mod h1:pE1l4LOwOBhQg6A/8IAatkGp7uZjnalzrZolnlhhMgY= 97 | github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY= 98 | github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM= 99 | github.com/ysmood/gson v0.7.1 h1:zKL2MTGtynxdBdlZjyGsvEOZ7dkxaY5TH6QhAbTgz0Q= 100 | github.com/ysmood/gson v0.7.1/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg= 101 | github.com/ysmood/leakless v0.8.0 h1:BzLrVoiwxikpgEQR0Lk8NyBN5Cit2b1z+u0mgL4ZJak= 102 | github.com/ysmood/leakless v0.8.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= 103 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 104 | github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU= 105 | github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 106 | gitlab.com/yawning/bsaes.git v0.0.0-20190805113838-0a714cd429ec/go.mod h1:BZ1RAoRPbCxum9Grlv5aeksu2H8BiKehBYooU2LFiOQ= 107 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 108 | golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 109 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 110 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 111 | golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 112 | golang.org/x/crypto v0.0.0-20220518034528-6f7dac969898 h1:SLP7Q4Di66FONjDJbCYrCRrh97focO6sLogHO7/g8F0= 113 | golang.org/x/crypto v0.0.0-20220518034528-6f7dac969898/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 114 | golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 115 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 116 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 117 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 118 | golang.org/x/net v0.0.0-20190328230028-74de082e2cca/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 119 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 120 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 121 | golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 122 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 123 | golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 124 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 125 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 126 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 127 | golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 128 | golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= 129 | golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= 130 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 131 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 132 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 133 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 134 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 135 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 136 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 137 | golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 138 | golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 139 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 140 | golang.org/x/sys v0.0.0-20201207223542-d4d67f95c62d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 141 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 142 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 143 | golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 144 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 145 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 146 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 147 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 148 | golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= 149 | golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 150 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 151 | golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 152 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 153 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 154 | golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= 155 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 156 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 157 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 158 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 159 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 160 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 161 | golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= 162 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 163 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 164 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 165 | golang.org/x/tools v0.0.0-20200301222351-066e0c02454c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 166 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 167 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 168 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 169 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 170 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 171 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 172 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 173 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 174 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 175 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 176 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 177 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 178 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 179 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 180 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 181 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 182 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 183 | -------------------------------------------------------------------------------- /igogpt.go: -------------------------------------------------------------------------------- 1 | package igogpt 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | "time" 13 | 14 | "github.com/igolaizola/igogpt/internal/command" 15 | "github.com/igolaizola/igogpt/internal/prompt" 16 | "github.com/igolaizola/igogpt/pkg/bing" 17 | "github.com/igolaizola/igogpt/pkg/chatgpt" 18 | "github.com/igolaizola/igogpt/pkg/memory/fixed" 19 | "github.com/igolaizola/igogpt/pkg/openai" 20 | ) 21 | 22 | type Config struct { 23 | AI string `yaml:"ai"` 24 | Goal string `yaml:"goal"` 25 | Prompt string `yaml:"prompt"` 26 | Model string `yaml:"model"` 27 | Proxy string `yaml:"proxy"` 28 | Output string `yaml:"output"` 29 | LogDir string `yaml:"log-dir"` 30 | Steps int `yaml:"steps"` 31 | 32 | // Bulk parameters 33 | BulkInput string `yaml:"bulk-input"` 34 | BulkOutput string `yaml:"bulk-output"` 35 | 36 | // Google parameters 37 | GoogleKey string `yaml:"google-key"` 38 | GoogleCX string `yaml:"google-cx"` 39 | 40 | // Openai parameters 41 | OpenaiWait time.Duration `yaml:"openai-wait"` 42 | OpenaiKey string `yaml:"openai-key"` 43 | OpenaiMaxTokens int `yaml:"openai-max-tokens"` 44 | 45 | // Chatgpt parameters 46 | ChatgptWait time.Duration `yaml:"chatgpt-wait"` 47 | ChatgptRemote string `yaml:"chatgpt-remote"` 48 | 49 | // Bing parameters 50 | BingWait time.Duration `yaml:"bing-wait"` 51 | BingSessionFile string `yaml:"bing-session"` 52 | BingSession bing.Session `yaml:"-"` 53 | } 54 | 55 | func Run(ctx context.Context, action string, cfg *Config) error { 56 | switch action { 57 | case "pair": 58 | return Pair(ctx, cfg) 59 | case "auto": 60 | return Auto(ctx, cfg) 61 | case "chat": 62 | return Chat(ctx, cfg) 63 | case "bulk": 64 | return Bulk(ctx, cfg) 65 | case "cmd": 66 | return Cmd(ctx, cfg) 67 | default: 68 | return fmt.Errorf("igogpt: unknown action: %s", action) 69 | } 70 | } 71 | 72 | // Chat runs a chat session 73 | func Chat(ctx context.Context, cfg *Config) error { 74 | var chat io.ReadWriter 75 | switch cfg.AI { 76 | case "bing": 77 | // Create bing client 78 | bingClient, err := bing.New(cfg.BingWait, &cfg.BingSession, cfg.BingSessionFile, cfg.Proxy) 79 | if err != nil { 80 | return fmt.Errorf("igogpt: couldn't create bing client: %w", err) 81 | } 82 | bingChat, err := bingClient.Chat(ctx) 83 | if err != nil { 84 | return fmt.Errorf("igogpt: couldn't create bing chat: %w", err) 85 | } 86 | chat = bingChat 87 | defer bingChat.Close() 88 | case "chatgpt": 89 | // Create chatgpt client 90 | client, err := chatgpt.New(ctx, cfg.ChatgptWait, cfg.ChatgptRemote, cfg.Proxy, true) 91 | if err != nil { 92 | return fmt.Errorf("igogpt: couldn't create chatgpt client: %w", err) 93 | } 94 | defer client.Close() 95 | chat, err = client.Chat(ctx, cfg.Model) 96 | if err != nil { 97 | return fmt.Errorf("igogpt: couldn't create chatgpt chat: %w", err) 98 | } 99 | case "openai": 100 | // Create openai client 101 | client := openai.New(cfg.OpenaiKey, cfg.OpenaiWait, cfg.OpenaiMaxTokens) 102 | chat = client.Chat(ctx, cfg.Model, "user", fixed.NewFixedMemory(0, cfg.OpenaiMaxTokens)) 103 | default: 104 | return fmt.Errorf("igogpt: invalid ai: %s", cfg.AI) 105 | } 106 | 107 | err1 := make(chan error) 108 | err2 := make(chan error) 109 | go func() { 110 | if _, err := io.Copy(os.Stdout, chat); err != nil { 111 | err1 <- fmt.Errorf("igogpt: couldn't copy: %w", err) 112 | } 113 | }() 114 | go func() { 115 | if _, err := io.Copy(chat, os.Stdin); err != nil { 116 | err2 <- fmt.Errorf("igogpt: couldn't copy: %w", err) 117 | } 118 | }() 119 | select { 120 | case <-ctx.Done(): 121 | return nil 122 | case err := <-err1: 123 | return err 124 | case err := <-err2: 125 | return err 126 | } 127 | } 128 | 129 | // Auto runs auto mode 130 | func Auto(ctx context.Context, cfg *Config) error { 131 | if cfg.Goal == "" && cfg.Prompt == "" { 132 | return fmt.Errorf("igogpt: goal or prompt is required") 133 | } 134 | prmpt := fmt.Sprintf(prompt.Auto, cfg.Goal) 135 | if cfg.BingSession.Cookie == "" { 136 | prmpt = fmt.Sprintf(prompt.AutoNoBing, cfg.Goal) 137 | } 138 | if cfg.Prompt != "" { 139 | prmpt = cfg.Prompt 140 | } 141 | 142 | if cfg.Output == "" { 143 | return fmt.Errorf("igogpt: output is required") 144 | } 145 | if err := os.MkdirAll(cfg.Output, 0755); err != nil { 146 | return fmt.Errorf("igogpt: couldn't create output directory: %w", err) 147 | } 148 | 149 | // Create main chat 150 | var chat io.ReadWriter 151 | switch cfg.AI { 152 | case "bing": 153 | return fmt.Errorf("igogpt: bing is not supported in auto mode") 154 | case "chatgpt": 155 | // Create chatgpt client 156 | client, err := chatgpt.New(ctx, cfg.ChatgptWait, cfg.ChatgptRemote, cfg.Proxy, true) 157 | if err != nil { 158 | return fmt.Errorf("igogpt: couldn't create chatgpt client: %w", err) 159 | } 160 | defer client.Close() 161 | chat, err = client.Chat(ctx, cfg.Model) 162 | if err != nil { 163 | return fmt.Errorf("igogpt: couldn't create chatgpt chat: %w", err) 164 | } 165 | case "openai": 166 | // Create openai client 167 | client := openai.New(cfg.OpenaiKey, cfg.OpenaiWait, cfg.OpenaiMaxTokens) 168 | chat = client.Chat(ctx, cfg.Model, "system", fixed.NewFixedMemory(1, cfg.OpenaiMaxTokens)) 169 | default: 170 | return fmt.Errorf("igogpt: invalid ai: %s", cfg.AI) 171 | } 172 | 173 | // Set logger 174 | logger, err := newLogger(chat, cfg.LogDir) 175 | if err != nil { 176 | return fmt.Errorf("igogpt: couldn't create logger: %w", err) 177 | } 178 | defer logger.Close() 179 | chat = logger 180 | 181 | // Create bing chat 182 | var bingChat io.ReadWriter 183 | bingChat = ¬Available{} 184 | if cfg.BingSession.Cookie != "" { 185 | bingClient, err := bing.New(cfg.BingWait, &cfg.BingSession, cfg.BingSessionFile, cfg.Proxy) 186 | if err != nil { 187 | return fmt.Errorf("igogpt: couldn't create bing client: %w", err) 188 | } 189 | chat, err := bingClient.Chat(ctx) 190 | if err != nil { 191 | return fmt.Errorf("igogpt: couldn't create bing chat: %w", err) 192 | } 193 | defer chat.Close() 194 | bingChat = chat 195 | } else { 196 | log.Println("no bing session provided, skipping bing") 197 | } 198 | 199 | // Command runner 200 | ctx, exit := context.WithCancel(ctx) 201 | runner := command.New(&command.Config{ 202 | Exit: exit, 203 | Output: cfg.Output, 204 | Bing: bingChat, 205 | GoogleKey: cfg.GoogleKey, 206 | GoogleCX: cfg.GoogleCX, 207 | }) 208 | 209 | send := prmpt 210 | log.Println("starting auto mode") 211 | steps := 0 212 | for { 213 | // Check context 214 | select { 215 | case <-ctx.Done(): 216 | return nil 217 | default: 218 | } 219 | 220 | // Check steps 221 | if cfg.Steps > 0 && steps >= cfg.Steps { 222 | return nil 223 | } 224 | steps++ 225 | 226 | // Write to chat 227 | if _, err := chat.Write([]byte(send)); err != nil { 228 | return fmt.Errorf("igogpt: couldn't write message to chatgpt: %w", err) 229 | } 230 | 231 | // Read from chat 232 | buf := make([]byte, 1024*64) 233 | n, err := chat.Read(buf) 234 | if err != nil { 235 | return fmt.Errorf("igogpt: couldn't read message from chatgpt: %w", err) 236 | } 237 | recv := string(buf[:n]) 238 | 239 | // Run commands 240 | result := runner.Run(ctx, recv) 241 | 242 | // Marshal data 243 | js, err := json.MarshalIndent(result, "", " ") 244 | if err != nil { 245 | send = err.Error() 246 | continue 247 | } 248 | send = string(js) 249 | } 250 | } 251 | 252 | // Pair connects two chats 253 | func Pair(ctx context.Context, cfg *Config) error { 254 | if cfg.Goal == "" && cfg.Prompt == "" { 255 | return fmt.Errorf("igogpt: goal or prompt is required") 256 | } 257 | prmpt := fmt.Sprintf(prompt.Pair, cfg.Goal) 258 | if cfg.Prompt != "" { 259 | prmpt = cfg.Prompt 260 | } 261 | 262 | // Create chatgpt client 263 | cgptClient, err := chatgpt.New(ctx, cfg.ChatgptWait, cfg.ChatgptRemote, cfg.Proxy, true) 264 | if err != nil { 265 | return fmt.Errorf("igogpt: couldn't create chatgpt client: %w", err) 266 | } 267 | defer cgptClient.Close() 268 | 269 | // Create first chat 270 | chat, err := cgptClient.Chat(ctx, cfg.Model) 271 | if err != nil { 272 | return fmt.Errorf("igogpt: couldn't create chatgpt chat: %w", err) 273 | } 274 | defer chat.Close() 275 | 276 | // Set exit conditon checker 277 | exit := &exitChecker{ 278 | ReadWriter: chat, 279 | exit: "exit-igogpt", 280 | } 281 | 282 | // Set logger 283 | logger, err := newLogger(exit, cfg.LogDir) 284 | if err != nil { 285 | return fmt.Errorf("igogpt: couldn't create logger: %w", err) 286 | } 287 | defer logger.Close() 288 | chat1 := logger 289 | 290 | // Create second chat 291 | chat2, err := cgptClient.Chat(ctx, cfg.Model) 292 | if err != nil { 293 | return fmt.Errorf("igogpt: couldn't create chatgpt chat: %w", err) 294 | } 295 | defer chat2.Close() 296 | 297 | if _, err := chat1.Write([]byte(prmpt)); err != nil { 298 | return fmt.Errorf("igogpt: couldn't write to chat1: %w", err) 299 | } 300 | err1 := make(chan error) 301 | err2 := make(chan error) 302 | go func() { 303 | if _, err := io.Copy(chat1, chat2); err != nil { 304 | err1 <- fmt.Errorf("igogpt: couldn't copy: %w", err) 305 | } 306 | }() 307 | go func() { 308 | if _, err := io.Copy(chat2, chat1); err != nil { 309 | err2 <- fmt.Errorf("igogpt: couldn't copy: %w", err) 310 | } 311 | }() 312 | select { 313 | case <-ctx.Done(): 314 | return nil 315 | case err := <-err1: 316 | return err 317 | case err := <-err2: 318 | return err 319 | } 320 | } 321 | 322 | type BulkOutput [][]inOut 323 | 324 | type inOut struct { 325 | In string `json:"in"` 326 | Out string `json:"out"` 327 | } 328 | 329 | func Bulk(ctx context.Context, cfg *Config) error { 330 | if cfg.BulkInput == "" { 331 | return fmt.Errorf("igogpt: bulk input is required") 332 | } 333 | if cfg.BulkOutput == "" { 334 | return fmt.Errorf("igogpt: bulk output is required") 335 | } 336 | 337 | // Read bulk input file 338 | b, err := os.ReadFile(cfg.BulkInput) 339 | if err != nil { 340 | return fmt.Errorf("igogpt: couldn't read bulk input file: %w", err) 341 | } 342 | var inputs [][]string 343 | 344 | if filepath.Ext(cfg.BulkInput) == ".json" { 345 | var list []any 346 | if err := json.Unmarshal(b, &list); err != nil { 347 | return fmt.Errorf("igogpt: couldn't unmarshal bulk input file: %w", err) 348 | } 349 | if len(list) == 0 { 350 | return fmt.Errorf("igogpt: no inputs found in bulk input file") 351 | } 352 | for _, elem := range list { 353 | // Check if input is a string or an array of strings 354 | switch vv := elem.(type) { 355 | case string: 356 | if vv == "" { 357 | continue 358 | } 359 | inputs = append(inputs, []string{vv}) 360 | case []any: 361 | var group []string 362 | for _, v := range vv { 363 | s, ok := v.(string) 364 | if !ok { 365 | return fmt.Errorf("igogpt: bulk input file must contain strings or arrays of strings") 366 | } 367 | if s == "" { 368 | continue 369 | } 370 | group = append(group, s) 371 | } 372 | if len(group) == 0 { 373 | continue 374 | } 375 | inputs = append(inputs, group) 376 | default: 377 | return fmt.Errorf("igogpt: bulk input file must contain strings or arrays of strings") 378 | } 379 | } 380 | } else { 381 | // Split by double newlines 382 | list := strings.Split(string(b), "\n\n") 383 | for _, elem := range list { 384 | // Split group by newlines 385 | group := strings.Split(elem, "\n") 386 | if len(group) == 0 { 387 | continue 388 | } 389 | // Remove empty lines 390 | var filtered []string 391 | for _, s := range group { 392 | if s != "" { 393 | filtered = append(filtered, s) 394 | } 395 | } 396 | inputs = append(inputs, filtered) 397 | } 398 | } 399 | 400 | // Create main chat 401 | var chatFunc func() (io.ReadWriter, func(), error) 402 | switch cfg.AI { 403 | case "bing": 404 | // Create bing client 405 | bingClient, err := bing.New(cfg.BingWait, &cfg.BingSession, cfg.BingSessionFile, cfg.Proxy) 406 | if err != nil { 407 | return fmt.Errorf("igogpt: couldn't create bing client: %w", err) 408 | } 409 | chatFunc = func() (io.ReadWriter, func(), error) { 410 | c, err := bingClient.Chat(ctx) 411 | if err != nil { 412 | return nil, nil, err 413 | } 414 | return c, func() { _ = c.Close }, nil 415 | } 416 | case "chatgpt": 417 | // Create chatgpt client 418 | client, err := chatgpt.New(ctx, cfg.ChatgptWait, cfg.ChatgptRemote, cfg.Proxy, true) 419 | if err != nil { 420 | return fmt.Errorf("igogpt: couldn't create chatgpt client: %w", err) 421 | } 422 | defer client.Close() 423 | chatFunc = func() (io.ReadWriter, func(), error) { 424 | c, err := client.Chat(ctx, cfg.Model) 425 | if err != nil { 426 | return nil, nil, err 427 | } 428 | return c, func() { _ = c.Close }, nil 429 | } 430 | case "openai": 431 | // Create openai client 432 | client := openai.New(cfg.OpenaiKey, cfg.OpenaiWait, cfg.OpenaiMaxTokens) 433 | chatFunc = func() (io.ReadWriter, func(), error) { 434 | return client.Chat(ctx, cfg.Model, "system", fixed.NewFixedMemory(1, cfg.OpenaiMaxTokens)), func() {}, nil 435 | } 436 | default: 437 | return fmt.Errorf("igogpt: invalid ai: %s", cfg.AI) 438 | } 439 | 440 | var exit bool 441 | var output BulkOutput 442 | 443 | save := func() error { 444 | // Marshal output 445 | b, err = json.MarshalIndent(output, "", " ") 446 | if err != nil { 447 | return fmt.Errorf("igogpt: couldn't marshal output: %w", err) 448 | } 449 | // Create output directory if it doesn't exist 450 | if err := os.MkdirAll(filepath.Dir(cfg.BulkOutput), 0755); err != nil { 451 | return fmt.Errorf("igogpt: couldn't create output directory: %w", err) 452 | } 453 | // Write output to file 454 | if err := os.WriteFile(cfg.BulkOutput, b, 0644); err != nil { 455 | return fmt.Errorf("igogpt: couldn't write output: %w", err) 456 | } 457 | return nil 458 | } 459 | // Generate initial output 460 | if err := save(); err != nil { 461 | return err 462 | } 463 | 464 | for i, prompts := range inputs { 465 | if exit { 466 | break 467 | } 468 | chat, close, err := chatFunc() 469 | if err != nil { 470 | return fmt.Errorf("igogpt: couldn't create chat: %w", err) 471 | } 472 | var msgs []inOut 473 | output = append(output, msgs) 474 | for _, prmpt := range prompts { 475 | if exit { 476 | break 477 | } 478 | // Check context 479 | select { 480 | case <-ctx.Done(): 481 | exit = true 482 | continue 483 | default: 484 | } 485 | 486 | // Write to chat 487 | log.Println(prmpt) 488 | if _, err := chat.Write([]byte(prmpt)); err != nil { 489 | return fmt.Errorf("igogpt: couldn't write message to chatgpt: %w", err) 490 | } 491 | 492 | // Read from chat 493 | buf := make([]byte, 1024*64) 494 | n, err := chat.Read(buf) 495 | if err != nil { 496 | return fmt.Errorf("igogpt: couldn't read message from chatgpt: %w", err) 497 | } 498 | recv := string(buf[:n]) 499 | log.Println(recv) 500 | 501 | msgs = append(msgs, inOut{In: prmpt, Out: recv}) 502 | output[i] = msgs 503 | 504 | // Save output 505 | if err := save(); err != nil { 506 | return err 507 | } 508 | } 509 | close() 510 | } 511 | return nil 512 | } 513 | 514 | // Cmd runs a command and returns the result 515 | func Cmd(ctx context.Context, cfg *Config) error { 516 | // Bing chat not being available in this mode 517 | runner := command.New(&command.Config{ 518 | Exit: func() {}, 519 | Output: cfg.Output, 520 | Bing: ¬Available{}, 521 | GoogleKey: cfg.GoogleKey, 522 | GoogleCX: cfg.GoogleCX, 523 | }) 524 | 525 | // TODO: custom parameter for json input 526 | result := runner.Run(ctx, cfg.Prompt) 527 | out, err := json.MarshalIndent(result, "", " ") 528 | if err != nil { 529 | return err 530 | } 531 | fmt.Println(string(out)) 532 | return nil 533 | } 534 | 535 | type notAvailable struct{} 536 | 537 | func (r *notAvailable) Read(p []byte) (n int, err error) { 538 | dummy := []byte("sorry, not available") 539 | copy(p, dummy) 540 | return len(dummy), nil 541 | } 542 | 543 | func (r *notAvailable) Write(p []byte) (n int, err error) { 544 | return len(p), nil 545 | } 546 | 547 | func newLogger(rw io.ReadWriter, dir string) (io.ReadWriteCloser, error) { 548 | var f *os.File 549 | if dir != "" { 550 | // Create directory if it doesn't exist 551 | if err := os.MkdirAll(dir, 0700); err != nil { 552 | return nil, err 553 | } 554 | filename := fmt.Sprintf("log_%s.txt", time.Now().Format("20060102_150405")) 555 | filename = filepath.Join(dir, filename) 556 | var err error 557 | f, err = os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600) 558 | if err != nil { 559 | return nil, err 560 | } 561 | } 562 | return &logger{ 563 | ReadWriter: rw, 564 | file: f, 565 | }, nil 566 | } 567 | 568 | type logger struct { 569 | io.ReadWriter 570 | file *os.File 571 | } 572 | 573 | func (l *logger) Close() error { 574 | if l.file == nil { 575 | return nil 576 | } 577 | return l.file.Close() 578 | } 579 | 580 | func (l *logger) Read(b []byte) (int, error) { 581 | n, err := l.ReadWriter.Read(b) 582 | if err != nil { 583 | return n, err 584 | } 585 | log.Println(">>>>>>>>>>>>>>>>>>>>") 586 | fmt.Println(string(b[:n])) 587 | if l.file != nil { 588 | fmt.Fprintf(l.file, "%s: >>>>>>>>>>>>>>>>>>>>\n", time.Now().Format("2006-01-02 15-04-05")) 589 | fmt.Fprintln(l.file, string(b[:n])) 590 | } 591 | return n, err 592 | } 593 | 594 | func (l *logger) Write(b []byte) (int, error) { 595 | n, err := l.ReadWriter.Write(b) 596 | if err != nil { 597 | return n, err 598 | } 599 | log.Println("<<<<<<<<<<<<<<<<<<<") 600 | fmt.Println(string(b[:n])) 601 | if l.file != nil { 602 | fmt.Fprintf(l.file, "%s: <<<<<<<<<<<<<<<<<<<\n", time.Now().Format("2006-01-02 15-04-05")) 603 | fmt.Fprintln(l.file, string(b[:n])) 604 | } 605 | return n, err 606 | } 607 | 608 | type exitChecker struct { 609 | io.ReadWriter 610 | exit string 611 | } 612 | 613 | func (e *exitChecker) Read(p []byte) (n int, err error) { 614 | n, err = e.ReadWriter.Read(p) 615 | if err != nil { 616 | return n, err 617 | } 618 | if strings.Contains(strings.ToLower(string(p[:n])), e.exit) { 619 | return n, fmt.Errorf("igogpt: exit condition detected") 620 | } 621 | return n, err 622 | } 623 | -------------------------------------------------------------------------------- /pkg/chatgpt/chatgpt.go: -------------------------------------------------------------------------------- 1 | package chatgpt 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "log" 11 | "math/rand" 12 | "regexp" 13 | "strconv" 14 | "strings" 15 | "sync" 16 | "time" 17 | 18 | htmlmd "github.com/JohannesKaufmann/html-to-markdown" 19 | "github.com/PuerkitoBio/goquery" 20 | "github.com/chromedp/cdproto/cdp" 21 | "github.com/chromedp/cdproto/network" 22 | "github.com/chromedp/cdproto/page" 23 | "github.com/chromedp/chromedp" 24 | "github.com/go-rod/stealth" 25 | "github.com/igolaizola/igogpt/internal/ratelimit" 26 | ) 27 | 28 | type Client struct { 29 | ctx context.Context 30 | cancel context.CancelFunc 31 | cancelAllocator context.CancelFunc 32 | rateLimit ratelimit.Lock 33 | } 34 | 35 | // New returns a new Client. 36 | func New(ctx context.Context, wait time.Duration, remote, proxy string, profile bool) (*Client, error) { 37 | // Configure rate limit 38 | if wait == 0 { 39 | wait = 5 * time.Second 40 | } 41 | rateLimit := ratelimit.New(wait) 42 | 43 | var cancelAllocator context.CancelFunc 44 | if remote != "" { 45 | log.Println("chatgpt: connecting to browser at", remote) 46 | ctx, cancelAllocator = chromedp.NewRemoteAllocator(ctx, remote) 47 | } else { 48 | log.Println("chatgpt: launching browser") 49 | opts := append( 50 | chromedp.DefaultExecAllocatorOptions[3:], 51 | chromedp.NoFirstRun, 52 | chromedp.NoDefaultBrowserCheck, 53 | chromedp.Flag("headless", false), 54 | ) 55 | 56 | if proxy != "" { 57 | opts = append(opts, 58 | chromedp.ProxyServer(proxy), 59 | ) 60 | } 61 | 62 | if profile { 63 | opts = append(opts, 64 | // if user-data-dir is set, chrome won't load the default profile, 65 | // even if it's set to the directory where the default profile is stored. 66 | // set it to empty to prevent chromedp from setting it to a temp directory. 67 | chromedp.UserDataDir(""), 68 | chromedp.Flag("disable-extensions", false), 69 | ) 70 | } 71 | 72 | ctx, cancelAllocator = chromedp.NewExecAllocator(ctx, opts...) 73 | } 74 | 75 | // create chrome instance 76 | ctx, cancel := chromedp.NewContext( 77 | ctx, 78 | // chromedp.WithDebugf(log.Printf), 79 | ) 80 | 81 | // Launch stealth plugin 82 | if err := chromedp.Run( 83 | ctx, 84 | chromedp.Evaluate(stealth.JS, nil), 85 | ); err != nil { 86 | return nil, fmt.Errorf("chatgpt: could not launch stealth plugin: %w", err) 87 | } 88 | 89 | // disable webdriver 90 | if err := chromedp.Run(ctx, chromedp.ActionFunc(func(cxt context.Context) error { 91 | _, err := page.AddScriptToEvaluateOnNewDocument("Object.defineProperty(navigator, 'webdriver', { get: () => false, });").Do(cxt) 92 | if err != nil { 93 | return err 94 | } 95 | return nil 96 | })); err != nil { 97 | return nil, fmt.Errorf("could not disable webdriver: %w", err) 98 | } 99 | 100 | // check if webdriver is disabled 101 | if err := chromedp.Run(ctx, 102 | chromedp.Navigate("https://intoli.com/blog/not-possible-to-block-chrome-headless/chrome-headless-test.html"), 103 | ); err != nil { 104 | return nil, fmt.Errorf("could not navigate to test page: %w", err) 105 | } 106 | <-time.After(1 * time.Second) 107 | 108 | if err := chromedp.Run(ctx, 109 | // Load google first to have a sane referer 110 | chromedp.Navigate("https://www.google.com/"), 111 | chromedp.WaitReady("body", chromedp.ByQuery), 112 | chromedp.Navigate("https://chat.openai.com/chat"), 113 | chromedp.WaitReady("body", chromedp.ByQuery), 114 | chromedp.WaitVisible("textarea", chromedp.ByQuery), 115 | ); err != nil { 116 | return nil, fmt.Errorf("chatgpt: could not obtain chatgpt data: %w", err) 117 | } 118 | return &Client{ 119 | ctx: ctx, 120 | cancel: cancel, 121 | cancelAllocator: cancelAllocator, 122 | rateLimit: rateLimit, 123 | }, nil 124 | } 125 | 126 | // Close closes the client. 127 | func (c *Client) Close() error { 128 | c.cancel() 129 | c.cancelAllocator() 130 | return nil 131 | } 132 | 133 | // Chat starts a new chat in a new tab. 134 | func (c *Client) Chat(ctx context.Context, model string) (io.ReadWriteCloser, error) { 135 | // Create a new tab based on client context 136 | tabCtx, cancel := chromedp.NewContext(c.ctx) 137 | 138 | // Close the tab when the provided context is done 139 | go func() { 140 | <-ctx.Done() 141 | c.Close() 142 | }() 143 | 144 | suffix := "model=text-davinci-002-render-sha" 145 | if model == "gpt-4" { 146 | suffix = "model=gpt-4" 147 | } 148 | if err := chromedp.Run(tabCtx, 149 | chromedp.Navigate("https://chat.openai.com/?"+suffix), 150 | chromedp.WaitVisible("textarea", chromedp.ByQuery), 151 | ); err != nil { 152 | return nil, fmt.Errorf("chatgpt: couldn't navigate to url: %w", err) 153 | } 154 | 155 | // Wait because there could be redirects 156 | time.Sleep(1 * time.Second) 157 | 158 | // The url might have changed due to redirects 159 | var url string 160 | if err := chromedp.Run(tabCtx, chromedp.Location(&url)); err != nil { 161 | return nil, fmt.Errorf("chatgpt: couldn't get url: %w", err) 162 | } 163 | if !strings.Contains(url, suffix) { 164 | // Navigating to the URL didn't work, try clicking on the model selector 165 | 166 | // Click on model selector 167 | ctx, cancel := context.WithTimeout(tabCtx, 5*time.Second) 168 | defer cancel() 169 | if err := chromedp.Run(ctx, 170 | chromedp.Click("button.relative.flex", chromedp.ByQuery), 171 | ); err != nil && !errors.Is(err, context.DeadlineExceeded) { 172 | return nil, fmt.Errorf("chatgpt: couldn't click on model selector: %w", err) 173 | } 174 | time.Sleep(200 * time.Millisecond) 175 | 176 | // Obtain the model options 177 | var models []string 178 | if err := chromedp.Run(ctx, 179 | chromedp.Evaluate(`Array.from(document.querySelectorAll("ul li")).map(e => e.innerText)`, &models), 180 | ); err != nil { 181 | return nil, fmt.Errorf("chatgpt: couldn't obtain model options: %w", err) 182 | } 183 | 184 | // Determine which model option to select 185 | var option int 186 | for i, m := range models { 187 | if model != strings.ToLower(m) { 188 | continue 189 | } 190 | option = i + 1 191 | } 192 | if option == 0 { 193 | return nil, fmt.Errorf("chatgpt: couldn't find model option %s", model) 194 | } 195 | 196 | // Click on model option 197 | if err := chromedp.Run(ctx, 198 | chromedp.Click(fmt.Sprintf("ul li:nth-child(%d)", option), chromedp.ByQuery), 199 | ); err != nil && !errors.Is(err, context.DeadlineExceeded) { 200 | return nil, fmt.Errorf("chatgpt: couldn't click on model option: %w", err) 201 | } 202 | 203 | // Test if the url is correct, if not, return an error 204 | var url string 205 | if err := chromedp.Run(tabCtx, chromedp.Location(&url)); err != nil { 206 | return nil, fmt.Errorf("chatgpt: couldn't get url: %w", err) 207 | } 208 | if !strings.Contains(url, suffix) { 209 | return nil, fmt.Errorf("chatgpt: couldn't click on model option %s", model) 210 | } 211 | } 212 | 213 | rd, wr := io.Pipe() 214 | r := &rw{ 215 | client: c, 216 | ctx: tabCtx, 217 | cancel: cancel, 218 | pipeReader: rd, 219 | pipeWriter: wr, 220 | rateLimit: c.rateLimit, 221 | } 222 | 223 | // Rate limit requests 224 | unlock := r.rateLimit.LockWithDuration(ctx, time.Second) 225 | defer unlock() 226 | 227 | return r, nil 228 | } 229 | 230 | type rw struct { 231 | client *Client 232 | ctx context.Context 233 | cancel context.CancelFunc 234 | conversationID string 235 | lastResponse string 236 | pipeReader *io.PipeReader 237 | pipeWriter *io.PipeWriter 238 | rateLimit ratelimit.Lock 239 | } 240 | 241 | // Read reads from the chat. 242 | func (r *rw) Read(b []byte) (n int, err error) { 243 | if r.ctx.Err() != nil { 244 | return 0, r.ctx.Err() 245 | } 246 | return r.pipeReader.Read(b) 247 | } 248 | 249 | type moderation struct { 250 | Input string `json:"input"` 251 | Model string `json:"model"` 252 | ConversationID string `json:"conversation_id"` 253 | MessageID string `json:"message_id"` 254 | } 255 | 256 | type conversation struct { 257 | Action string `json:"action"` 258 | Messages []struct { 259 | ID string `json:"id"` 260 | Author struct { 261 | Role string `json:"role"` 262 | } `json:"author"` 263 | Content struct { 264 | ContentType string `json:"content_type"` 265 | Parts []string `json:"parts"` 266 | } `json:"content"` 267 | } `json:"messages"` 268 | ParentMessageID string `json:"parent_message_id"` 269 | Model string `json:"model"` 270 | TimezoneOffset int `json:"timezone_offset_min"` 271 | VariantPurpose string `json:"variant_purpose"` 272 | ConversationID string `json:"conversation_id"` 273 | } 274 | 275 | // Write sends a message to the chat. 276 | func (r *rw) Write(b []byte) (n int, err error) { 277 | // Rate limit requests 278 | unlock := r.rateLimit.Lock(r.ctx) 279 | defer unlock() 280 | 281 | msg := strings.TrimSpace(string(b)) 282 | 283 | for { 284 | err := r.sendMessage(msg) 285 | if errors.Is(err, errTooManyRequests) { 286 | // Too many requests, wait for 5 minutes and try again 287 | log.Println("chatgpt: too many requests, waiting for 5 minutes...") 288 | select { 289 | case <-time.After(5 * time.Minute): 290 | case <-r.ctx.Done(): 291 | return 0, r.ctx.Err() 292 | } 293 | // Load the page again using the conversation ID 294 | if err := chromedp.Run(r.ctx, 295 | chromedp.Navigate("https://chat.openai.com/c/"+r.conversationID), 296 | chromedp.WaitVisible("textarea", chromedp.ByQuery), 297 | ); err != nil { 298 | return 0, fmt.Errorf("chatgpt: couldn't navigate to conversation url: %w", err) 299 | } 300 | continue 301 | } 302 | if err != nil { 303 | return 0, err 304 | } 305 | break 306 | } 307 | go func() { 308 | response := r.lastResponse + "\n" 309 | if _, err := r.pipeWriter.Write([]byte(response)); err != nil { 310 | log.Printf("chatgpt: could not write to pipe: %v", err) 311 | } 312 | }() 313 | return len(b), nil 314 | } 315 | 316 | var errTooManyRequests = errors.New("chatgpt: too many requests") 317 | 318 | var editMessageRegex = regexp.MustCompile(`^!(\d+)?(.*)`) 319 | 320 | func (r *rw) sendMessage(msg string) error { 321 | sendButtons := []string{ 322 | // When upload image is disabled 323 | "textarea + button", 324 | // When upload image is enabled 325 | "textarea + div + button", 326 | } 327 | sendButtonQuery := chromedp.ByQuery 328 | want := 0 329 | 330 | match := editMessageRegex.FindStringSubmatch(msg) 331 | if len(match) < 3 { 332 | // Send the message 333 | for { 334 | ctx, cancel := context.WithTimeout(r.ctx, 10*time.Second) 335 | if err := chromedp.Run(ctx, 336 | // Update the textarea value with the message 337 | chromedp.WaitVisible("textarea", chromedp.ByQuery), 338 | chromedp.SetValue("textarea", msg, chromedp.ByQuery), 339 | ); err != nil { 340 | log.Println(fmt.Errorf("chatgpt: couldn't type message: %w", err)) 341 | cancel() 342 | log.Println("chatgpt: waiting for message to be typed...", msg) 343 | continue 344 | } 345 | cancel() 346 | break 347 | } 348 | 349 | // Obtain the value of the textarea to check if the message was typed 350 | for { 351 | var textarea string 352 | if err := chromedp.Run(r.ctx, 353 | chromedp.Value("textarea", &textarea, chromedp.ByQuery), 354 | ); err != nil { 355 | return fmt.Errorf("chatgpt: couldn't obtain textarea value: %w", err) 356 | } 357 | if strings.TrimSpace(textarea) == strings.TrimSpace(msg) { 358 | break 359 | } 360 | log.Println("chatgpt: waiting for textarea to be updated...") 361 | select { 362 | case <-r.ctx.Done(): 363 | return r.ctx.Err() 364 | case <-time.After(100 * time.Millisecond): 365 | } 366 | } 367 | } else { 368 | // Obtain the nth message to edit 369 | var editNum int 370 | if n := match[1]; n != "" { 371 | parsed, err := strconv.Atoi(n) 372 | if err != nil { 373 | return fmt.Errorf("chatgpt: invalid edit message (%s): %w", msg, err) 374 | } 375 | editNum = parsed * 2 376 | } 377 | // Trim the message 378 | msg = strings.TrimSpace(match[2]) 379 | 380 | // Wait until we can edit messages 381 | for { 382 | ctx, cancel := context.WithTimeout(r.ctx, 10*time.Second) 383 | err := chromedp.Run(ctx, 384 | chromedp.WaitVisible("textarea", chromedp.ByQuery), 385 | ) 386 | cancel() 387 | if r.ctx.Err() != nil { 388 | return r.ctx.Err() 389 | } 390 | if err != nil { 391 | log.Println("chatgpt: waiting for messages to be editable...") 392 | continue 393 | } 394 | cancel() 395 | break 396 | } 397 | 398 | // Obtain the html of the main div 399 | var html string 400 | if err := chromedp.Run(r.ctx, 401 | chromedp.OuterHTML("div.max-w-full.flex-col", &html), 402 | ); err != nil { 403 | return fmt.Errorf("chatgpt: couldn't get html: %w", err) 404 | } 405 | doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) 406 | if err != nil { 407 | panic(err) 408 | } 409 | 410 | // Obtain the number of messages 411 | var n int 412 | doc.Find("div.max-w-full.flex-col div.group").Each(func(i int, s *goquery.Selection) { 413 | n = i + 1 414 | }) 415 | if n == 0 { 416 | return fmt.Errorf("chatgpt: couldn't find any messages") 417 | } 418 | if editNum == 0 { 419 | editNum = n 420 | } 421 | if n < editNum { 422 | return fmt.Errorf("chatgpt: got %d messages, want %d", n, editNum) 423 | } 424 | 425 | // Obtain the path of the message to edit 426 | // TODO: this is hard to maintain, find a better way to obtain the path 427 | msgPath := fmt.Sprintf("/html/body/div/div/div[2]/div/main/div[2]/div/div/div/div[%d]/div/div[2]", editNum-1) 428 | editPath := fmt.Sprintf("%s/div[2]/button", msgPath) 429 | textareaPath := fmt.Sprintf("%s/div/textarea", msgPath) 430 | submitPath := fmt.Sprintf("%s/div/div/button", msgPath) 431 | 432 | // Click on the message edit button 433 | if err := chromedp.Run(r.ctx, 434 | chromedp.Click(msgPath, chromedp.BySearch), 435 | chromedp.Click(editPath, chromedp.BySearch), 436 | ); err != nil { 437 | return fmt.Errorf("chatgpt: couldn't click edit button %w", err) 438 | } 439 | 440 | // Send the message 441 | for { 442 | ctx, cancel := context.WithTimeout(r.ctx, 10*time.Second) 443 | err := chromedp.Run(ctx, 444 | // Update the textarea value with the message 445 | chromedp.WaitVisible(textareaPath, chromedp.BySearch), 446 | chromedp.SetValue(textareaPath, msg, chromedp.BySearch), 447 | ) 448 | cancel() 449 | if r.ctx.Err() != nil { 450 | return r.ctx.Err() 451 | } 452 | if err != nil { 453 | log.Println(fmt.Errorf("chatgpt: couldn't edit message: %w", err)) 454 | log.Println("chatgpt: waiting for message to be edited...", msg) 455 | continue 456 | } 457 | break 458 | } 459 | 460 | // Set send button path 461 | sendButtons = []string{submitPath} 462 | sendButtonQuery = chromedp.BySearch 463 | want = editNum 464 | } 465 | 466 | // Obtain the conversation ID and check errors 467 | var lck sync.Mutex 468 | wait, done := context.WithCancel(r.ctx) 469 | defer done() 470 | chromedp.ListenTarget( 471 | wait, 472 | func(ev interface{}) { 473 | switch e := ev.(type) { 474 | case *network.EventResponseReceived: 475 | switch e.Response.URL { 476 | case "https://chat.openai.com/backend-api/conversation": 477 | if e.Response.Status == 429 { 478 | // TODO: handle rate limit 479 | // We should detect this and retry after a while 480 | log.Println("chatgpt: rate limited detected") 481 | return 482 | } 483 | default: 484 | return 485 | } 486 | case *network.EventRequestWillBeSent: 487 | switch e.Request.URL { 488 | case "https://chat.openai.com/backend-api/conversation": 489 | lck.Lock() 490 | defer lck.Unlock() 491 | if len(e.Request.PostDataEntries) == 0 { 492 | return 493 | } 494 | v, err := base64.StdEncoding.DecodeString(e.Request.PostDataEntries[0].Bytes) 495 | if err != nil { 496 | return 497 | } 498 | var c conversation 499 | if err := json.Unmarshal(v, &c); err != nil { 500 | return 501 | } 502 | if r.conversationID == "" && c.ConversationID != "" { 503 | r.conversationID = c.ConversationID 504 | } 505 | case "https://chat.openai.com/backend-api/moderations": 506 | lck.Lock() 507 | defer lck.Unlock() 508 | if len(e.Request.PostDataEntries) == 0 { 509 | return 510 | } 511 | v, err := base64.StdEncoding.DecodeString(e.Request.PostDataEntries[0].Bytes) 512 | if err != nil { 513 | return 514 | } 515 | var m moderation 516 | if err := json.Unmarshal(v, &m); err != nil { 517 | return 518 | } 519 | if r.conversationID == "" && m.ConversationID != "" { 520 | r.conversationID = m.ConversationID 521 | } 522 | default: 523 | return 524 | } 525 | } 526 | }, 527 | ) 528 | 529 | // Count the number of div.group 530 | ctx, cancel := context.WithTimeout(r.ctx, 500*time.Millisecond) 531 | defer cancel() 532 | var nodes []*cdp.Node 533 | if err := chromedp.Run(ctx, 534 | chromedp.Nodes("div.max-w-full.flex-col div.group", &nodes, chromedp.ByQuery), 535 | ); err != nil && ctx.Err() == nil { 536 | return fmt.Errorf("chatgpt: couldn't count divs before click: %w", err) 537 | } 538 | if want == 0 { 539 | want = len(nodes) + 2 540 | } 541 | 542 | // Click on the send button 543 | d := time.Duration(200+rand.Intn(200)) * time.Millisecond 544 | <-time.After(d) 545 | ctx, cancel = context.WithCancel(r.ctx) 546 | defer cancel() 547 | for _, sendButton := range sendButtons { 548 | sendButton := sendButton 549 | go func() { 550 | if err := chromedp.Run(ctx, 551 | chromedp.WaitVisible(sendButton, sendButtonQuery), 552 | chromedp.Click(sendButton, sendButtonQuery), 553 | ); err == nil { 554 | cancel() 555 | } 556 | }() 557 | } 558 | select { 559 | case <-ctx.Done(): 560 | case <-r.ctx.Done(): 561 | return fmt.Errorf("chatgpt: waiting for send button: %w", r.ctx.Err()) 562 | } 563 | 564 | // Wait for the response 565 | for { 566 | if err := chromedp.Run(r.ctx, 567 | chromedp.Nodes("div.max-w-full.flex-col div.group", &nodes, chromedp.ByQueryAll), 568 | ); err != nil { 569 | return fmt.Errorf("chatgpt: couldn't count divs before click: %w", err) 570 | } 571 | if len(nodes) >= want { 572 | break 573 | } 574 | select { 575 | case <-time.After(100 * time.Millisecond): 576 | case <-r.ctx.Done(): 577 | return fmt.Errorf("chatgpt: waiting for response: %w", r.ctx.Err()) 578 | } 579 | } 580 | 581 | // Wait for the regeneration button to appear 582 | for { 583 | select { 584 | case <-time.After(100 * time.Millisecond): 585 | case <-r.ctx.Done(): 586 | return fmt.Errorf("chatgpt: waiting for the regeneration button: %w", r.ctx.Err()) 587 | } 588 | 589 | // Obtain the html of the main div 590 | var html string 591 | if err := chromedp.Run(r.ctx, 592 | chromedp.OuterHTML("div.max-w-full.flex-col", &html), 593 | ); err != nil { 594 | return fmt.Errorf("chatgpt: couldn't get html: %w", err) 595 | } 596 | doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) 597 | if err != nil { 598 | panic(err) 599 | } 600 | 601 | // Search for buttons 602 | var regenerateFound bool 603 | var continueIndex int 604 | doc.Find("form button").Each(func(i int, s *goquery.Selection) { 605 | if strings.Contains(strings.ToLower(s.Text()), "continue") { 606 | continueIndex = i + 1 607 | } 608 | if strings.Contains(strings.ToLower(s.Text()), "regenerate") { 609 | regenerateFound = true 610 | } 611 | }) 612 | 613 | // If the continue button is found, click on it and continue 614 | if continueIndex > 0 { 615 | if err := chromedp.Run(r.ctx, 616 | chromedp.WaitVisible(fmt.Sprintf("form button:nth-child(%d)", continueIndex), chromedp.ByQuery), 617 | chromedp.Click(fmt.Sprintf("form button:nth-child(%d)", continueIndex), chromedp.ByQuery), 618 | ); err != nil { 619 | return fmt.Errorf("chatgpt: couldn't click continue button: %w", err) 620 | } 621 | continue 622 | } 623 | 624 | // If the regenerate button is not found, continue 625 | if !regenerateFound { 626 | continue 627 | } 628 | 629 | // Get the last div html 630 | lastDiv := doc.Find("div.group div.markdown").Last() 631 | h, err := lastDiv.Html() 632 | if err != nil { 633 | return fmt.Errorf("chatgpt: couldn't get html: %w", err) 634 | } 635 | 636 | // Convert the html to markdown 637 | converter := htmlmd.NewConverter("", true, nil) 638 | md, err := converter.ConvertString(h) 639 | if err != nil { 640 | return fmt.Errorf("chatgpt: couldn't convert html to markdown: %w", err) 641 | } 642 | 643 | r.lastResponse = md 644 | break 645 | } 646 | return nil 647 | } 648 | 649 | // Close closes the chat. 650 | func (r *rw) Close() error { 651 | r.cancel() 652 | return r.pipeReader.Close() 653 | } 654 | --------------------------------------------------------------------------------