├── images ├── 0dd10adfff33b2a3b284462731ca4ffea8c73280782d82555babde363bec86aa.png ├── 2b12775ec3def000a1cb89cedbc49395cc4fe5e1b702125959d3de8937557a27.png ├── 37604bf9e2e3a6c2d9bfb7295cc934f6fb2db4fae61422ac79096ef7f76f041f.png ├── 44ca1b937d365fe2923b7ee38a400e6d5ebf31f2b60b5fb1191188cd69e24f76.png ├── 4e2d2ebbbfc956ef79e436d5d4ef91a6c8a204fad1bed0d545e534694270bb82.png ├── 664662d5922f9a502a5bf017b3724fa1a72293910998634f94fd3fcc6673f68a.png ├── 832c74662b60bb56826763616c98ef4478f270089ccad8a9577fe010af33372f.png ├── 9533ee53ff63c1c43f34d8919d3fac7496057e0af03bd41b0040c6685c05d201.png ├── 9da3517fffb88a4d8d8ff7add6cb602c9dcff054c191bb9754353bd18819e86b.png ├── ae1d98dfcb6fe4a3eaf7ffa397a9797b20b020476d97b1bdd6c76bf730c358a2.png ├── d32f488da1869b30a2020687e8307a86d102b832dea3b7334ce94f57f5d7b692.png ├── eb9c6c0921d9114542c21b88e58f75474af88b82c062d74c5a52bbe12c55c298.png ├── edbcec227ca630733bcf444593e4d191247ece4f56ae41553976a044fe5e0d75.png └── fe095476d8e22c550c99c45af5b343aebdcb008afb2faeea4187339ab3c0b01f.png ├── LICENSE ├── README.md ├── event.js └── app.js /images/0dd10adfff33b2a3b284462731ca4ffea8c73280782d82555babde363bec86aa.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lelehub/OpenAI-Proxy-Api/HEAD/images/0dd10adfff33b2a3b284462731ca4ffea8c73280782d82555babde363bec86aa.png -------------------------------------------------------------------------------- /images/2b12775ec3def000a1cb89cedbc49395cc4fe5e1b702125959d3de8937557a27.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lelehub/OpenAI-Proxy-Api/HEAD/images/2b12775ec3def000a1cb89cedbc49395cc4fe5e1b702125959d3de8937557a27.png -------------------------------------------------------------------------------- /images/37604bf9e2e3a6c2d9bfb7295cc934f6fb2db4fae61422ac79096ef7f76f041f.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lelehub/OpenAI-Proxy-Api/HEAD/images/37604bf9e2e3a6c2d9bfb7295cc934f6fb2db4fae61422ac79096ef7f76f041f.png -------------------------------------------------------------------------------- /images/44ca1b937d365fe2923b7ee38a400e6d5ebf31f2b60b5fb1191188cd69e24f76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lelehub/OpenAI-Proxy-Api/HEAD/images/44ca1b937d365fe2923b7ee38a400e6d5ebf31f2b60b5fb1191188cd69e24f76.png -------------------------------------------------------------------------------- /images/4e2d2ebbbfc956ef79e436d5d4ef91a6c8a204fad1bed0d545e534694270bb82.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lelehub/OpenAI-Proxy-Api/HEAD/images/4e2d2ebbbfc956ef79e436d5d4ef91a6c8a204fad1bed0d545e534694270bb82.png -------------------------------------------------------------------------------- /images/664662d5922f9a502a5bf017b3724fa1a72293910998634f94fd3fcc6673f68a.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lelehub/OpenAI-Proxy-Api/HEAD/images/664662d5922f9a502a5bf017b3724fa1a72293910998634f94fd3fcc6673f68a.png -------------------------------------------------------------------------------- /images/832c74662b60bb56826763616c98ef4478f270089ccad8a9577fe010af33372f.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lelehub/OpenAI-Proxy-Api/HEAD/images/832c74662b60bb56826763616c98ef4478f270089ccad8a9577fe010af33372f.png -------------------------------------------------------------------------------- /images/9533ee53ff63c1c43f34d8919d3fac7496057e0af03bd41b0040c6685c05d201.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lelehub/OpenAI-Proxy-Api/HEAD/images/9533ee53ff63c1c43f34d8919d3fac7496057e0af03bd41b0040c6685c05d201.png -------------------------------------------------------------------------------- /images/9da3517fffb88a4d8d8ff7add6cb602c9dcff054c191bb9754353bd18819e86b.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lelehub/OpenAI-Proxy-Api/HEAD/images/9da3517fffb88a4d8d8ff7add6cb602c9dcff054c191bb9754353bd18819e86b.png -------------------------------------------------------------------------------- /images/ae1d98dfcb6fe4a3eaf7ffa397a9797b20b020476d97b1bdd6c76bf730c358a2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lelehub/OpenAI-Proxy-Api/HEAD/images/ae1d98dfcb6fe4a3eaf7ffa397a9797b20b020476d97b1bdd6c76bf730c358a2.png -------------------------------------------------------------------------------- /images/d32f488da1869b30a2020687e8307a86d102b832dea3b7334ce94f57f5d7b692.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lelehub/OpenAI-Proxy-Api/HEAD/images/d32f488da1869b30a2020687e8307a86d102b832dea3b7334ce94f57f5d7b692.png -------------------------------------------------------------------------------- /images/eb9c6c0921d9114542c21b88e58f75474af88b82c062d74c5a52bbe12c55c298.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lelehub/OpenAI-Proxy-Api/HEAD/images/eb9c6c0921d9114542c21b88e58f75474af88b82c062d74c5a52bbe12c55c298.png -------------------------------------------------------------------------------- /images/edbcec227ca630733bcf444593e4d191247ece4f56ae41553976a044fe5e0d75.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lelehub/OpenAI-Proxy-Api/HEAD/images/edbcec227ca630733bcf444593e4d191247ece4f56ae41553976a044fe5e0d75.png -------------------------------------------------------------------------------- /images/fe095476d8e22c550c99c45af5b343aebdcb008afb2faeea4187339ab3c0b01f.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lelehub/OpenAI-Proxy-Api/HEAD/images/fe095476d8e22c550c99c45af5b343aebdcb008afb2faeea4187339ab3c0b01f.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 JiaLe 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 | # OpenAI-Proxy-Api 2 | 3 | ## API Proxy 4 | 5 | > ⚠️ 由于腾讯云自身规则,虽然代码本身支持SSE,但部署为云函数后可能无法正常工作 6 | ① 进入[云函数创建面板](https://console.cloud.tencent.com/scf/list-create?rid=5&ns=default&createType=empty),选择日本/新加坡/美国(建议不要使用~~中国香港~~)、web函数、NodeJS 16。 7 | ![图 1](images/37604bf9e2e3a6c2d9bfb7295cc934f6fb2db4fae61422ac79096ef7f76f041f.png) 8 | 9 | ② 在函数代码处点击`event.js`将本项目 [event.js](/event.js) 的代码粘贴进去。 10 | ![图 2](images/edbcec227ca630733bcf444593e4d191247ece4f56ae41553976a044fe5e0d75.png) 11 | 12 | 其他不用改,点创建。 13 | 14 | ③ 创建完成后,点击「函数管理」→「函数代码」。等编辑器把函数代码加载完成后 CloudStudio → 终端 → 新终端,打开一个新终端。 15 | ![图 3](images/44ca1b937d365fe2923b7ee38a400e6d5ebf31f2b60b5fb1191188cd69e24f76.png) 16 | 17 | ④ 在出现的终端中粘贴以下代码 18 | 19 | ```bash 20 | cd src && yarn add body-parser@1.20.2 cross-fetch@3.1.5 eventsource-parser@0.1.0 express@4.18.2 multer@1.4.5-lts.1 tencentcloud-sdk-nodejs@4.0.567 cors@2.8.5 21 | ``` 22 | ![图 4](images/4e2d2ebbbfc956ef79e436d5d4ef91a6c8a204fad1bed0d545e534694270bb82.png) 23 | 24 | ⑤ 点编辑器右上角的「部署」,等待部署完成。 25 | ![图 5](images/eb9c6c0921d9114542c21b88e58f75474af88b82c062d74c5a52bbe12c55c298.png) 26 | 27 | ⑥ 下拉或者进入「触发管理」可以看到云函数的访问地址。 28 | ![图 7](images/d32f488da1869b30a2020687e8307a86d102b832dea3b7334ce94f57f5d7b692.png) 29 | 30 | ⑦ 调整函数执行超时时间,默认的3s会经常超时,建议调整为30s;同时添加环境变量 `TIMEOUT`(单位为毫秒,如30000) 31 | ![图 8](images/2b12775ec3def000a1cb89cedbc49395cc4fe5e1b702125959d3de8937557a27.png) 32 | ⑧ 如果你想绑定自己的域名,需要在「触发管理」中开启「标准API网关」,按腾讯云教程进行配置。 33 | ## Proxy的使用 34 | 35 | 使用时将 `https://api.openai.com/` 替换为该路径即可,如 `https://api.openai.com/v1/chat/completions` 替换为 `https://xxxxx.apigw.tencentcs.com/release/v1/chat/completions` 36 | 37 | ## Feishu-ChatGPT 38 | ### 1. 创建一个飞书开放平台应用,并获取到 APPID 和 Secret 39 | 40 | 访问 [开发者后台](https://open.feishu.cn/app?lang=zh-CN),创建一个名为 **ChatGPT** 的应用,并上传应用头像。创建完成后,访问【凭证与基础信息】页面,复制 APPID 和 Secret 备用。 41 | ![图 9](images/667159c3535567448fef2b911affe8dc67b7cbcfb8785149778cbae0891fe11b.png) 42 | 43 | ### 2. 开启机器人能力 44 | 45 | 打开应用的机器人应用功能 46 | 47 | 48 | ![图 17](images/0dd10adfff33b2a3b284462731ca4ffea8c73280782d82555babde363bec86aa.png) 49 | 50 | 51 | ### 3. 访问 [AirCode](https://aircode.io/dashboard) ,创建一个新的项目 52 | 53 | 登录 [AirCode](https://aircode.io/dashboard) ,创建一个新的 Node.js v16 的项目,项目名可以根据你的需要填写,可以填写 ChatGPT 54 | 55 | ![图 11](images/664662d5922f9a502a5bf017b3724fa1a72293910998634f94fd3fcc6673f68a.png) 56 | 57 | ### 4. 复制本项目下的 event.js 的源码内容,并粘贴到 Aircode 当中 58 | 59 | 访问[app.js](/app.js),复制代码 60 | 61 | 62 | ![图 19](images/9da3517fffb88a4d8d8ff7add6cb602c9dcff054c191bb9754353bd18819e86b.png) 63 | 64 | 65 | 66 | 67 | 并把代码粘贴到 AIrcode 默认创建的 hello.js 。然后点击顶部的 deploy ,完成第一次部署。 68 | 69 | ![图 13](images/9533ee53ff63c1c43f34d8919d3fac7496057e0af03bd41b0040c6685c05d201.png) 70 | 71 | 72 | 部署成功后,可以在下方看到。 73 | 74 | ### 5. 安装所需依赖 75 | 76 | 这个开发过程中,我们使用了飞书开放平台官方提供的 SDK,以及 axios 来完成调用。点击页面左下角的包管理器,安装 `axios` 和 `@larksuiteoapi/node-sdk`。安装完成后,点击上方的部署,使其生效。 77 | 78 | ![图 14](images/fe095476d8e22c550c99c45af5b343aebdcb008afb2faeea4187339ab3c0b01f.png) 79 | 80 | 81 | 82 | ### 6. 配置环境变量 83 | 84 | 接下来我们来配置环境变量,你需要配置三个环境变量 `APPID` 、`SECRET` 和 `BOTNAME`,APPID 填写你刚刚在飞书开放平台获取的 APPID,SECRET 填写你在飞书开放平台获取到的 SECRET,BOTNAME 填写你的机器人的名字。 85 | 86 | > 配置环境变量可能会失败,可以多 deploy 几次,确保配置成功。 87 | 88 | 配置完成后,点击上方的 **Deploy** 按钮部署,使这些环境变量生效。 89 | 90 | ![图 15](images/832c74662b60bb56826763616c98ef4478f270089ccad8a9577fe010af33372f.png) 91 | 92 | 93 | ### 7. 开启权限并配置事件 94 | 95 | 访问开放平台页面,开通如下 6 个权限: 96 | 97 | - im:message 98 | - im:message.group_at_msg 99 | - im:message.group_at_msg:readonly 100 | - im:message.p2p_msg 101 | - im:message.p2p_msg:readonly 102 | - im:message:send_as_bot 103 | 104 | 然后回到 AirCode ,复制函数的调用地址。 105 | 然后回到事件订阅界面,添加事件。 106 | 107 | 108 | ![图 18](images/ae1d98dfcb6fe4a3eaf7ffa397a9797b20b020476d97b1bdd6c76bf730c358a2.png) 109 | 110 | 111 | ### 8. 发布版本,等待审核 112 | 113 | 上述这些都配置完成后,你的机器人就配置好了,接下来只需要在飞书开放平台后台找到应用发布,创建一个全新的版本并发布版本即可。 114 | 115 | ## 如何贡献? 116 | 117 | 欢迎通过 issue 提交你的想法,帮助我迭代这个项目 or 直接通过 Pull Request 来提交你的代码。发布成功后,你就可以在飞书当中体验 ChatGPT 了。 118 | 119 | 120 | ## 发布历史 121 | 122 | ### 1.0.0 123 | 124 | - 初版发布。 -------------------------------------------------------------------------------- /event.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const fetch = require('cross-fetch') 3 | const app = express() 4 | var multer = require('multer'); 5 | var forms = multer({limits: { fieldSize: 10*1024*1024 }}); 6 | app.use(forms.array()); 7 | const cors = require('cors'); 8 | app.use(cors()); 9 | 10 | const bodyParser = require('body-parser') 11 | app.use(bodyParser.json({limit : '50mb' })); 12 | app.use(bodyParser.urlencoded({ extended: true })); 13 | 14 | const tencentcloud = require("tencentcloud-sdk-nodejs"); 15 | const TmsClient = tencentcloud.tms.v20201229.Client; 16 | const clientConfig = { 17 | credential: { 18 | secretId: process.env.TENCENT_CLOUD_SID, 19 | secretKey: process.env.TENCENT_CLOUD_SKEY, 20 | }, 21 | region: process.env.TENCENT_CLOUD_AP||"ap-singapore", 22 | profile: { 23 | httpProfile: { 24 | endpoint: "tms.tencentcloudapi.com", 25 | }, 26 | }, 27 | }; 28 | const mdClient = process.env.TENCENT_CLOUD_SID && process.env.TENCENT_CLOUD_SKEY ? new TmsClient(clientConfig) : false; 29 | 30 | const controller = new AbortController(); 31 | 32 | app.all(`*`, async (req, res) => { 33 | 34 | if(req.originalUrl) req.url = req.originalUrl; 35 | let url = `https://api.openai.com${req.url}`; 36 | // 从 header 中取得 Authorization': 'Bearer 后的 token 37 | const token = req.headers.authorization?.split(' ')[1]; 38 | if( !token ) return res.status(403).send('Forbidden'); 39 | 40 | const openai_key = process.env.OPENAI_KEY||token.split(':')[0]; 41 | if( !openai_key ) return res.status(403).send('Forbidden'); 42 | if( openai_key.startsWith("fk") ) url = url.replaceAll( "api.openai.com", "openai.api2d.net" ); 43 | 44 | const proxy_key = token.split(':')[1]||""; 45 | if( process.env.PROXY_KEY && proxy_key !== process.env.PROXY_KEY ) 46 | return res.status(403).send('Forbidden'); 47 | 48 | // console.log( req ); 49 | const { moderation, moderation_level, ...restBody } = req.body; 50 | let sentence = ""; 51 | // 建立一个句子缓冲区 52 | let sentence_buffer = []; 53 | let processing = false; 54 | let processing_stop = false; 55 | 56 | async function process_buffer(res) 57 | { 58 | if( processing_stop ) 59 | { 60 | console.log("processing_stop",processing_stop); 61 | return false; 62 | } 63 | 64 | console.log("句子缓冲区" + new Date(), sentence_buffer); 65 | 66 | // 处理句子缓冲区 67 | if( processing ) 68 | { 69 | // 有正在处理的,1秒钟后重试 70 | console.log("有正在处理的,1秒钟后重试"); 71 | setTimeout( () => process_buffer(res), 1000 ); 72 | return false; 73 | } 74 | 75 | processing = true; 76 | const sentence = sentence_buffer.shift(); 77 | console.log("取出句子", sentence); 78 | if( sentence ) 79 | { 80 | if( sentence === '[DONE]' ) 81 | { 82 | console.log("[DONE]", "结束输出"); 83 | res.write("data: "+sentence+"\n\n" ); 84 | processing = false; 85 | res.end(); 86 | return true; 87 | }else 88 | { 89 | // 开始对句子进行审核 90 | let data_array = JSON.parse(sentence); 91 | console.log("解析句子数据为array",data_array); 92 | 93 | const sentence_content = data_array.choices[0]?.delta?.content; 94 | console.log("sentence_content", sentence_content); 95 | if( sentence_content ) 96 | { 97 | const params = {"Content": Buffer.from(sentence_content).toString('base64')}; 98 | const md_result = await mdClient.TextModeration(params); 99 | // console.log("审核结果", md_result); 100 | let md_check = moderation_level == 'high' ? md_result.Suggestion != 'Pass' : md_result.Suggestion == 'Block'; 101 | if( md_check ) 102 | { 103 | // 终止输出 104 | console.log("审核不通过", sentence_content, md_result); 105 | let forbidden_array = data_array; 106 | forbidden_array.choices[0].delta.content = "这个话题不适合讨论,换个话题吧。"; 107 | res.write("data: "+JSON.stringify(forbidden_array)+"\n\n" ); 108 | res.write("data: [DONE]\n\n" ); 109 | res.end(); 110 | controller.abort(); 111 | processing = false; 112 | processing_stop = true; 113 | return false; 114 | }else 115 | { 116 | console.log("审核通过", sentence_content); 117 | res.write("data: "+sentence+"\n\n" ); 118 | processing = false; 119 | console.log("processing",processing); 120 | return true; 121 | } 122 | } 123 | 124 | } 125 | }else 126 | { 127 | // console.log("句子缓冲区为空"); 128 | } 129 | 130 | processing = false; 131 | } 132 | 133 | 134 | const options = { 135 | method: req.method, 136 | timeout: process.env.TIMEOUT||30000, 137 | signal: controller.signal, 138 | headers: { 139 | 'Content-Type': 'application/json; charset=utf-8', 140 | 'Authorization': 'Bearer '+ openai_key, 141 | }, 142 | onMessage: async (data) => { 143 | // console.log(data); 144 | if( data === '[DONE]' ) 145 | { 146 | sentence_buffer.push(data); 147 | await process_buffer(res); 148 | }else 149 | { 150 | if( moderation && mdClient ) 151 | { 152 | try { 153 | let data_array = JSON.parse(data); 154 | const char = data_array.choices[0]?.delta?.content; 155 | if( char ) sentence += char; 156 | // console.log("sentence",sentence ); 157 | if( char == '。' || char == '?' || char == '!' || char == "\n" ) 158 | { 159 | // 将 sentence 送审 160 | console.log("遇到句号,将句子放入缓冲区", sentence); 161 | data_array.choices[0].delta.content = sentence; 162 | sentence = ""; 163 | sentence_buffer.push(JSON.stringify(data_array)); 164 | await process_buffer(res); 165 | } 166 | } catch (error) { 167 | // 因为开头已经处理的了 [DONE] 的情况,这里应该不会出现无法解析json的情况 168 | console.log( "error", error ); 169 | } 170 | }else 171 | { 172 | // 如果没有文本审核参数或者设置,直接输出 173 | res.write("data: "+data+"\n\n" ); 174 | } 175 | } 176 | } 177 | }; 178 | 179 | if( req.method.toLocaleLowerCase() === 'post' && req.body ) options.body = JSON.stringify(restBody); 180 | // console.log({url, options}); 181 | 182 | try { 183 | 184 | // 如果是 chat completion 和 text completion,使用 SSE 185 | if( (req.url.startsWith('/v1/completions') || req.url.startsWith('/v1/chat/completions')) && req.body.stream ) { 186 | console.log("使用 SSE"); 187 | const response = await myFetch(url, options); 188 | if( response.ok ) 189 | { 190 | // write header 191 | res.writeHead(200, { 192 | 'Content-Type': 'text/event-stream', 193 | 'Cache-Control': 'no-cache', 194 | 'Connection': 'keep-alive', 195 | }); 196 | const { createParser } = await import("eventsource-parser"); 197 | const parser = createParser((event) => { 198 | // console.log(event); 199 | if (event.type === "event") { 200 | options.onMessage(event.data); 201 | } 202 | }); 203 | if (!response.body.getReader) { 204 | const body = response.body; 205 | if (!body.on || !body.read) { 206 | throw new error('unsupported "fetch" implementation'); 207 | } 208 | body.on("readable", () => { 209 | let chunk; 210 | while (null !== (chunk = body.read())) { 211 | // console.log(chunk.toString()); 212 | parser.feed(chunk.toString()); 213 | } 214 | }); 215 | } else { 216 | for await (const chunk of streamAsyncIterable(response.body)) { 217 | const str = new TextDecoder().decode(chunk); 218 | parser.feed(str); 219 | } 220 | } 221 | } 222 | 223 | }else 224 | { 225 | console.log("使用 fetch"); 226 | const response = await myFetch(url, options); 227 | // console.log(response); 228 | const data = await response.json(); 229 | // 审核结果 230 | if( moderation && mdClient ) 231 | { 232 | const params = {"Content": Buffer.from(data.choices[0].message.content).toString('base64')}; 233 | const md_result = await mdClient.TextModeration(params); 234 | // console.log("审核结果", md_result); 235 | let md_check = moderation_level == 'high' ? md_result.Suggestion != 'Pass' : md_result.Suggestion == 'Block'; 236 | if( md_check ) 237 | { 238 | // 终止输出 239 | console.log("审核不通过", data.choices[0].message.content, md_result); 240 | data.choices[0].message.content = "这个话题不适合讨论,换个话题吧。"; 241 | }else 242 | { 243 | console.log("审核通过", data.choices[0].message.content); 244 | } 245 | } 246 | 247 | res.json(data); 248 | } 249 | 250 | 251 | } catch (error) { 252 | console.error(error); 253 | res.status(500).json({"error":error.toString()}); 254 | } 255 | }) 256 | 257 | async function* streamAsyncIterable(stream) { 258 | const reader = stream.getReader(); 259 | try { 260 | while (true) { 261 | const { done, value } = await reader.read(); 262 | if (done) { 263 | return; 264 | } 265 | yield value; 266 | } 267 | } finally { 268 | reader.releaseLock(); 269 | } 270 | } 271 | 272 | async function myFetch(url, options) { 273 | const {timeout, ...fetchOptions} = options; 274 | const controller = new AbortController(); 275 | const timeoutId = setTimeout(() => controller.abort(), timeout||30000) 276 | const res = await fetch(url, {...fetchOptions,signal:controller.signal}); 277 | clearTimeout(timeoutId); 278 | return res; 279 | } 280 | 281 | // Error handler 282 | app.use(function(err, req, res, next) { 283 | console.error(err) 284 | res.status(500).send('Internal Serverless Error') 285 | }) 286 | 287 | const port = process.env.PORT||9000; 288 | app.listen(port, () => { 289 | console.log(`Server start on http://localhost:${port}`); 290 | }) 291 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | // 调整了 axios 的报错的输出,以便于调试 2 | const aircode = require("aircode"); 3 | const lark = require("@larksuiteoapi/node-sdk"); 4 | var axios = require("axios"); 5 | const EventDB = aircode.db.table("event"); 6 | const MsgTable = aircode.db.table("msg"); // 用于保存历史会话的表 7 | 8 | // 如果你不想配置环境变量,或环境变量不生效,则可以把结果填写在每一行最后的 "" 内部 9 | const FEISHU_APP_ID = process.env.APPID || ""; // 飞书的应用 ID 10 | const FEISHU_APP_SECRET = process.env.SECRET || ""; // 飞书的应用的 Secret 11 | const FEISHU_BOTNAME = process.env.BOTNAME || ""; // 飞书机器人的名字 12 | const OPENAI_KEY = process.env.KEY || ""; // OpenAI 的 Key 13 | const OPENAI_MODEL = process.env.MODEL || "gpt-3.5-turbo"; // 使用的模型 14 | const OPENAI_MAX_TOKEN = process.env.MAX_TOKEN || 1024; // 最大 token 的值 15 | 16 | const client = new lark.Client({ 17 | appId: FEISHU_APP_ID, 18 | appSecret: FEISHU_APP_SECRET, 19 | disableTokenCache: false, 20 | }); 21 | 22 | // 日志辅助函数,请贡献者使用此函数打印关键日志 23 | function logger(param) { 24 | console.debug(`[CF]`, param); 25 | } 26 | 27 | // 回复消息 28 | async function reply(messageId, content) { 29 | try{ 30 | return await client.im.message.reply({ 31 | path: { 32 | message_id: messageId, 33 | }, 34 | data: { 35 | content: JSON.stringify({ 36 | text: content, 37 | }), 38 | msg_type: "text", 39 | }, 40 | }); 41 | } catch(e){ 42 | logger("send message to feishu error",e,messageId,content); 43 | } 44 | } 45 | 46 | 47 | // 根据sessionId构造用户会话 48 | async function buildConversation(sessionId, question) { 49 | let prompt = []; 50 | 51 | // 从 MsgTable 表中取出历史记录构造 question 52 | const historyMsgs = await MsgTable.where({ sessionId }).find(); 53 | for (const conversation of historyMsgs) { 54 | // {"role": "system", "content": "You are a helpful assistant."}, 55 | prompt.push({"role": "user", "content": conversation.question}) 56 | prompt.push({"role": "assistant", "content": conversation.answer}) 57 | } 58 | 59 | // 拼接最新 question 60 | prompt.push({"role": "user", "content": question}) 61 | return prompt; 62 | } 63 | 64 | // 保存用户会话 65 | async function saveConversation(sessionId, question, answer) { 66 | const msgSize = question.length + answer.length 67 | const result = await MsgTable.save({ 68 | sessionId, 69 | question, 70 | answer, 71 | msgSize, 72 | }); 73 | if (result) { 74 | // 有历史会话是否需要抛弃 75 | await discardConversation(sessionId); 76 | } 77 | } 78 | 79 | // 如果历史会话记录大于OPENAI_MAX_TOKEN,则从第一条开始抛弃超过限制的对话 80 | async function discardConversation(sessionId) { 81 | let totalSize = 0; 82 | const countList = []; 83 | const historyMsgs = await MsgTable.where({ sessionId }).sort({ createdAt: -1 }).find(); 84 | const historyMsgLen = historyMsgs.length; 85 | for (let i = 0; i < historyMsgLen; i++) { 86 | const msgId = historyMsgs[i]._id; 87 | totalSize += historyMsgs[i].msgSize; 88 | countList.push({ 89 | msgId, 90 | totalSize, 91 | }); 92 | } 93 | for (const c of countList) { 94 | if (c.totalSize > OPENAI_MAX_TOKEN) { 95 | await MsgTable.where({_id: c.msgId}).delete(); 96 | } 97 | } 98 | } 99 | 100 | // 清除历史会话 101 | async function clearConversation(sessionId) { 102 | return await MsgTable.where({ sessionId }).delete(); 103 | } 104 | 105 | // 指令处理 106 | async function cmdProcess(cmdParams) { 107 | switch (cmdParams && cmdParams.action) { 108 | case "/help": 109 | await cmdHelp(cmdParams.messageId); 110 | break; 111 | case "/clear": 112 | await cmdClear(cmdParams.sessionId, cmdParams.messageId); 113 | break; 114 | default: 115 | await cmdHelp(cmdParams.messageId); 116 | break; 117 | } 118 | return { code: 0 } 119 | } 120 | 121 | // 帮助指令 122 | async function cmdHelp(messageId) { 123 | helpText = `ChatGPT 指令使用指南 124 | 125 | Usage: 126 | /clear 清除上下文 127 | /help 获取更多帮助 128 | ` 129 | await reply(messageId, helpText); 130 | } 131 | 132 | // 清除记忆指令 133 | async function cmdClear(sessionId, messageId) { 134 | await clearConversation(sessionId) 135 | await reply(messageId, "✅记忆已清除"); 136 | } 137 | 138 | // 通过 OpenAI API 获取回复 139 | async function getOpenAIReply(prompt) { 140 | 141 | var data = JSON.stringify({ 142 | model: OPENAI_MODEL, 143 | messages: prompt 144 | }); 145 | 146 | var config = { 147 | method: "post", 148 | maxBodyLength: Infinity, 149 | url: "https://XXXXXXXXXXXX.tencentcs.com/release/v1/chat/completions", 150 | headers: { 151 | Authorization: `Bearer ${OPENAI_KEY}`, 152 | "Content-Type": "application/json", 153 | }, 154 | data: data, 155 | timeout: 50000 156 | }; 157 | 158 | try{ 159 | const response = await axios(config); 160 | 161 | if (response.status === 429) { 162 | return '问题太多了,我有点眩晕,请稍后再试'; 163 | } 164 | // 去除多余的换行 165 | return response.data.choices[0].message.content.replace("\n\n", ""); 166 | 167 | }catch(e){ 168 | logger(e.response.data) 169 | return "问题太难了 出错了. (uДu〃)."; 170 | } 171 | 172 | } 173 | 174 | // 自检函数 175 | async function doctor() { 176 | if (FEISHU_APP_ID === "") { 177 | return { 178 | code: 1, 179 | message: { 180 | zh_CN: "你没有配置飞书应用的 AppID,请检查 & 部署后重试", 181 | en_US: 182 | "Here is no FeiSHu APP id, please check & re-Deploy & call again", 183 | }, 184 | }; 185 | } 186 | if (!FEISHU_APP_ID.startsWith("cli_")) { 187 | return { 188 | code: 1, 189 | message: { 190 | zh_CN: 191 | "你配置的飞书应用的 AppID 是错误的,请检查后重试。飞书应用的 APPID 以 cli_ 开头。", 192 | en_US: 193 | "Your FeiShu App ID is Wrong, Please Check and call again. FeiShu APPID must Start with cli", 194 | }, 195 | }; 196 | } 197 | if (FEISHU_APP_SECRET === "") { 198 | return { 199 | code: 1, 200 | message: { 201 | zh_CN: "你没有配置飞书应用的 Secret,请检查 & 部署后重试", 202 | en_US: 203 | "Here is no FeiSHu APP Secret, please check & re-Deploy & call again", 204 | }, 205 | }; 206 | } 207 | 208 | if (FEISHU_BOTNAME === "") { 209 | return { 210 | code: 1, 211 | message: { 212 | zh_CN: "你没有配置飞书应用的名称,请检查 & 部署后重试", 213 | en_US: 214 | "Here is no FeiSHu APP Name, please check & re-Deploy & call again", 215 | }, 216 | }; 217 | } 218 | 219 | if (OPENAI_KEY === "") { 220 | return { 221 | code: 1, 222 | message: { 223 | zh_CN: "你没有配置 OpenAI 的 Key,请检查 & 部署后重试", 224 | en_US: "Here is no OpenAI Key, please check & re-Deploy & call again", 225 | }, 226 | }; 227 | } 228 | 229 | if (!OPENAI_KEY.startsWith("sk-")) { 230 | return { 231 | code: 1, 232 | message: { 233 | zh_CN: 234 | "你配置的 OpenAI Key 是错误的,请检查后重试。OpenAI 的 KEY 以 sk- 开头。", 235 | en_US: 236 | "Your OpenAI Key is Wrong, Please Check and call again. FeiShu APPID must Start with cli", 237 | }, 238 | }; 239 | } 240 | return { 241 | code: 0, 242 | message: { 243 | zh_CN: 244 | "✅ 配置成功,接下来你可以在飞书应用当中使用机器人来完成你的工作。", 245 | en_US: 246 | "✅ Configuration is correct, you can use this bot in your FeiShu App", 247 | 248 | }, 249 | meta: { 250 | FEISHU_APP_ID, 251 | OPENAI_MODEL, 252 | OPENAI_MAX_TOKEN, 253 | FEISHU_BOTNAME, 254 | }, 255 | }; 256 | } 257 | 258 | async function handleReply(userInput, sessionId, messageId, eventId) { 259 | const question = userInput.text.replace("@_user_1", ""); 260 | logger("question: " + question); 261 | const action = question.trim(); 262 | if (action.startsWith("/")) { 263 | return await cmdProcess({action, sessionId, messageId}); 264 | } 265 | const prompt = await buildConversation(sessionId, question); 266 | const openaiResponse = await getOpenAIReply(prompt); 267 | await saveConversation(sessionId, question, openaiResponse) 268 | await reply(messageId, openaiResponse); 269 | 270 | // update content to the event record 271 | const evt_record = await EventDB.where({ event_id: eventId }).findOne(); 272 | evt_record.content = userInput.text; 273 | await EventDB.save(evt_record); 274 | return { code: 0 }; 275 | } 276 | 277 | module.exports = async function (params, context) { 278 | // 如果存在 encrypt 则说明配置了 encrypt key 279 | if (params.encrypt) { 280 | logger("user enable encrypt key"); 281 | return { 282 | code: 1, 283 | message: { 284 | zh_CN: "你配置了 Encrypt Key,请关闭该功能。", 285 | en_US: "You have open Encrypt Key Feature, please close it.", 286 | }, 287 | }; 288 | } 289 | // 处理飞书开放平台的服务端校验 290 | if (params.type === "url_verification") { 291 | logger("deal url_verification"); 292 | return { 293 | challenge: params.challenge, 294 | }; 295 | } 296 | // 自检查逻辑 297 | if (!params.hasOwnProperty("header") || context.trigger === "DEBUG") { 298 | logger("enter doctor"); 299 | return await doctor(); 300 | } 301 | // 处理飞书开放平台的事件回调 302 | if ((params.header.event_type === "im.message.receive_v1")) { 303 | let eventId = params.header.event_id; 304 | let messageId = params.event.message.message_id; 305 | let chatId = params.event.message.chat_id; 306 | let senderId = params.event.sender.sender_id.user_id; 307 | let sessionId = chatId + senderId; 308 | 309 | // 对于同一个事件,只处理一次 310 | const count = await EventDB.where({ event_id: eventId }).count(); 311 | if (count != 0) { 312 | logger("skip repeat event"); 313 | return { code: 1 }; 314 | } 315 | await EventDB.save({ event_id: eventId }); 316 | 317 | // 私聊直接回复 318 | if (params.event.message.chat_type === "p2p") { 319 | // 不是文本消息,不处理 320 | if (params.event.message.message_type != "text") { 321 | await reply(messageId, "暂不支持其他类型的提问"); 322 | logger("skip and reply not support"); 323 | return { code: 0 }; 324 | } 325 | // 是文本消息,直接回复 326 | const userInput = JSON.parse(params.event.message.content); 327 | return await handleReply(userInput, sessionId, messageId, eventId); 328 | } 329 | 330 | // 群聊,需要 @ 机器人 331 | if (params.event.message.chat_type === "group") { 332 | // 这是日常群沟通,不用管 333 | if ( 334 | !params.event.message.mentions || 335 | params.event.message.mentions.length === 0 336 | ) { 337 | logger("not process message without mention"); 338 | return { code: 0 }; 339 | } 340 | // 没有 mention 机器人,则退出。 341 | if (params.event.message.mentions[0].name != FEISHU_BOTNAME) { 342 | logger("bot name not equal first mention name "); 343 | return { code: 0 }; 344 | } 345 | const userInput = JSON.parse(params.event.message.content); 346 | return await handleReply(userInput, sessionId, messageId, eventId); 347 | } 348 | } 349 | 350 | logger("return without other log"); 351 | return { 352 | code: 2, 353 | }; 354 | }; 355 | --------------------------------------------------------------------------------