├── vercel.json ├── session-token.png ├── docs ├── .vitepress │ ├── theme │ │ └── index.ts │ └── config.ts ├── context.md ├── config.md └── index.md ├── crowdin.yml ├── .editorconfig ├── .gitignore ├── tsconfig.json ├── .github └── workflows │ ├── build.yml │ └── stale.yml ├── src ├── locales │ └── zh-CN.yml ├── utils.ts ├── index.ts ├── types.ts └── api.ts ├── LICENSE ├── readme.md └── package.json /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "github": { 3 | "silent": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /session-token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koishijs/chatgpt-bot/HEAD/session-token.png -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.ts: -------------------------------------------------------------------------------- 1 | import { defineTheme } from '@koishijs/vitepress/client' 2 | 3 | export default defineTheme({}) 4 | -------------------------------------------------------------------------------- /crowdin.yml: -------------------------------------------------------------------------------- 1 | pull_request_title: 'i18n: update translations' 2 | pull_request_labels: 3 | - i18n 4 | files: 5 | - source: /src/locales/zh-CN.yml 6 | translation: /src/locales/%locale%.yml 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | insert_final_newline = true 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib 2 | dist 3 | 4 | node_modules 5 | npm-debug.log 6 | yarn-debug.log 7 | yarn-error.log 8 | tsconfig.tsbuildinfo 9 | 10 | .eslintcache 11 | .DS_Store 12 | .idea 13 | .vscode 14 | *.suo 15 | *.ntvs* 16 | *.njsproj 17 | *.sln 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "src", 4 | "outDir": "lib", 5 | "target": "es2020", 6 | "module": "commonjs", 7 | "declaration": true, 8 | "composite": true, 9 | "incremental": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "moduleResolution": "node" 13 | }, 14 | "include": [ 15 | "src" 16 | ] 17 | } -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Check out 13 | uses: actions/checkout@v3 14 | - name: Setup Node 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: 16 18 | - name: Install 19 | run: yarn 20 | - name: Build 21 | run: yarn build 22 | -------------------------------------------------------------------------------- /src/locales/zh-CN.yml: -------------------------------------------------------------------------------- 1 | commands: 2 | chatgpt: 3 | description: AI 聊天 4 | 5 | options: 6 | reset: 重置对话 7 | 8 | messages: 9 | expect-prompt: 您想对我说什么呢? 10 | invalid-token: 会话令牌无效,请联系管理员。 11 | unknown-error: 发生未知错误。 12 | reset-success: 已重置对话。 13 | unauthorized: 请求被拒绝,可能由于会话令牌过期所致。请联系管理员。 14 | conversation-not-found: 未找到会话,请联系管理员或使用 chatgpt -r 重置会话。 15 | too-many-requests: 请求过于频繁。请稍后再试。 16 | service-unavailable: 当前服务不可用 ({0})。请稍后再试。 17 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Stale 2 | 3 | on: 4 | schedule: 5 | - cron: 30 7 * * * 6 | 7 | jobs: 8 | stale: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/stale@v4 12 | with: 13 | stale-issue-label: stale 14 | stale-issue-message: | 15 | This issue is stale because it has been open 15 days with no activity. 16 | Remove stale label or comment or this will be closed in 5 days. 17 | close-issue-message: | 18 | This issue was closed because it has been stalled for 5 days with no activity. 19 | days-before-issue-stale: 15 20 | days-before-issue-close: 5 21 | any-of-labels: awaiting feedback 22 | -------------------------------------------------------------------------------- /docs/context.md: -------------------------------------------------------------------------------- 1 | # 对话上下文 2 | 3 | ChatGPT 与其他 AI 机器人的一大区别在于,ChatGPT 可以记住较长时间内的对话历史。这使得 ChatGPT 能够在一次次的对话中学习到更多的概念,产生较为自然的回复。这被称为**对话上下文**。 4 | 5 | 对话上下文的存在使我们可以通过对话逐步构建出 ChatGPT 机器人的“人设”。例如,我们可以通过对话让 ChatGPT 机器人学会对某些人的称呼,学会对特定问题作出特定的回复,学会特别的用词和语气等等。 6 | 7 | 目前对话上下文是保存在**内存**里,每次重载插件或重启 Koishi 时都会丢失。 8 | 9 | ## 上下文共享方式 10 | 11 | 我们可以通过 [`interaction`](./config.md#interaction) 配置项来控制 ChatGPT 机器人的对话上下文共享方式。这个配置项目前有三种可选值: 12 | 13 | - `user`:用户上下文 14 | ChatGPT 机器人会对每一位用户独立维护一个对话上下文。不管通过私聊还是群聊与 ChatGPT 机器人对话,都会使用同一个对话上下文。这可以使每位用户打造属于自己的个人助理。 15 | 16 | - `channel`:频道上下文 17 | ChatGPT 机器人会在每个频道中独立维护一个对话上下文。在同一个频道里,不管哪位用户与 ChatGPT 机器人对话,都会使用同一个对话上下文。这有助于我们在同一个频道内维护一个“人设”。 18 | 19 | - `both`:频道内用户上下文 20 | ChatGPT 机器人会对每一个频道中的每一位用户独立维护一个对话上下文。在此配置下,每位用户在不同的频道内都会有不同的对话上下文。 21 | 22 | ## 重置对话线程 23 | 24 | 当我们想要放弃当前的对话上下文,重新开始一个新的对话时,可以通过发送 `chatgpt -r` 指令来重置对话线程。这个指令只对当前的对话上下文有效,不会影响其他对话上下文。 25 | -------------------------------------------------------------------------------- /docs/config.md: -------------------------------------------------------------------------------- 1 | # 配置项 2 | 3 | ## 登录设置 4 | 5 | ### sessionToken 6 | 7 | - 类型:`string` 8 | - 必选项 9 | 10 | ChatGPT 会话令牌。 11 | 12 | ### cloudflareToken 13 | 14 | - 类型:`string` 15 | - 必选项 16 | 17 | Cloudflare 令牌。 18 | 19 | ### endpoint 20 | 21 | - 类型:`string` 22 | - 默认值:`https://chat.openai.com/` 23 | 24 | ChatGPT API 的地址。 25 | 26 | ### headers 27 | 28 | - 类型:`Dict` 29 | 30 | 要附加的额外请求头。 31 | 32 | ### proxyAgent 33 | 34 | - 类型:`string` 35 | 36 | 使用的代理服务器地址。 37 | 38 | ### markdown 39 | 40 | - 类型:`boolean` 41 | - 默认值:`false` 42 | 43 | 是否以 Markdown 格式回复。 44 | 45 | ## 基础设置 46 | 47 | ### appellation 48 | 49 | - 类型:`boolean` 50 | - 默认值:`true` 51 | 52 | 是否自动回复带称呼的消息。 53 | 54 | ### prefix 55 | 56 | - 类型:`string[]` 57 | - 默认值:`['!', '!']` 58 | 59 | 消息前缀。带此前缀的消息将被回复。 60 | 61 | ### interaction 62 | 63 | - 类型:`'user' | 'channel' | 'both'` 64 | - 默认值:`'channel'` 65 | 66 | 上下文共享方式。详情请参考[对话上下文](./context.md)。 67 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # 介绍 2 | 3 | 基于 ChatGPT 的 AI 对话插件。 4 | 5 | ## 搭建教程 6 | 7 | 1. 通过 注册并登录。 8 | 2. 打开浏览器开发者工具,切换到 Application 标签页。 9 | 3. 在左侧的 Storage - Cookies 中找到 `__Secure-next-auth.session-token` 一行并复制其值。 10 | 4. 将值填写到 `sessionToken` 字段。 11 | 5. 在左侧的 Storage - Cookies 中找到 `cf_clearance` 一行并复制其值。 12 | 6. 将值填写到 `cloudflareToken` 字段并启用插件。 13 | 14 | ::: warning 15 | 如果你使用了代理,请确保你的浏览器和 `chatgpt` 插件**连接到同一个代理服务器**! 16 | 17 | 如果你使用代理后仍然提示 `会话令牌无效,请联系管理员。` 你可以**关闭代理并刷新页面**重新获取 `cf_clearance` 18 | ::: 19 | 20 | ## 使用方法 21 | 22 | 此插件有三种使用方法: 23 | 24 | 1. 调用指令 `chatgpt` 进行交互 25 | 2. 任何带有称呼的消息都会被回复,称呼全局的 `nickname` 配置项设置 26 | 3. 任何带有前缀的消息都会被回复,前缀默认为 `!`,可通过插件的 `prefix` 配置项设置 27 | 28 | 29 | !你是谁 30 | 我是 Assistant,一个由 OpenAI 训练的大型语言模型。我可以回答您提出的各种问题,并尽力为您提供有用的信息。如果您有任何问题,请随时告诉我,我将竭诚为您服务。 31 | 32 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { segment } from 'koishi' 2 | import { marked } from 'marked' 3 | 4 | declare module 'marked' { 5 | namespace Tokens { 6 | interface Def { 7 | type: 'def' 8 | } 9 | 10 | interface Paragraph { 11 | tokens: marked.Token[] 12 | } 13 | } 14 | } 15 | 16 | function renderToken(token: marked.Token) { 17 | if (token.type === 'code') { 18 | return token.text + '\n' 19 | } else if (token.type === 'paragraph') { 20 | return render(token.tokens) 21 | } else if (token.type === 'image') { 22 | return segment.image(token.href) 23 | } else if (token.type === 'blockquote') { 24 | return token.text 25 | } 26 | return token.raw 27 | } 28 | 29 | function render(tokens: marked.Token[]) { 30 | return tokens.map(renderToken).join('') 31 | } 32 | 33 | export function transform(source: string) { 34 | if (!source) return '' 35 | source = source.replace(/^$/gm, '') 36 | return render(marked.lexer(source)).trim().replace(/\n\s*\n/g, '\n') 37 | } 38 | -------------------------------------------------------------------------------- /docs/.vitepress/config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from '@koishijs/vitepress' 2 | 3 | export default defineConfig({ 4 | lang: 'zh-CN', 5 | title: 'ChatGPT Bot', 6 | description: '基于 ChatGPT 的 AI 对话机器人', 7 | 8 | head: [ 9 | ['link', { rel: 'icon', href: 'https://koishi.chat/logo.png' }], 10 | ['link', { rel: 'manifest', href: 'https://koishi.chat/manifest.json' }], 11 | ['meta', { name: 'theme-color', content: '#5546a3' }], 12 | ], 13 | 14 | themeConfig: { 15 | sidebar: [{ 16 | text: '指南', 17 | items: [ 18 | { text: '介绍', link: '/' }, 19 | { text: '配置项', link: '/config' }, 20 | { text: '对话上下文', link: '/context' }, 21 | ], 22 | }, { 23 | text: '更多', 24 | items: [ 25 | { text: 'Koishi 官网', link: 'https://koishi.chat' }, 26 | { text: '支持作者', link: 'https://afdian.net/a/shigma' }, 27 | ], 28 | }], 29 | 30 | socialLinks: { 31 | github: 'https://github.com/koishijs/chatgpt-bot', 32 | }, 33 | 34 | editLink: { 35 | pattern: 'https://github.com/koishijs/chatgpt-bot/edit/main/docs/:path', 36 | }, 37 | }, 38 | }) 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-present Maiko Tan 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 | # [koishi-plugin-chatgpt](https://chatgpt.koishi.chat) 2 | 3 | [![downloads](https://img.shields.io/npm/dm/koishi-plugin-chatgpt?style=flat-square)](https://www.npmjs.com/package/koishi-plugin-chatgpt) 4 | [![npm](https://img.shields.io/npm/v/koishi-plugin-chatgpt?style=flat-square)](https://www.npmjs.com/package/koishi-plugin-chatgpt) 5 | 6 | 基于 ChatGPT 的 AI 对话插件。 7 | 8 | ## 使用教程 9 | 10 | 搭建教程、使用方法、参数配置、常见问题请见: 11 | 12 | ## License 13 | 14 | 使用 [MIT](./LICENSE) 许可证发布。 15 | 16 | ``` 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 19 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 20 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 21 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 22 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | ``` 24 | 25 | [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fkoishijs%2Fnovelai-bot.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Fkoishijs%2Fnovelai-bot?ref=badge_large) 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "koishi-plugin-chatgpt", 3 | "description": "AI conversation based on ChatGPT", 4 | "version": "1.4.0", 5 | "main": "lib/index.js", 6 | "typings": "lib/index.d.ts", 7 | "files": [ 8 | "lib", 9 | "dist" 10 | ], 11 | "author": { 12 | "name": "Maiko Tan", 13 | "email": "maiko.tan.coding@gmail.com" 14 | }, 15 | "contributors": [ 16 | { 17 | "name": "Maiko Tan", 18 | "email": "maiko.tan.coding@gmail.com" 19 | }, 20 | "Seidko ", 21 | "Shigma " 22 | ], 23 | "license": "MIT", 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/koishijs/chatgpt-bot.git" 27 | }, 28 | "bugs": { 29 | "url": "https://github.com/koishijs/chatgpt-bot/issues" 30 | }, 31 | "homepage": "https://chatgpt.koishi.chat", 32 | "scripts": { 33 | "build": "atsc -b", 34 | "docs:dev": "vitepress dev docs --open", 35 | "docs:build": "vitepress build docs", 36 | "docs:serve": "vitepress serve docs" 37 | }, 38 | "koishi": { 39 | "description": { 40 | "en": "AI conversation based on ChatGPT", 41 | "zh": "基于 ChatGPT 的 AI 对话机器人" 42 | } 43 | }, 44 | "keywords": [ 45 | "chatbot", 46 | "koishi", 47 | "plugin", 48 | "ai", 49 | "chatgpt", 50 | "openai", 51 | "gpt", 52 | "conversation", 53 | "dialogue", 54 | "chat" 55 | ], 56 | "peerDependencies": { 57 | "koishi": "^4.12.0" 58 | }, 59 | "devDependencies": { 60 | "@koishijs/vitepress": "^1.6.5", 61 | "@types/marked": "^4.0.8", 62 | "@types/node": "^17.0.45", 63 | "atsc": "^1.2.2", 64 | "koishi": "^4.12.0", 65 | "sass": "^1.58.3", 66 | "typescript": "^4.9.5", 67 | "vitepress": "1.0.0-alpha.34" 68 | }, 69 | "dependencies": { 70 | "eventsource-parser": "^0.0.5", 71 | "expiry-map": "^2.0.0", 72 | "marked": "^4.2.12", 73 | "uuid": "^9.0.0" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import ChatGPT from './api' 2 | import { Context, Logger, Schema, Session, SessionError } from 'koishi' 3 | 4 | const logger = new Logger('chatgpt') 5 | 6 | const interaction = ['user', 'channel', 'both'] as const 7 | export type Interaction = typeof interaction[number] 8 | 9 | export interface Config extends ChatGPT.Config { 10 | appellation: boolean 11 | prefix: string[] 12 | /** 13 | * Configure how to share the conversation context between users: 14 | * 15 | * - `user`: every user has its own context, across all channels 16 | * - `channel`: every channel has its own context, no matter which user is talking 17 | * - `both`: every user has its own context in each channel 18 | * 19 | * @see https://github.com/koishijs/chatgpt-bot/pull/15 20 | */ 21 | interaction: Interaction 22 | } 23 | 24 | export const Config: Schema = Schema.intersect([ 25 | ChatGPT.Config, 26 | Schema.object({ 27 | appellation: Schema.boolean().description('是否使用称呼触发对话。').default(true), 28 | prefix: Schema.union([ 29 | Schema.array(String), 30 | Schema.transform(String, (prefix) => [prefix]), 31 | ] as const).description('使用特定前缀触发对话。').default(['!', '!']), 32 | interaction: Schema.union([ 33 | Schema.const('user' as const).description('用户独立'), 34 | Schema.const('channel' as const).description('频道独立'), 35 | Schema.const('both' as const).description('频道内用户独立'), 36 | ]).description('上下文共享方式。').default('channel'), 37 | }), 38 | ]) 39 | 40 | const conversations = new Map() 41 | 42 | export function apply(ctx: Context, config: Config) { 43 | ctx.i18n.define('zh', require('./locales/zh-CN')) 44 | 45 | const api = new ChatGPT(ctx, config) 46 | 47 | const getContextKey = (session: Session, config: Config) => { 48 | switch (config.interaction) { 49 | case 'user': 50 | return session.uid 51 | case 'channel': 52 | return session.cid 53 | case 'both': 54 | const { platform, channelId, userId } = session 55 | return `${platform}:${channelId}:${userId}` 56 | } 57 | } 58 | 59 | ctx.middleware(async (session, next) => { 60 | if (session.parsed?.appel && config.appellation) { 61 | return session.execute('chatgpt ' + session.parsed.content) 62 | } 63 | for (const prefix of config.prefix) { 64 | if (!prefix || !session.content.startsWith(prefix)) continue 65 | return session.execute('chatgpt ' + session.content.slice(prefix.length)) 66 | } 67 | return next() 68 | }) 69 | 70 | ctx.command('chatgpt ') 71 | .option('reset', '-r') 72 | .action(async ({ options, session }, input) => { 73 | const key = getContextKey(session, config) 74 | 75 | if (options?.reset) { 76 | conversations.delete(key) 77 | return session.text('.reset-success') 78 | } 79 | 80 | input = input?.trim() 81 | if (!input) { 82 | await session.send(session.text('.expect-prompt')) 83 | input = await session.prompt() 84 | } 85 | 86 | try { 87 | // ensure the API is properly authenticated (optional) 88 | await api.ensureAuth() 89 | } catch (error) { 90 | logger.warn(error) 91 | return session.text('.invalid-token') 92 | } 93 | 94 | try { 95 | // send a message and wait for the response 96 | const { conversationId, messageId } = conversations.get(key) ?? {} 97 | const response = await api.sendMessage({ message: input, conversationId, messageId }) 98 | conversations.set(key, { conversationId: response.conversationId, messageId: response.messageId }) 99 | return response.message 100 | } catch (error) { 101 | logger.warn(error) 102 | if (error instanceof SessionError) throw error 103 | throw new SessionError('commands.chatgpt.messages.unknown-error') 104 | } 105 | }) 106 | } 107 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type ContentType = 'text' 2 | 3 | export type Role = 'user' | 'assistant' 4 | 5 | /** 6 | * https://chat.openapi.com/api/auth/session 7 | */ 8 | export interface SessionResult { 9 | /** Object of the current user */ 10 | user: User 11 | /** ISO date of the expiration date of the access token */ 12 | expires: string 13 | /** The access token */ 14 | accessToken: string 15 | } 16 | 17 | export interface User { 18 | /** ID of the user */ 19 | id: string 20 | /** Name of the user */ 21 | name: string 22 | /** Email of the user */ 23 | email: string 24 | /** Image of the user */ 25 | image: string 26 | /** Picture of the user */ 27 | picture: string 28 | /** Groups the user is in */ 29 | groups: string[] | [] 30 | /** Features the user is in */ 31 | features: string[] | [] 32 | } 33 | 34 | /** 35 | * https://chat.openapi.com/backend-api/models 36 | */ 37 | export interface ModelsResult { 38 | /** Array of models */ 39 | models: Model[] 40 | } 41 | 42 | export interface Model { 43 | /** Name of the model */ 44 | slug: string 45 | /** Max tokens of the model */ 46 | max_tokens: number 47 | /** Whether or not the model is special */ 48 | is_special: boolean 49 | } 50 | 51 | /** 52 | * https://chat.openapi.com/backend-api/moderations 53 | */ 54 | export interface ModerationsJSONBody { 55 | /** Input for the moderation decision */ 56 | input: string 57 | /** The model to use in the decision */ 58 | model: AvailableModerationModels 59 | } 60 | 61 | export type AvailableModerationModels = 'text-moderation-playground' 62 | 63 | /** 64 | * https://chat.openapi.com/backend-api/moderations 65 | */ 66 | export interface ModerationsJSONResult { 67 | /** Whether or not the input is flagged */ 68 | flagged: boolean 69 | /** Whether or not the input is blocked */ 70 | blocked: boolean 71 | /** The ID of the decision */ 72 | moderation_id: string 73 | } 74 | 75 | /** 76 | * https://chat.openapi.com/backend-api/conversation 77 | */ 78 | export interface ConversationJSONBody { 79 | /** The action to take */ 80 | action: string 81 | /** The ID of the conversation */ 82 | conversation_id?: string 83 | /** Prompts to provide */ 84 | messages: Prompt[] 85 | /** The model to use */ 86 | model: string 87 | /** The parent message ID */ 88 | parent_message_id: string 89 | } 90 | 91 | export interface Prompt { 92 | /** The content of the prompt */ 93 | content: PromptContent 94 | /** The ID of the prompt */ 95 | id: string 96 | /** The role played in the prompt */ 97 | role: Role 98 | } 99 | 100 | export interface PromptContent { 101 | /** The content type of the prompt */ 102 | content_type: ContentType 103 | /** The parts to the prompt */ 104 | parts: string[] 105 | } 106 | 107 | /** 108 | * https://chat.openapi.com/backend-api/conversation/message_feedback 109 | */ 110 | export interface MessageFeedbackJSONBody { 111 | /** The ID of the conversation */ 112 | conversation_id: string 113 | /** The message ID */ 114 | message_id: string 115 | /** The rating */ 116 | rating: MessageFeedbackRating 117 | /** Tags to give the rating */ 118 | tags?: MessageFeedbackTags[] 119 | /** The text to include */ 120 | text?: string 121 | } 122 | 123 | export type MessageFeedbackTags = 'harmful' | 'false' | 'not-helpful' 124 | 125 | export interface MessageFeedbackResult { 126 | /** The message ID */ 127 | message_id: string 128 | /** The ID of the conversation */ 129 | conversation_id: string 130 | /** The ID of the user */ 131 | user_id: string 132 | /** The rating */ 133 | rating: MessageFeedbackRating 134 | /** The text the server received, including tags */ 135 | text?: string 136 | } 137 | 138 | export type MessageFeedbackRating = 'thumbsUp' | 'thumbsDown' 139 | 140 | export interface ConversationResponseEvent { 141 | message?: Message 142 | conversation_id?: string 143 | error?: string | null 144 | } 145 | 146 | export interface Message { 147 | id: string 148 | content: MessageContent 149 | role: string 150 | user: string | null 151 | create_time: string | null 152 | update_time: string | null 153 | end_turn: null 154 | weight: number 155 | recipient: string 156 | metadata: MessageMetadata 157 | } 158 | 159 | export interface MessageContent { 160 | content_type: string 161 | parts: string[] 162 | } 163 | 164 | export type MessageMetadata = any 165 | -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | import ExpiryMap from 'expiry-map' 2 | import { createParser } from 'eventsource-parser' 3 | import { Context, Dict, Quester, Schema, SessionError, trimSlash } from 'koishi' 4 | import internal, { Writable } from 'stream' 5 | import { v4 as uuidv4 } from 'uuid' 6 | 7 | import * as types from './types' 8 | import { transform } from './utils' 9 | 10 | const KEY_ACCESS_TOKEN = 'accessToken' 11 | 12 | export interface Conversation { 13 | conversationId?: string 14 | messageId?: string 15 | message: string 16 | } 17 | 18 | class ChatGPT { 19 | protected http: Quester 20 | // stores access tokens for up to 10 seconds before needing to refresh 21 | protected _accessTokenCache = new ExpiryMap(10 * 1000) 22 | 23 | constructor(ctx: Context, public config: ChatGPT.Config) { 24 | this.http = ctx.http.extend(config) 25 | } 26 | 27 | async getIsAuthenticated() { 28 | try { 29 | await this.refreshAccessToken() 30 | return true 31 | } catch (err) { 32 | return false 33 | } 34 | } 35 | 36 | async ensureAuth() { 37 | return await this.refreshAccessToken() 38 | } 39 | 40 | /** 41 | * Sends a message to ChatGPT, waits for the response to resolve, and returns 42 | * the response. 43 | * 44 | * @param message - The plaintext message to send. 45 | * @param opts.conversationId - Optional ID of the previous message in a conversation 46 | */ 47 | async sendMessage(conversation: Conversation): Promise> { 48 | const { conversationId, messageId = uuidv4(), message } = conversation 49 | 50 | const accessToken = await this.refreshAccessToken() 51 | 52 | const body: types.ConversationJSONBody = { 53 | action: 'next', 54 | conversation_id: conversationId, 55 | messages: [ 56 | { 57 | id: uuidv4(), 58 | role: 'user', 59 | content: { 60 | content_type: 'text', 61 | parts: [message], 62 | }, 63 | }, 64 | ], 65 | model: 'text-davinci-002-render', 66 | parent_message_id: messageId, 67 | } 68 | 69 | let data: internal.Readable 70 | try { 71 | const resp = await this.http.axios('/backend-api/conversation', { 72 | method: 'POST', 73 | responseType: 'stream', 74 | data: body, 75 | headers: { 76 | Authorization: `Bearer ${accessToken}`, 77 | cookie: `cf_clearance=${this.config.cloudflareToken};__Secure-next-auth.session-token=${this.config.sessionToken}`, 78 | referer: 'https://chat.openai.com/chat', 79 | authority: 'chat.openai.com', 80 | }, 81 | }) 82 | 83 | data = resp.data 84 | } catch (err) { 85 | if (Quester.isAxiosError(err)) { 86 | switch (err.response?.status) { 87 | case 401: 88 | throw new SessionError('commands.chatgpt.messages.unauthorized') 89 | case 404: 90 | throw new SessionError('commands.chatgpt.messages.conversation-not-found') 91 | case 429: 92 | throw new SessionError('commands.chatgpt.messages.too-many-requests') 93 | case 500: 94 | case 503: 95 | throw new SessionError('commands.chatgpt.messages.service-unavailable', [err.response.status]) 96 | default: 97 | throw err 98 | } 99 | } 100 | } 101 | 102 | let response = '' 103 | return await new Promise>((resolve, reject) => { 104 | let messageId: string 105 | let conversationId: string 106 | const parser = createParser((event) => { 107 | if (event.type === 'event') { 108 | const { data } = event 109 | if (data === '[DONE]') { 110 | return resolve({ message: response, messageId, conversationId }) 111 | } 112 | try { 113 | const parsedData: types.ConversationResponseEvent = JSON.parse(data) 114 | const message = parsedData.message 115 | conversationId = parsedData.conversation_id 116 | 117 | if (message) { 118 | messageId = message?.id 119 | let text = message?.content?.parts?.[0] 120 | 121 | if (text) { 122 | if (!this.config.markdown) { 123 | text = transform(text) 124 | } 125 | 126 | response = text 127 | } 128 | } 129 | } catch (err) { 130 | reject(err) 131 | } 132 | } 133 | }) 134 | data.pipe(new Writable({ 135 | write(chunk: string | Buffer, _encoding, cb) { 136 | parser.feed(chunk.toString()) 137 | cb() 138 | }, 139 | })) 140 | }) 141 | } 142 | 143 | async refreshAccessToken(): Promise { 144 | const cachedAccessToken = this._accessTokenCache.get(KEY_ACCESS_TOKEN) 145 | if (cachedAccessToken) { 146 | return cachedAccessToken 147 | } 148 | 149 | try { 150 | const res = await this.http.get('/api/auth/session', { 151 | headers: { 152 | cookie: `cf_clearance=${this.config.cloudflareToken};__Secure-next-auth.session-token=${this.config.sessionToken}`, 153 | referer: 'https://chat.openai.com/chat', 154 | authority: 'chat.openai.com', 155 | }, 156 | }) 157 | 158 | const accessToken = res?.accessToken 159 | 160 | if (!accessToken) { 161 | console.warn('no auth token', res) 162 | throw new Error('Unauthorized') 163 | } 164 | 165 | this._accessTokenCache.set(KEY_ACCESS_TOKEN, accessToken) 166 | return accessToken 167 | } catch (err: any) { 168 | throw new Error(`ChatGPT failed to refresh auth token: ${err.toString()}`) 169 | } 170 | } 171 | } 172 | 173 | namespace ChatGPT { 174 | export interface Config { 175 | sessionToken: string 176 | cloudflareToken: string 177 | endpoint: string 178 | markdown?: boolean 179 | headers?: Dict 180 | proxyAgent?: string 181 | } 182 | 183 | export const Config: Schema = Schema.object({ 184 | sessionToken: Schema.string().role('secret').description('ChatGPT 会话令牌。').required(), 185 | cloudflareToken: Schema.string().role('secret').description('Cloudflare 令牌。').required(), 186 | endpoint: Schema.string().description('ChatGPT API 的地址。').default('https://chat.openai.com'), 187 | headers: Schema.dict(String).description('要附加的额外请求头。').default({ 188 | 'User-Agent': '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', 189 | }), 190 | proxyAgent: Schema.string().role('link').description('使用的代理服务器地址。'), 191 | markdown: Schema.boolean().hidden().default(false), 192 | }).description('登录设置') 193 | } 194 | 195 | export default ChatGPT 196 | --------------------------------------------------------------------------------