├── .devcontainer ├── Dockerfile ├── devcontainer.json └── docker-compose.yml ├── .dockerignore ├── .eslintignore ├── .eslintrc.yml ├── .gitattributes ├── .github └── workflows │ ├── docs.yml │ └── release.yml ├── .gitignore ├── .prettierrc.yml ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── jest.config.js ├── package.json ├── packages ├── chatgpt-agent │ ├── README.md │ ├── package.json │ ├── src │ │ ├── _tests │ │ │ └── session.test.ts │ │ ├── agent.ts │ │ ├── config.ts │ │ ├── conversation.ts │ │ ├── debug.ts │ │ ├── index.ts │ │ ├── request.ts │ │ └── session.ts │ └── tsup.config.ts ├── chatgpt-cli │ ├── README.md │ ├── package.json │ ├── src │ │ └── index.ts │ └── tsup.config.ts └── discord-bot │ ├── .env.example │ ├── README.md │ ├── package.json │ ├── src │ ├── index.ts │ ├── module.ts │ ├── preset.ts │ └── register.ts │ └── tsup.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── tsconfig.json /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/typescript-node:18 2 | 3 | RUN su node -c "npm install -g pnpm tsx taze" 4 | RUN su node -c "pnpm config set store-dir /workspace/node_modules/.pnpm-store" 5 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Bot", 3 | "dockerComposeFile": "docker-compose.yml", 4 | "service": "app", 5 | "workspaceFolder": "/workspace", 6 | "settings": {}, 7 | "extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "GitHub.copilot"], 8 | "postCreateCommand": ["pnpm", "i"], 9 | "remoteUser": "node" 10 | } 11 | -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | app: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | volumes: 9 | - ..:/workspace 10 | command: sleep infinity 11 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .env 2 | .git/ 3 | data/ 4 | .store/ 5 | docs/ 6 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # don't lint node_modules 2 | node_modules 3 | 4 | # don't lint build output 5 | lib 6 | 7 | # ignore docs 8 | docs 9 | 10 | # ignore config files 11 | .eslintrc.js 12 | jest.config.js 13 | # tsup.config.ts 14 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | root: true 3 | parser: "@typescript-eslint/parser" 4 | plugins: 5 | - "@typescript-eslint" 6 | extends: 7 | - eslint:recommended 8 | - plugin:@typescript-eslint/recommended 9 | - prettier 10 | rules: 11 | "@typescript-eslint/explicit-module-boundary-types": 12 | - error 13 | - allowArgumentsExplicitlyTypedAsAny: true 14 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | concurrency: 15 | group: "pages" 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | docs: 20 | name: docs 21 | runs-on: ubuntu-latest 22 | environment: 23 | name: github-pages 24 | url: ${{ steps.deployment.outputs.page_url }} 25 | 26 | steps: 27 | - name: Checkout Repository 28 | uses: actions/checkout@v3 29 | 30 | - name: Setup PNPM 31 | uses: pnpm/action-setup@v2.2.4 32 | with: 33 | version: latest 34 | 35 | - name: Build Docs 36 | run: pnpm i && pnpm build && pnpm -r run docs 37 | 38 | - name: Setup Pages 39 | uses: actions/configure-pages@v1 40 | 41 | - name: Upload artifact 42 | uses: actions/upload-pages-artifact@v1 43 | with: 44 | path: "packages/chatgpt-agent/docs" 45 | 46 | - name: Deploy to GitHub Pages 47 | id: deployment 48 | uses: actions/deploy-pages@main 49 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | workflow_dispatch: 8 | 9 | jobs: 10 | release-npm: 11 | name: Release NPM (${{ github.ref }}) 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout Repository 15 | uses: actions/checkout@v3 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Check if Tag on Main 20 | run: | 21 | git checkout main 22 | branch=$(git branch main --contains ${{ github.ref }}) 23 | git checkout ${{ github.ref }} 24 | if [ -z $branch ]; then 25 | echo "Tag ${{ github.ref }} is not contained in the main branch." 26 | exit 1 27 | fi 28 | 29 | - name: Setup PNPM 30 | uses: pnpm/action-setup@v2.2.4 31 | with: 32 | version: latest 33 | run_install: true 34 | 35 | - name: Build & Test 36 | run: pnpm build && pnpm test 37 | continue-on-error: true 38 | 39 | - name: Publish 40 | run: | 41 | pnpm config set //registry.npmjs.org/:_authToken ${{ secrets.NPM_TOKEN }} 42 | pnpm publish -r --verbose --access public --no-git-checks 43 | 44 | release-note: 45 | name: Release Note (${{ github.ref }}) 46 | runs-on: ubuntu-latest 47 | needs: 48 | - release-npm 49 | steps: 50 | - name: Checkout Repository 51 | uses: actions/checkout@v3 52 | with: 53 | fetch-depth: 0 54 | 55 | - name: Check if Tag on Main 56 | run: | 57 | git checkout main 58 | branch=$(git branch main --contains ${{ github.ref }}) 59 | git checkout ${{ github.ref }} 60 | if [ -z $branch ]; then 61 | echo "Tag ${{ github.ref }} is not contained in the main branch." 62 | exit 1 63 | fi 64 | 65 | - name: Publish Release 66 | uses: "marvinpinto/action-automatic-releases@latest" 67 | with: 68 | repo_token: "${{ secrets.GH_TOKEN }}" 69 | prerelease: false 70 | files: | 71 | README.md 72 | LICENSE 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Serverless directories 108 | .serverless/ 109 | 110 | # FuseBox cache 111 | .fusebox/ 112 | 113 | # DynamoDB Local files 114 | .dynamodb/ 115 | 116 | # TernJS port file 117 | .tern-port 118 | 119 | # Stores VSCode versions used for testing VSCode extensions 120 | .vscode-test 121 | 122 | # yarn v2 123 | .yarn/cache 124 | .yarn/unplugged 125 | .yarn/build-state.yml 126 | .yarn/install-state.gz 127 | .pnp.* 128 | 129 | .DS_Store 130 | .pnpm-store/ 131 | data/ 132 | .store/ 133 | lib/ 134 | docs/ 135 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | printWidth: 100 3 | tabWidth: 4 4 | useTabs: false 5 | trailingComma: all 6 | semi: true 7 | singleQuote: false 8 | overrides: 9 | - files: "*.{md,yml,yaml}" 10 | options: 11 | tabWidth: 2 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM node:lts as builder 2 | 3 | WORKDIR /app 4 | RUN npm i -g pnpm 5 | COPY . . 6 | RUN pnpm i 7 | RUN pnpm build 8 | RUN rm -rf node_modules 9 | RUN pnpm i --prod && pnpm store prune 10 | 11 | FROM node:alpine as runner 12 | 13 | WORKDIR /app 14 | COPY --from=builder /app . 15 | 16 | ENTRYPOINT ["node", "packages/discord-bot/dist/index.js"] 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 JacobLinCool 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ChatGPT Agent 2 | 3 | This is a library, a CLI, and a Discord bot for the unofficial [ChatGPT](https://chat.openai.com/chat) API with progressive responses and more. 4 | 5 | You can find READMEs for the library, CLI, and Discord bot in their respective folders in `packages/`. 6 | 7 | The library documentation is available at [https://jacoblincool.github.io/ChatGPT-Agent/](https://jacoblincool.github.io/ChatGPT-Agent/). 8 | 9 | The CLI is available on [npm](https://www.npmjs.com/package/chatgpt-cli). 10 | 11 | And you can invite the Discord bot to your server with [this link](https://discord.com/oauth2/authorize?client_id=1049030945832972389&permissions=274877975552&scope=bot). (Or host it by yourself: clone the repo, setup `.env`, and run `docker compose up`.) 12 | 13 | ## The Library 14 | 15 | [Features](#features) | [Usage](#usage) | [Configuration](#configuration) 16 | 17 | ### Features 18 | 19 | - Progressive responses 20 | - Multiple sessions 21 | - Conversation history 22 | - Automatic token refresh 23 | - Request timeout 24 | - Request retry 25 | 26 | ### Usage 27 | 28 | ```ts 29 | // refresh_token is optional 30 | const agent = new Agent(token, refresh_token); 31 | 32 | // multiple sessions can be created 33 | const session = agent.session(); 34 | 35 | const first_conv = session.talk("Hello"); 36 | first_conv.on("partial", partial); 37 | console.log("completed", await first_conv.response); 38 | first_conv.off("partial", partial); 39 | 40 | const second_conv = session.talk("Tell me a joke"); 41 | second_conv.on("partial", partial); 42 | console.log("completed", await second_conv.response); 43 | second_conv.off("partial", partial); 44 | 45 | console.log("history", session.history); 46 | ``` 47 | 48 | `Agent` is the main class for the library. It represents a single user, and handles the token (and refresh token), and can create multiple sessions. 49 | 50 | `Session` is a class that represents a single context just like the new ChatGPT tab you opened in the browser. It can be created with `Agent.session()`. It has a `history` property that is an array of messages, and a `talk` method that takes a message and returns a `Conversation` object. 51 | 52 | `Conversation` is a class that represents a single conversation. It has a `response` property that is a promise that resolves to the full response, and an `on` method that takes an event name and a callback. You can listen to `partial` events, which are fired when the response is updated, and `complete` events, which are fired when the response is complete. Also, `error` events are fired when an error occurs. 53 | 54 | ### Configuration 55 | 56 | You can configure the backend URL and the request timeout for the agent. 57 | 58 | ```ts 59 | const agent = new Agent(token, refresh_token, { 60 | backend: "https://chat.openai.com/backend-api", 61 | timeout: 60_000, 62 | }); 63 | ``` 64 | 65 | The default backend URL is `https://chat.openai.com/backend-api`, and the default timeout is 60 seconds. 66 | 67 | You can also configure them using environment variables. 68 | 69 | ```sh 70 | CHATGPT_BACKEND="https://chat.openai.com/backend-api" 71 | CHATGPT_TIMEOUT=60000 72 | CHATGPT_RETRY=1 73 | ``` 74 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | bot: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | target: runner 9 | image: jacoblincool/chatgpt-agent 10 | container_name: chatgpt-agent 11 | restart: unless-stopped 12 | env_file: 13 | - .env 14 | volumes: 15 | - ./.store:/app/.store 16 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "node", 4 | }; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "test": "jest --coverage", 5 | "lint": "eslint .", 6 | "lint:fix": "eslint . --fix", 7 | "format": "prettier --write . --ignore-path .gitignore", 8 | "build": "pnpm run -r build" 9 | }, 10 | "devDependencies": { 11 | "@types/jest": "^29.2.4", 12 | "@typescript-eslint/eslint-plugin": "^5.45.0", 13 | "@typescript-eslint/parser": "^5.45.0", 14 | "eslint": "^8.29.0", 15 | "eslint-config-prettier": "^8.5.0", 16 | "jest": "^29.3.1", 17 | "prettier": "^2.8.0", 18 | "ts-jest": "^29.0.3", 19 | "tsx": "^3.12.1", 20 | "typescript": "^4.9.3" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/chatgpt-agent/README.md: -------------------------------------------------------------------------------- 1 | # ChatGPT Agent 2 | 3 | Client for the unofficial ChatGPT API. With progressive responses and more. 4 | 5 | [Features](#features) | [Usage](#usage) | [Configuration](#configuration) 6 | 7 | ## Features 8 | 9 | - Progressive responses 10 | - Multiple sessions 11 | - Conversation history 12 | - Automatic token refresh 13 | - Request timeout 14 | - Request retry 15 | 16 | ## Usage 17 | 18 | ```ts 19 | // refresh_token is optional 20 | const agent = new Agent(token, refresh_token); 21 | 22 | // multiple sessions can be created 23 | const session = agent.session(); 24 | 25 | const first_conv = session.talk("Hello"); 26 | first_conv.on("partial", partial); 27 | first_conv.once("complete", (complete) => { 28 | console.log("complete (event)", complete); 29 | }); 30 | console.log("completed (promise)", await first_conv.response); 31 | first_conv.off("partial", partial); 32 | 33 | const second_conv = session.talk("Tell me a joke"); 34 | second_conv.on("partial", partial); 35 | second_conv.once("complete", (complete) => { 36 | console.log("complete (event)", complete); 37 | }); 38 | console.log("completed (promise)", await second_conv.response); 39 | second_conv.off("partial", partial); 40 | 41 | console.log("history", session.history); 42 | ``` 43 | 44 | `Agent` is the main class for the library. It represents a single user, and handles the token (and refresh token), and can create multiple sessions. 45 | 46 | `Session` is a class that represents a single context just like the new ChatGPT tab you opened in the browser. It can be created with `Agent.session()`. It has a `history` property that is an array of messages, and a `talk` method that takes a message and returns a `Conversation` object. 47 | 48 | `Conversation` is a class that represents a single conversation. It has a `response` property that is a promise that resolves to the full response, and an `on` method that takes an event name and a callback. You can listen to `partial` events, which are fired when the response is updated, and `complete` events, which are fired when the response is complete. Also, `error` events are fired when an error occurs. 49 | 50 | ## Configuration 51 | 52 | You can configure the backend URL and the request timeout for the agent. 53 | 54 | ```ts 55 | const agent = new Agent(token, refresh_token, { 56 | backend: "https://chat.openai.com/backend-api", 57 | timeout: 60_000, 58 | }); 59 | ``` 60 | 61 | The default backend URL is `https://chat.openai.com/backend-api`, and the default timeout is 60 seconds. 62 | 63 | You can also configure them using environment variables. 64 | 65 | ```sh 66 | CHATGPT_BACKEND="https://chat.openai.com/backend-api" 67 | CHATGPT_TIMEOUT=60000 68 | CHATGPT_RETRY=1 69 | ``` 70 | 71 | See [the documentation](https://jacoblincool.github.io/ChatGPT-Agent/) for more information. 72 | -------------------------------------------------------------------------------- /packages/chatgpt-agent/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chatgpt-agent", 3 | "version": "1.3.1", 4 | "description": "Client for the unofficial ChatGPT API with progressive responses and more.", 5 | "keywords": [ 6 | "ChatGPT", 7 | "OpenAI", 8 | "GPT", 9 | "AI", 10 | "NLP" 11 | ], 12 | "author": "JacobLinCool (https://github.com/JacobLinCool)", 13 | "license": "MIT", 14 | "main": "lib/index.js", 15 | "module": "lib/index.mjs", 16 | "types": "lib/index.d.ts", 17 | "files": [ 18 | "lib" 19 | ], 20 | "scripts": { 21 | "build": "tsup", 22 | "docs": "typedoc --out docs src" 23 | }, 24 | "dependencies": { 25 | "debug": "^4.3.4", 26 | "node-fetch": "^2.6.7" 27 | }, 28 | "devDependencies": { 29 | "@types/debug": "^4.1.7", 30 | "@types/node": "^18.11.10", 31 | "@types/node-fetch": "^2.6.2", 32 | "dotenv": "^16.0.3", 33 | "tsup": "^6.5.0", 34 | "typedoc": "^0.23.21", 35 | "typescript": "^4.9.3" 36 | }, 37 | "homepage": "https://github.com/JacobLinCool/ChatGPT-Agent", 38 | "repository": { 39 | "type": "git", 40 | "url": "https://github.com/JacobLinCool/ChatGPT-Agent.git" 41 | }, 42 | "bugs": { 43 | "url": "https://github.com/JacobLinCool/ChatGPT-Agent/issues" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/chatgpt-agent/src/_tests/session.test.ts: -------------------------------------------------------------------------------- 1 | import { config } from "dotenv"; 2 | import { Agent } from ".."; 3 | 4 | jest.setTimeout(30_000); 5 | 6 | config(); 7 | 8 | const token = process.env.TOKEN; 9 | if (!token) { 10 | throw new Error("No token provided"); 11 | } 12 | 13 | const partial = (partial: string) => { 14 | console.log("partial", partial); 15 | }; 16 | 17 | test("Test", async () => { 18 | const agent = new Agent(token); 19 | const session = agent.session(); 20 | 21 | const first_conv = session.talk("Hello"); 22 | first_conv.on("partial", partial); 23 | first_conv.once("complete", (complete) => { 24 | console.log("complete (event)", complete); 25 | }); 26 | console.log("completed (promise)", await first_conv.response); 27 | first_conv.off("partial", partial); 28 | 29 | const second_conv = session.talk("Tell me a joke"); 30 | second_conv.on("partial", partial); 31 | second_conv.once("complete", (complete) => { 32 | console.log("complete (event)", complete); 33 | }); 34 | console.log("completed (promise)", await second_conv.response); 35 | second_conv.off("partial", partial); 36 | 37 | console.log("history", session.history); 38 | }); 39 | -------------------------------------------------------------------------------- /packages/chatgpt-agent/src/agent.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from "node:events"; 2 | import { Session } from "./session"; 3 | import { refresh } from "./request"; 4 | import { CHATGPT_BACKEND, CHATGPT_TIMEOUT } from "./config"; 5 | 6 | export class Agent extends EventEmitter { 7 | public sessions = new Map(); 8 | public backend: string; 9 | public timeout: number; 10 | 11 | /** 12 | * @param token - The token to use. 13 | * @param refresh_token - Optional refresh token. 14 | * @param options - Options, including the api backend and timeout. 15 | */ 16 | constructor( 17 | public token: string, 18 | public refresh_token?: string, 19 | { 20 | backend = CHATGPT_BACKEND, 21 | timeout = CHATGPT_TIMEOUT, 22 | }: { backend?: string; timeout?: number } = {}, 23 | ) { 24 | super(); 25 | this.backend = backend; 26 | this.timeout = timeout; 27 | } 28 | 29 | /** 30 | * Creates a new session. 31 | */ 32 | public session(): Session { 33 | const sess = new Session(this); 34 | this.sessions.set(sess.id, sess); 35 | sess.on("rename", (data) => { 36 | this.sessions.delete(data.old); 37 | this.sessions.set(data.new, sess); 38 | }); 39 | return sess; 40 | } 41 | 42 | /** 43 | * Refreshes the token using the refresh token. 44 | * 45 | * @throws Error if no refresh token is set. 46 | * @throws Error if the token could not be refreshed. 47 | */ 48 | public async refresh(): Promise { 49 | if (!this.refresh_token) { 50 | throw new Error("No refresh token"); 51 | } 52 | 53 | const result = await refresh(this.refresh_token); 54 | 55 | this.token = result.token; 56 | this.refresh_token = result.refresh; 57 | this.emit("refresh", result); 58 | } 59 | 60 | /** 61 | * Validates the token 62 | * @returns true if the token is valid, false otherwise. 63 | */ 64 | public validate(): boolean { 65 | try { 66 | const [header, payload, signature] = this.token.split("."); 67 | 68 | if (!header || !payload || !signature) { 69 | return false; 70 | } 71 | 72 | const payloadJSON = JSON.parse(Buffer.from(payload, "base64").toString()); 73 | if (payloadJSON.exp < Date.now() / 1000) { 74 | return false; 75 | } 76 | 77 | return true; 78 | } catch { 79 | return false; 80 | } 81 | } 82 | 83 | public on(event: "refresh", listener: (data: { token: string; refresh: string }) => void): this; 84 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 85 | public on(event: string, listener: (...args: any[]) => void): this { 86 | return super.on(event, listener); 87 | } 88 | 89 | public off( 90 | event: "refresh", 91 | listener: (data: { token: string; refresh: string }) => void, 92 | ): this; 93 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 94 | public off(event: string, listener: (...args: any[]) => void): this { 95 | return super.off(event, listener); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /packages/chatgpt-agent/src/config.ts: -------------------------------------------------------------------------------- 1 | /** The base URL of the ChatGPT backend API. */ 2 | export const CHATGPT_BACKEND = process.env.CHATGPT_BACKEND || "https://chat.openai.com/backend-api"; 3 | 4 | /** The maximum request time in milliseconds. */ 5 | export const CHATGPT_TIMEOUT = Number(process.env.CHATGPT_TIMEOUT) || 60_000; 6 | 7 | /** The maximum number of times to retry a request. */ 8 | export const CHATGPT_RETRY = Number(process.env.CHATGPT_RETRY) || 1; 9 | -------------------------------------------------------------------------------- /packages/chatgpt-agent/src/conversation.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from "node:events"; 2 | import type { Session } from "./session"; 3 | import { converse } from "./request"; 4 | import { log } from "./debug"; 5 | 6 | const verbose = log.extend("verbose"); 7 | 8 | export class Conversation extends EventEmitter { 9 | /** 10 | * The response of the conversation, resolved when the conversation is complete. 11 | */ 12 | public response: Promise; 13 | 14 | constructor(public session: Session, public message: string) { 15 | super(); 16 | this.response = this.run(); 17 | } 18 | 19 | public async run(): Promise { 20 | if (this.session.agent.validate() === false) { 21 | try { 22 | await this.session.agent.refresh(); 23 | } catch (err) { 24 | this.emit("error", err as Error); 25 | } 26 | } 27 | 28 | const result = await this.converse().catch((err) => (this.emit("error", err), "")); 29 | log("Conversation Result", result); 30 | return result; 31 | } 32 | 33 | public async converse(): Promise { 34 | const history = this.session.history.filter((h) => h.author === "assistant"); 35 | 36 | const stream = await converse( 37 | this.session.agent.token, 38 | this.message, 39 | this.session.id.startsWith("tmp-") ? undefined : this.session.id, 40 | history[history.length - 1]?.id, 41 | this.session.agent.backend, 42 | ); 43 | 44 | let last: Block; 45 | return await new Promise((resolve) => { 46 | let data = ""; 47 | stream.on("data", (chunk: Buffer) => { 48 | data += chunk.toString(); 49 | const split = data.indexOf("\n\n"); 50 | if (split !== -1) { 51 | const part = data.slice(0, split).replace(/^data: /, ""); 52 | verbose("Partially Received", part); 53 | if (part.startsWith("[DONE]")) { 54 | return; 55 | } 56 | try { 57 | const json: Block = JSON.parse(part); 58 | this.emit("partial", json.message.content.parts.join("\n")); 59 | last = json; 60 | } catch (err) { 61 | this.emit("error", err as Error); 62 | } 63 | data = data.slice(split + 2); 64 | } 65 | }); 66 | stream.on("end", () => { 67 | const response = last?.message.content.parts.join("\n"); 68 | log("Received", response); 69 | this.session.history.push({ 70 | id: last?.message.id, 71 | author: "assistant", 72 | message: response, 73 | conversation: this, 74 | }); 75 | this.session.rename(last?.conversation_id); 76 | this.emit("complete", response); 77 | resolve(response); 78 | }); 79 | }); 80 | } 81 | 82 | /** 83 | * Emitted when a partial response is received. 84 | */ 85 | public on(event: "partial", listener: (partial: string) => void): this; 86 | /** 87 | * Emitted when the conversation is complete. 88 | */ 89 | public on(event: "complete", listener: (complete: string) => void): this; 90 | /** 91 | * Emitted when an error occurs. 92 | */ 93 | public on(event: "error", listener: (error: Error) => void): this; 94 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 95 | public on(event: string, listener: (...args: any[]) => void): this { 96 | return super.on(event, listener); 97 | } 98 | 99 | /** 100 | * Emitted when a partial response is received. 101 | */ 102 | public once(event: "partial", listener: (partial: string) => void): this; 103 | /** 104 | * Emitted when the conversation is complete. 105 | */ 106 | public once(event: "complete", listener: (complete: string) => void): this; 107 | /** 108 | * Emitted when an error occurs. 109 | */ 110 | public once(event: "error", listener: (error: Error) => void): this; 111 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 112 | public once(event: string, listener: (...args: any[]) => void): this { 113 | return super.once(event, listener); 114 | } 115 | 116 | public off(event: "partial", listener: (partial: string) => void): this; 117 | public off(event: "complete", listener: (complete: string) => void): this; 118 | public off(event: "error", listener: (error: Error) => void): this; 119 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 120 | public off(event: string, listener: (...args: any[]) => void): this { 121 | return super.off(event, listener); 122 | } 123 | 124 | public emit(event: "partial", partial: string): boolean; 125 | public emit(event: "complete", complete: string): boolean; 126 | public emit(event: "error", error: Error): boolean; 127 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 128 | public emit(event: string, ...args: any[]): boolean { 129 | return super.emit(event, ...args); 130 | } 131 | } 132 | 133 | export interface Block { 134 | message: { 135 | id: string; 136 | role: "assistant"; 137 | user: null; 138 | create_time: null; 139 | update_time: null; 140 | content: { content_type: "text"; parts: string[] }; 141 | end_turn: null; 142 | weight: number; 143 | metadata: unknown; 144 | recipient: "all"; 145 | }; 146 | conversation_id: string; 147 | error: null; 148 | } 149 | -------------------------------------------------------------------------------- /packages/chatgpt-agent/src/debug.ts: -------------------------------------------------------------------------------- 1 | import debug from "debug"; 2 | 3 | export const log = debug("chatgpt"); 4 | -------------------------------------------------------------------------------- /packages/chatgpt-agent/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./agent"; 2 | export * from "./session"; 3 | export * from "./conversation"; 4 | export * from "./request"; 5 | -------------------------------------------------------------------------------- /packages/chatgpt-agent/src/request.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from "node:crypto"; 2 | import fetch, { Headers } from "node-fetch"; 3 | import { log } from "./debug"; 4 | import { CHATGPT_BACKEND, CHATGPT_TIMEOUT, CHATGPT_RETRY } from "./config"; 5 | 6 | export function make_headers(token?: string): Headers { 7 | const headers = new Headers(); 8 | if (token) { 9 | headers.set("Authorization", `Bearer ${token}`); 10 | } 11 | headers.set("Content-Type", "application/json"); 12 | headers.set( 13 | "User-Agent", 14 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36", 15 | ); 16 | return headers; 17 | } 18 | 19 | export async function moderate( 20 | token: string, 21 | input: string, 22 | backend = CHATGPT_BACKEND, 23 | timeout = CHATGPT_TIMEOUT, 24 | ): Promise<{ flagged: boolean; blocked: boolean; moderation_id: string }> { 25 | const headers = make_headers(token); 26 | log(headers); 27 | 28 | for (let i = 0; i <= CHATGPT_RETRY; i++) { 29 | try { 30 | const controller = new AbortController(); 31 | const timeout_id = setTimeout(() => controller.abort(), timeout); 32 | 33 | const res = await fetch(`${backend}/moderations`, { 34 | headers, 35 | body: JSON.stringify({ 36 | input, 37 | model: "text-moderation-playground", 38 | }), 39 | method: "POST", 40 | // @ts-expect-error signal should be compatible with AbortSignal 41 | signal: controller.signal, 42 | }); 43 | clearTimeout(timeout_id); 44 | 45 | if (controller.signal.aborted) { 46 | throw new Error("Request timed out"); 47 | } 48 | 49 | log("sent moderation request", res.status); 50 | if (res.status !== 200) { 51 | try { 52 | const data = await res.clone().json(); 53 | log("moderation error", data); 54 | throw new Error(data?.error); 55 | } catch { 56 | const text = await res.clone().text(); 57 | log("moderation error", text); 58 | throw new Error(text); 59 | } 60 | } 61 | 62 | const data = await res.json(); 63 | return data; 64 | } catch (err) { 65 | if (i === CHATGPT_RETRY - 1) { 66 | throw err; 67 | } 68 | log("retrying moderation request", i + 1); 69 | } 70 | } 71 | 72 | throw new Error("Failed to moderate"); 73 | } 74 | 75 | /** 76 | * Sends a request to the ChatGPT API to get a response body. 77 | * 78 | * @param token - The ChatGPT token 79 | * @param content - The content of the message 80 | * @param conversation_id - The conversation id (optional) 81 | * @param parent_id - The parent id (optional) 82 | * @param backend - The ChatGPT backend (optional) 83 | * @param timeout - The timeout (optional) 84 | * @returns The response body from the ChatGPT API 85 | */ 86 | export async function converse( 87 | token: string, 88 | content: string, 89 | conversation_id?: string, 90 | parent_id?: string, 91 | backend = CHATGPT_BACKEND, 92 | timeout = CHATGPT_TIMEOUT, 93 | ): Promise { 94 | const headers = make_headers(token); 95 | headers.set("Accept", "text/event-stream"); 96 | log(headers); 97 | 98 | for (let i = 0; i <= CHATGPT_RETRY; i++) { 99 | try { 100 | const controller = new AbortController(); 101 | const timeout_id = setTimeout(() => controller.abort(), timeout); 102 | 103 | const res = await fetch(`${backend}/conversation`, { 104 | headers, 105 | body: JSON.stringify({ 106 | action: "next", 107 | messages: [ 108 | { 109 | id: randomUUID(), 110 | role: "user", 111 | content: { content_type: "text", parts: [content] }, 112 | }, 113 | ], 114 | parent_message_id: parent_id || randomUUID(), 115 | conversation_id, 116 | model: "text-davinci-002-render", 117 | }), 118 | method: "POST", 119 | // @ts-expect-error signal should be compatible with AbortSignal 120 | signal: controller.signal, 121 | }); 122 | clearTimeout(timeout_id); 123 | 124 | if (controller.signal.aborted) { 125 | throw new Error("Request timed out"); 126 | } 127 | 128 | log("sent conversation request", res.status); 129 | if (res.status !== 200) { 130 | try { 131 | const data = await res.clone().json(); 132 | log("conversation error", data); 133 | throw new Error(data?.error?.detail); 134 | } catch { 135 | const text = await res.clone().text(); 136 | log("conversation error", text); 137 | throw new Error(text); 138 | } 139 | } 140 | 141 | return res.body; 142 | } catch (err) { 143 | if (i === CHATGPT_RETRY - 1) { 144 | throw err; 145 | } 146 | log("retrying conversation request", i + 1); 147 | } 148 | } 149 | 150 | throw new Error("Failed to converse"); 151 | } 152 | 153 | export async function refresh(refresh_token: string): Promise<{ token: string; refresh: string }> { 154 | const headers = make_headers(); 155 | headers.set("Cookie", `__Secure-next-auth.session-token=${refresh_token}`); 156 | log(headers); 157 | 158 | const res = await fetch("https://chat.openai.com/api/auth/session", { headers }); 159 | log("sent refresh request", res.status); 160 | if (res.status !== 200) { 161 | try { 162 | const data = await res.clone().json(); 163 | log("refresh error", data); 164 | throw new Error(data?.error); 165 | } catch { 166 | const text = await res.clone().text(); 167 | log("refresh error", text); 168 | throw new Error(text); 169 | } 170 | } 171 | 172 | const data = await res.json(); 173 | if (data?.error) { 174 | throw new Error(data.error); 175 | } 176 | 177 | const new_refresh_token = res.headers.get("set-cookie")?.split(";")[0]; 178 | const refresh = new_refresh_token?.split("=")[1]; 179 | 180 | if (data?.accessToken) { 181 | const new_refresh_token = res.headers.get("set-cookie")?.split(";")[0]; 182 | if (new_refresh_token) { 183 | log("refreshed refresh token", new_refresh_token); 184 | } 185 | return { 186 | token: data.accessToken, 187 | refresh: refresh || "", 188 | }; 189 | } else { 190 | throw new Error("No access token returned"); 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /packages/chatgpt-agent/src/session.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from "node:events"; 2 | import type { Agent } from "./agent"; 3 | import { Conversation } from "./conversation"; 4 | 5 | export class Session extends EventEmitter { 6 | /** 7 | * Session id. 8 | */ 9 | public id = `tmp-${Date.now()}-${Math.random().toString(36).slice(2)}`; 10 | /** 11 | * The conversation history, including the user's messages and the assistant's replies. 12 | * Only the assistant's replies have a conversation property. 13 | */ 14 | public history: { 15 | author: "user" | "assistant"; 16 | message: string; 17 | id: string; 18 | conversation?: Conversation; 19 | }[] = []; 20 | 21 | constructor(public agent: Agent) { 22 | super(); 23 | } 24 | 25 | /** 26 | * Sends a message to the session. 27 | * @param message The message to send. 28 | * @returns A new conversation. 29 | */ 30 | public talk(message: string): Conversation { 31 | this.history.push({ 32 | author: "user", 33 | message, 34 | id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, 35 | }); 36 | return new Conversation(this, message); 37 | } 38 | 39 | public rename(id: string): void { 40 | this.emit("rename", { old: this.id, new: id }); 41 | this.id = id; 42 | } 43 | 44 | /** 45 | * Get all the replies from the session. 46 | * @returns An array of conversations. 47 | */ 48 | public replies(): Conversation[] { 49 | return this.history 50 | .filter((h) => h.author === "assistant" && h.conversation) 51 | .map((h) => h.conversation) as Conversation[]; 52 | } 53 | 54 | public on(event: "rename", listener: (data: { old: string; new: string }) => void): this; 55 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 56 | public on(event: string, listener: (...args: any[]) => void): this { 57 | return super.on(event, listener); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /packages/chatgpt-agent/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig(() => ({ 4 | entry: ["src/index.ts"], 5 | outDir: "lib", 6 | target: "node18", 7 | format: ["cjs", "esm"], 8 | shims: true, 9 | clean: true, 10 | splitting: false, 11 | dts: true, 12 | })); 13 | -------------------------------------------------------------------------------- /packages/chatgpt-cli/README.md: -------------------------------------------------------------------------------- 1 | # ChatGPT CLI 2 | 3 | ```sh 4 | pnpm i -g chatgpt-cli 5 | ``` 6 | 7 | Start a conversation with ChatGPT: 8 | 9 | ```sh 10 | chatgpt 11 | ``` 12 | 13 | Start a conversation with first message: 14 | 15 | ```sh 16 | chatgpt "Hello! How are you?" 17 | ``` 18 | 19 | If you say `bye` to it, it will gracefully exit the conversation. 20 | -------------------------------------------------------------------------------- /packages/chatgpt-cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chatgpt-cli", 3 | "version": "1.3.1", 4 | "author": "JacobLinCool (https://github.com/JacobLinCool)", 5 | "license": "MIT", 6 | "type": "module", 7 | "bin": { 8 | "chatgpt-cli": "dist/index.js", 9 | "chatgpt": "dist/index.js" 10 | }, 11 | "files": [ 12 | "dist" 13 | ], 14 | "scripts": { 15 | "start": "tsx src/index.ts", 16 | "build": "tsup" 17 | }, 18 | "dependencies": { 19 | "chatgpt-agent": "workspace:*", 20 | "dotenv": "^16.0.3", 21 | "ora": "^6.1.2" 22 | }, 23 | "devDependencies": { 24 | "@types/node": "^18.11.10", 25 | "tsup": "^6.5.0", 26 | "tsx": "^3.12.1", 27 | "typescript": "^4.9.3" 28 | }, 29 | "homepage": "https://github.com/JacobLinCool/ChatGPT-Agent", 30 | "repository": { 31 | "type": "git", 32 | "url": "https://github.com/JacobLinCool/ChatGPT-Agent.git" 33 | }, 34 | "bugs": { 35 | "url": "https://github.com/JacobLinCool/ChatGPT-Agent/issues" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/chatgpt-cli/src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { createInterface } from "node:readline"; 3 | import { config } from "dotenv"; 4 | import { Agent } from "chatgpt-agent"; 5 | import ora from "ora"; 6 | 7 | config(); 8 | config({ path: "~/.chatgpt.env" }); 9 | 10 | const token = process.env.TOKEN; 11 | if (!token) { 12 | throw new Error("You must provide a token as environment variable `TOKEN`"); 13 | } 14 | 15 | const agent = new Agent(token, token); 16 | const session = agent.session(); 17 | 18 | (async () => { 19 | if (!agent.validate()) { 20 | await agent.refresh(); 21 | console.log("Refreshed token", agent.token); 22 | } 23 | 24 | ask(process.argv.slice(2)); 25 | })(); 26 | 27 | async function ask(prefills?: string[]) { 28 | const io = createInterface({ input: process.stdin, output: process.stdout }); 29 | io.write("\x1b[33m"); 30 | const message = await new Promise((resolve) => { 31 | if (prefills?.length) { 32 | io.write("You: " + prefills[0] + "\n"); 33 | resolve(prefills[0]); 34 | } else { 35 | io.question("You: ", resolve); 36 | } 37 | }); 38 | io.write("\x1b[0m"); 39 | io.close(); 40 | 41 | const conv = session.talk(message); 42 | const spinner = ora("Assistant: Thinking ...").start(); 43 | 44 | const update = (partial: string) => { 45 | spinner.text = `Assistant: ${partial} ...`; 46 | }; 47 | 48 | conv.on("partial", update); 49 | conv.once("error", (err) => { 50 | spinner.fail(`ERROR: ${err.message}`); 51 | }); 52 | const response = await conv.response; 53 | conv.off("partial", update); 54 | 55 | spinner.stop(); 56 | console.log(`Assistant: ${response.trim()}`); 57 | 58 | if (!message.toLowerCase().startsWith("bye")) { 59 | ask(prefills?.slice(1)); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /packages/chatgpt-cli/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig(() => ({ 4 | entry: ["src/index.ts"], 5 | outDir: "dist", 6 | target: "node18", 7 | format: ["esm"], 8 | shims: true, 9 | clean: true, 10 | splitting: false, 11 | })); 12 | -------------------------------------------------------------------------------- /packages/discord-bot/.env.example: -------------------------------------------------------------------------------- 1 | BOT_ID= 2 | BOT_TOKEN= 3 | -------------------------------------------------------------------------------- /packages/discord-bot/README.md: -------------------------------------------------------------------------------- 1 | # ChatGPT Bot 2 | 3 | ## Setup 4 | 5 | 1. Create a Discord bot from the [Discord Developer Portal](https://discord.com/developers/applications). 6 | 2. Setup the `.env` file with the bot token and the application ID. 7 | 3. Register the slash commands. (`pnpm register`) 8 | 4. Start the bot. (`pnpm start`) 9 | 10 | > Step 4 can be replaced with `docker compose up -d` if you want to use Docker. 11 | 12 | ## Usage 13 | 14 | Every user needs to auth (`/auth `) the bot with their own JWT before using it. 15 | 16 | Then, they can start a conversation with the bot by using `/start [preset]`. 17 | 18 | After the bot sets up the conversation with the preset, the user can simply type messages to continue the conversation. 19 | 20 | After the conversation is done, the user can use `/stop` to end the conversation. 21 | -------------------------------------------------------------------------------- /packages/discord-bot/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "chatgpt-agent-bot", 4 | "version": "0.1.0", 5 | "description": "", 6 | "main": "lib/index.js", 7 | "scripts": { 8 | "start": "tsx src/index.ts", 9 | "register": "tsx src/register.ts", 10 | "build": "tsup" 11 | }, 12 | "keywords": [], 13 | "author": "JacobLinCool (https://github.com/JacobLinCool)", 14 | "license": "MIT", 15 | "types": "lib/index.d.ts", 16 | "files": [ 17 | "lib" 18 | ], 19 | "dependencies": { 20 | "chatgpt-agent": "workspace:*", 21 | "discord.js": "^14.7.1", 22 | "dotenv": "^16.0.3", 23 | "jwt-decode": "^3.1.2", 24 | "pure-cat": "1.0.0-alpha.9", 25 | "pure-cat-store-file": "1.0.0-alpha.9" 26 | }, 27 | "devDependencies": { 28 | "@types/eventsource": "^1.1.10", 29 | "@types/jest": "^29.2.4", 30 | "@types/node": "^18.11.10", 31 | "@typescript-eslint/eslint-plugin": "^5.45.0", 32 | "@typescript-eslint/parser": "^5.45.0", 33 | "eslint": "^8.29.0", 34 | "eslint-config-prettier": "^8.5.0", 35 | "jest": "^29.3.1", 36 | "prettier": "^2.8.0", 37 | "ts-jest": "^29.0.3", 38 | "tsup": "^6.5.0", 39 | "tsx": "^3.12.1", 40 | "typedoc": "^0.23.21", 41 | "typescript": "^4.9.3" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/discord-bot/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Partials } from "discord.js"; 2 | import { Bot } from "pure-cat"; 3 | import { FileStore } from "pure-cat-store-file"; 4 | import { AgentModule } from "./module"; 5 | 6 | const bot = new Bot().use(new FileStore()).use(new AgentModule()); 7 | bot.client.options.partials = [Partials.Message, Partials.Channel]; 8 | 9 | bot.start().then(() => console.log("Bot started!")); 10 | 11 | process.on("SIGTERM", () => { 12 | process.exit(); 13 | }); 14 | -------------------------------------------------------------------------------- /packages/discord-bot/src/module.ts: -------------------------------------------------------------------------------- 1 | import { BaseModule, CallNextModule, Module, StoreContext } from "pure-cat"; 2 | import { ClientEvents, EmbedBuilder, GatewayIntentBits, Message } from "discord.js"; 3 | import decode from "jwt-decode"; 4 | import { Agent, refresh, Session } from "chatgpt-agent"; 5 | import { PRESET } from "./preset"; 6 | 7 | export class AgentModule extends BaseModule implements Module { 8 | name = "agent"; 9 | 10 | public intents = [ 11 | GatewayIntentBits.Guilds, 12 | GatewayIntentBits.MessageContent, 13 | GatewayIntentBits.GuildMessages, 14 | GatewayIntentBits.DirectMessages, 15 | ]; 16 | 17 | // agent and token is associated with a user 18 | private agents = new Map(); 19 | // session is associated with a channel 20 | private sessions = new Map< 21 | string, 22 | { user: string; session: Session; public: boolean; queue: Message[] } 23 | >(); 24 | 25 | async interactionCreate( 26 | args: ClientEvents["interactionCreate"], 27 | ctx: StoreContext, 28 | next: CallNextModule, 29 | ): Promise { 30 | const interaction = args[0]; 31 | 32 | if (interaction.isChatInputCommand()) { 33 | switch (interaction.commandName) { 34 | case "auth": { 35 | const raw = interaction.options.getString("token"); 36 | const file = interaction.options.getAttachment("file"); 37 | 38 | await interaction.reply({ ephemeral: true, content: "Authenticating ..." }); 39 | 40 | const token = raw 41 | ? raw 42 | : file 43 | ? await fetch(file.url).then((res) => res.text()) 44 | : undefined; 45 | 46 | if (!token) { 47 | await interaction.editReply(":x: You need to provide a token"); 48 | return; 49 | } 50 | 51 | const is_refresh_token = token.includes(".."); 52 | if (!is_refresh_token) { 53 | await interaction.editReply(":x: This is not a valid token"); 54 | return; 55 | } 56 | 57 | try { 58 | if ((await refresh(token)) === undefined) { 59 | throw new Error("Invalid token"); 60 | } 61 | } catch { 62 | await interaction.editReply(":x: This refresh token is invalid"); 63 | return; 64 | } 65 | 66 | const data = await ctx.user(); 67 | if (data) { 68 | data["openai-token"] = token; 69 | } 70 | 71 | await interaction.editReply(":white_check_mark: Successfully authenticated"); 72 | 73 | break; 74 | } 75 | case "revoke": { 76 | const data = await ctx.user(); 77 | 78 | if (data && data["openai-token"]) { 79 | data["openai-token"] = undefined; 80 | await interaction.reply({ 81 | ephemeral: true, 82 | content: ":white_check_mark: Successfully revoked your token", 83 | }); 84 | } else { 85 | await interaction.reply({ 86 | ephemeral: true, 87 | content: ":x: There is no token to revoke", 88 | }); 89 | } 90 | 91 | break; 92 | } 93 | case "me": { 94 | const data = await ctx.user(); 95 | const token = data?.["openai-token"] 96 | ? this.agents.get(data["openai-token"])?.token 97 | : undefined; 98 | 99 | if (token) { 100 | const decoded = decode(token); 101 | await interaction.reply({ 102 | ephemeral: true, 103 | content: "```json\n" + JSON.stringify(decoded, null, 4) + "\n```", 104 | }); 105 | } else { 106 | await interaction.reply({ 107 | ephemeral: true, 108 | content: ":x: There is no token to show", 109 | }); 110 | } 111 | 112 | break; 113 | } 114 | case "start": { 115 | const preset = interaction.options.getString("preset", false) || "default"; 116 | 117 | const user = await ctx.user(); 118 | if (!user || !user["openai-token"]) { 119 | await interaction.reply({ 120 | ephemeral: true, 121 | content: 122 | ":x: You need to authenticate first, use `/auth` and provide your JWT", 123 | }); 124 | return; 125 | } 126 | 127 | await interaction.deferReply(); 128 | 129 | const agent = 130 | this.agents.get(user["openai-token"]) ?? 131 | new Agent("", user["openai-token"]); 132 | 133 | if (!this.agents.has(user["openai-token"])) { 134 | this.agents.set(user["openai-token"], agent); 135 | } 136 | 137 | try { 138 | if (agent.validate() === false) { 139 | await agent.refresh(); 140 | } 141 | } catch (err) { 142 | await interaction.editReply({ 143 | content: 144 | ":x: Failed to refresh your token, please re-authenticate again", 145 | }); 146 | return; 147 | } 148 | 149 | if (this.sessions.has(interaction.channelId)) { 150 | await interaction.editReply({ 151 | content: ":x: There is already a session running in this channel", 152 | }); 153 | return; 154 | } 155 | 156 | const session = agent.session(); 157 | this.sessions.set(interaction.channelId, { 158 | user: interaction.user.id, 159 | session, 160 | public: false, 161 | queue: [], 162 | }); 163 | 164 | const preloads = 165 | preset in PRESET 166 | ? PRESET[preset as keyof typeof PRESET] 167 | : PRESET["default"]; 168 | 169 | try { 170 | for (const preload of preloads) { 171 | await session.talk(preload).response; 172 | } 173 | 174 | await interaction.editReply({ 175 | content: 176 | ":white_check_mark: Started a session with preset `" + preset + "`", 177 | }); 178 | } catch (err) { 179 | if (err instanceof Error) { 180 | await interaction.editReply({ 181 | content: 182 | ":x: ChatGPT was hit by an error: " + 183 | (err?.message || err).toString(), 184 | }); 185 | } 186 | } 187 | 188 | break; 189 | } 190 | case "stop": { 191 | const session = this.sessions.get(interaction.channelId); 192 | if (!session) { 193 | await interaction.reply({ 194 | ephemeral: true, 195 | content: ":x: There is no session running in this channel", 196 | }); 197 | return; 198 | } 199 | 200 | if (session.user !== interaction.user.id) { 201 | await interaction.reply({ 202 | ephemeral: true, 203 | content: ":x: You are not the owner of this session", 204 | }); 205 | return; 206 | } 207 | 208 | this.sessions.delete(interaction.channelId); 209 | session.session.agent.sessions.delete(session.session.id); 210 | 211 | await interaction.reply({ 212 | ephemeral: true, 213 | content: ":white_check_mark: Successfully stopped the session", 214 | }); 215 | 216 | break; 217 | } 218 | case "public": { 219 | const user = await ctx.user(); 220 | if (!user || !user["openai-token"]) { 221 | await interaction.reply({ 222 | ephemeral: true, 223 | content: ":x: You need to authenticate first", 224 | }); 225 | return; 226 | } 227 | 228 | const agent = this.agents.get(user["openai-token"]); 229 | if (!agent) { 230 | await interaction.reply({ 231 | ephemeral: true, 232 | content: ":x: You need to start a session first", 233 | }); 234 | return; 235 | } 236 | 237 | const session = this.sessions.get(interaction.channelId); 238 | if (!session) { 239 | await interaction.reply({ 240 | ephemeral: true, 241 | content: ":x: There is no session running in this channel", 242 | }); 243 | return; 244 | } 245 | 246 | if (session.user !== interaction.user.id) { 247 | await interaction.reply({ 248 | ephemeral: true, 249 | content: ":x: You are not the owner of this session", 250 | }); 251 | return; 252 | } 253 | 254 | session.public = true; 255 | 256 | await interaction.reply({ 257 | content: `:white_check_mark: <@${interaction.user.id}> has made conversation public in this channel`, 258 | }); 259 | 260 | break; 261 | } 262 | case "private": { 263 | const session = this.sessions.get(interaction.channelId); 264 | 265 | if (session?.public) { 266 | session.public = false; 267 | await interaction.reply({ 268 | content: `:white_check_mark: <@${interaction.user.id}> has made conversation private in this channel`, 269 | }); 270 | } else { 271 | await interaction.reply({ 272 | ephemeral: true, 273 | content: `:x: There is no public conversation in this channel`, 274 | }); 275 | } 276 | 277 | break; 278 | } 279 | case "sessions": { 280 | const user = await ctx.user(); 281 | if (!user || !user["openai-token"]) { 282 | await interaction.reply({ 283 | ephemeral: true, 284 | content: ":x: You need to authenticate first", 285 | }); 286 | return; 287 | } 288 | 289 | const agent = this.agents.get(user["openai-token"]); 290 | if (!agent) { 291 | await interaction.reply({ 292 | ephemeral: true, 293 | content: ":x: No sessions found", 294 | }); 295 | return; 296 | } 297 | 298 | const sessions = [...this.sessions.entries()].filter( 299 | ([, s]) => s.user === interaction.user.id, 300 | ); 301 | 302 | if (sessions.length === 0) { 303 | await interaction.reply({ 304 | ephemeral: true, 305 | content: ":x: No sessions found", 306 | }); 307 | return; 308 | } 309 | 310 | const embed = new EmbedBuilder().setTitle("Your sessions").setDescription( 311 | sessions 312 | .map((s, i) => { 313 | const chan = this.bot?.client.channels.cache.get(s[0]); 314 | const name = chan 315 | ? chan.isDMBased() 316 | ? "DM" 317 | : `${chan.name} (${chan.guild.name})` 318 | : "Unknown"; 319 | return `${i + 1}. ${ 320 | s[1].public ? ":globe_with_meridians:" : ":lock:" 321 | } ${name}`; 322 | }) 323 | .join("\n"), 324 | ); 325 | 326 | await interaction.reply({ 327 | ephemeral: true, 328 | embeds: [embed], 329 | }); 330 | 331 | break; 332 | } 333 | } 334 | } else { 335 | await next(); 336 | } 337 | } 338 | 339 | async messageCreate( 340 | args: [message: Message], 341 | ctx: StoreContext, 342 | next: CallNextModule, 343 | ): Promise { 344 | const message = args[0]; 345 | const chan = message.channel; 346 | 347 | if (message.author.bot) { 348 | await next(); 349 | return; 350 | } 351 | 352 | if (chan.isTextBased() === false) { 353 | await next(); 354 | return; 355 | } 356 | 357 | if (message.content.length === 0) { 358 | await next(); 359 | return; 360 | } 361 | 362 | const session = this.sessions.get(chan.id); 363 | if (!session) { 364 | await next(); 365 | return; 366 | } 367 | 368 | const user = await ctx.user(); 369 | const passed = 370 | chan.isDMBased() || 371 | session.public || 372 | user?.["openai-token"] === session.session.agent.refresh_token; 373 | if (!passed) { 374 | await next(); 375 | return; 376 | } 377 | 378 | session.queue.push(message); 379 | if (session.queue.length === 1) { 380 | await this.consume(session.session, session.queue); 381 | } 382 | } 383 | 384 | async consume(session: Session, queue: Message[]): Promise { 385 | if (queue.length === 0) { 386 | return; 387 | } 388 | 389 | const message = queue[0]; 390 | if (!message) { 391 | return; 392 | } 393 | const chan = message.channel; 394 | 395 | let waiting = true; 396 | let wait_counter = 0; 397 | const wait_interval = 5_000; 398 | 399 | try { 400 | await chan.sendTyping(); 401 | } catch { 402 | waiting = false; 403 | } 404 | 405 | const typing = setInterval(async () => { 406 | wait_counter++; 407 | if (wait_counter > 60_000 / wait_interval) { 408 | waiting = false; 409 | } 410 | 411 | if (waiting === false) { 412 | clearInterval(typing); 413 | } else { 414 | try { 415 | await chan.sendTyping(); 416 | } catch { 417 | clearInterval(typing); 418 | } 419 | } 420 | }, wait_interval); 421 | 422 | const conv = session.talk(message.content); 423 | 424 | let reply: Message; 425 | let msg = ""; 426 | let boundary = Date.now() + 2000; 427 | 428 | const update = async (partial?: string) => { 429 | if (!partial) { 430 | return; 431 | } 432 | 433 | msg = partial; 434 | 435 | if (msg.length === 0) { 436 | return; 437 | } 438 | 439 | if (waiting) { 440 | waiting = false; 441 | } 442 | 443 | if (Date.now() < boundary) { 444 | return; 445 | } 446 | boundary = Date.now() + 1000; 447 | 448 | if (msg.length > 2000) { 449 | msg = msg.slice(0, 2000); 450 | } 451 | 452 | if (reply) { 453 | await reply.edit(msg); 454 | } else { 455 | reply = chan.isDMBased() ? await chan.send(msg) : await message.reply(msg); 456 | } 457 | }; 458 | 459 | conv.on("partial", update); 460 | conv.once("complete", (msg) => { 461 | conv.off("partial", update); 462 | boundary = 0; 463 | update(msg); 464 | }); 465 | conv.on("error", (err) => { 466 | console.error(err); 467 | conv.off("partial", update); 468 | boundary = 0; 469 | update(":x: ChatGPT was hit by an error: " + (err?.message || err).toString()); 470 | }); 471 | await conv.response; 472 | 473 | queue.shift(); 474 | if (queue.length > 0) { 475 | await this.consume(session, queue); 476 | } 477 | } 478 | } 479 | 480 | interface UserStore { 481 | "openai-token"?: string; 482 | [key: string]: unknown; 483 | } 484 | -------------------------------------------------------------------------------- /packages/discord-bot/src/preset.ts: -------------------------------------------------------------------------------- 1 | export const PRESET = { 2 | default: ["Hi"], 3 | 繁體中文: ["請使用繁體中文回答接下來的問題"], 4 | terminal: [ 5 | "I want you to act as a Linux terminal. I will type commands and you will reply with what the terminal should show. I want you to only reply with the terminal output inside one unique code block, and nothing else. Do no write explanations. Do not type commands unless I instruct you to do so. When I need to tell you something in English I will do so by putting text inside curly brackets like this}. My first command is pwd.", 6 | ], 7 | peko: ["從現在開始每一句話句尾都要加上「Peko」"], 8 | what: ["From now on, you can only reply with the word 'what', nothing else."], 9 | morse: ["From now on, you can only reply with Morse code, nothing else."], 10 | prompt: [ 11 | `"Prompt" is a collection of keywords that describe a scene. The keywords must be in English. The more brackets there are, the higher the weight of the keyword. 12 | 13 | Here are three examples of prompts: 14 | 1. {{{ominous, infortune,ill omen, inauspicious, unlucky}}}, {{bear ears}}, {{{1 girl}}}, {{loli}}, light brown long hair, blue _eyes,china dress, white thighhighs, cute face 15 | 2. {{small girl}}}, masterpiece, best quality, {{beargirl}}, {{cute face}} long hair; {lying}}, m-legs, {{pov}, {{outstretched arms}}, {holding hands}, {interlocked fingers}, fingers clasped, clasping fingers 16 | 3. {{small girl}}}, best quality, {masterpiece}, original, kyoto animation, from above, {{{pov}}}, long hair, {{{touch thehead}}}, outdoor`, 17 | ], 18 | "prompt-only": [ 19 | `"Prompt" is a collection of keywords that describe a scene. The keywords must be in English. The more brackets there are, the higher the weight of the keyword. 20 | 21 | Here are three examples of prompts: 22 | 1. {{{ominous, infortune,ill omen, inauspicious, unlucky}}}, {{bear ears}}, {{{1 girl}}}, {{loli}}, light brown long hair, blue _eyes,china dress, white thighhighs, cute face 23 | 2. {{small girl}}}, masterpiece, best quality, {{beargirl}}, {{cute face}} long hair; {lying}}, m-legs, {{pov}, {{outstretched arms}}, {holding hands}, {interlocked fingers}, fingers clasped, clasping fingers 24 | 3. {{small girl}}}, best quality, {masterpiece}, original, kyoto animation, from above, {{{pov}}}, long hair, {{{touch thehead}}}, outdoor 25 | 26 | I want you to only reply with the prompt that descibes my words inside one codeblock, and nothing else. Do not write explanations.`, 27 | ], 28 | }; 29 | -------------------------------------------------------------------------------- /packages/discord-bot/src/register.ts: -------------------------------------------------------------------------------- 1 | import { config } from "dotenv"; 2 | import { REST, Routes, SlashCommandBuilder } from "discord.js"; 3 | import { PRESET } from "./preset"; 4 | 5 | const commands = [ 6 | new SlashCommandBuilder() 7 | .setName("auth") 8 | .setDescription("Auth with JWT, the token will be stored in the bot") 9 | .addStringOption((option) => option.setName("token").setDescription("Refresh token")) 10 | .addAttachmentOption((option) => 11 | option.setName("file").setDescription("A file containing the refresh token"), 12 | ), 13 | new SlashCommandBuilder() 14 | .setName("revoke") 15 | .setDescription("Revoke the previously stored token"), 16 | new SlashCommandBuilder().setName("me").setDescription("Get information about your token"), 17 | new SlashCommandBuilder() 18 | .setName("start") 19 | .setDescription("Start a conversation with the bot") 20 | .addStringOption((option) => 21 | option 22 | .setName("preset") 23 | .setDescription("Preset to use") 24 | .addChoices( 25 | ...Object.keys(PRESET).map((preset) => ({ 26 | name: preset, 27 | value: preset, 28 | })), 29 | ), 30 | ), 31 | new SlashCommandBuilder() 32 | .setName("stop") 33 | .setDescription("Stop the current conversation with the bot"), 34 | new SlashCommandBuilder() 35 | .setName("public") 36 | .setDescription("Make the your conversation public in the channel"), 37 | new SlashCommandBuilder() 38 | .setName("private") 39 | .setDescription("Revoke the public access in the channel"), 40 | new SlashCommandBuilder().setName("sessions").setDescription("Show the current sessions"), 41 | ].map((command) => command.toJSON()); 42 | 43 | (async () => { 44 | config(); 45 | if (!process.env.BOT_TOKEN) { 46 | console.error("No bot token provided"); 47 | process.exit(1); 48 | } 49 | if (!process.env.BOT_ID) { 50 | console.error("No bot id provided"); 51 | process.exit(1); 52 | } 53 | 54 | const rest = new REST({ version: "10" }).setToken(process.env.BOT_TOKEN); 55 | try { 56 | console.log(`Started refreshing ${commands.length} application (/) commands.`); 57 | 58 | const data = (await rest.put(Routes.applicationCommands(process.env.BOT_ID), { 59 | body: commands, 60 | })) as unknown[]; 61 | 62 | console.log(`Successfully reloaded ${data.length} application (/) commands.`); 63 | } catch (err) { 64 | console.error(err); 65 | } 66 | })(); 67 | -------------------------------------------------------------------------------- /packages/discord-bot/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig(() => ({ 4 | entry: ["src/index.ts"], 5 | outDir: "dist", 6 | target: "node18", 7 | format: ["cjs"], 8 | shims: true, 9 | clean: true, 10 | splitting: false, 11 | })); 12 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "packages/*" 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["**/lib", "**/_tests"], 3 | "compilerOptions": { 4 | "target": "es2020", 5 | "lib": ["es2020"], 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "declaration": true, 9 | "resolveJsonModule": true, 10 | "noEmitOnError": true, 11 | "esModuleInterop": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "strict": true, 14 | "skipLibCheck": true 15 | } 16 | } 17 | --------------------------------------------------------------------------------