引子
最近最火的莫過於ChatGPT了,在自己體驗後就想著如何其他同事也能方便的起起來,畢竟獨樂樂不如眾樂樂,自己註冊又是V-P-N,又是國外手機驗證,對於大部分同事來說門檻還是高的。現在也有不少小程式,公眾號整合了這個能力,但工作中大家還是在企微中,如果能讓大家在企微,釘釘這樣的工作臺上使用,與工作無縫對接,這豈不快哉。
本來週末遛娃一天已經筋疲力盡了,收拾完娃子也跟著睡覺了,哪成想,等把他們哄睡後,23點突然來了精神,就開始搗鼓起來了。
建立企微應用
最近公司剛把內部溝通平臺從釘釘切換到企微了,就開始摸索可能的對接方式。 一開始想著和釘釘一樣,@機器人時,解析內容,自動回覆。結果一查,企微的群機器人沒這個功能,只能配置有限的規則,自動回覆。在無奈之時,甚至想先用釘釘對接起來,但是沒能過了心裡這關,畢竟本月剛剛完成了釘釘到企微的全面遷移,遷移過程還問題多多。原本以為此路不通,即將放棄之時,突然看了社群有個回覆自建應用可以實現接收使用者的訊息並主動回覆內容(介面文件點這裡),一開始覺得用群,熱鬧一些,效果好些;不過從後來大家問的問題來看,單獨應用時,大家放得開些。 喧鬧與寂靜從來都是相輔相成。企微走了通了,全鏈路也就用了。(因為國內無法使用的情況,要有一個伺服器做請求代理)
話不多說,進入企微後臺,進入“應用管理”模組,建立自建應用,如下圖。我建立了一個GTP機器應用,配置訊息回撥地址,IP限制等資訊就可以了,配置回撥地址時,會有資料驗證,下面會說到。
配置代理伺服器
因為一些原因,國內無法直接訪問,於是購買了一臺香港的伺服器,安裝JKD jdk-8u202-linux-x64.tar.gz,使用RestTemplate發起Http請求, 測試程式碼比較簡單。 程式碼如下,
HttpHeaders headers = new HttpHeaders(); headers.add("Content-Type", "application/json;charset=UTF-8"); headers.add("Authorization", "Bearer key"); headers.add("OpenAI-Organization", "org-id"); JSONObject textMsg=new JSONObject(); textMsg.put("model", question.getModel()); textMsg.put("temperature",0.7); JSONArray messages = new JSONArray(); JSONObject message = new JSONObject(); message.put("role", "user"); message.put("content", question.getQuestion()); messages.add(message); textMsg.put("messages", messages); HttpEntity<String> request = new HttpEntity<>(JSONObject.toJSONString(textMsg), headers); ResponseEntity<JSONObject> responseBody = restTemplate.postForEntity(URL, request, JSONObject.class); JSONObject httpBody = responseBody.getBody(); String answer = httpBody.getJSONArray("choices").getJSONObject(0).getJSONObject("message").getString("content"); System.out.println("介面返回引數:" + httpBody.toJSONString());
其中 key 從api keys 中生成,ogi-id 在組織配置中查詢。
透過 Artifacts 方式生成jar, 透過命令 nohup java -jar gpt-test.jar & 跑起來,驗證下介面,一次透過,抬頭看時間已經2點多了,看起來簡單的事兒,也用了快3小時了,真是須知書上得來終覺淺、絕知此事要躬行。
內容回覆
之前對接企微時,資料加解密程式碼已經完成,後面的流程就非常簡單了。因為呼叫GPT介面比較慢,而自建應用要求在5s內回覆,所以需要使用非同步呼叫介面,主動回覆自建應用。
驗證URL有效性
應用配置回撥域名時,有一個get 請求校驗合法性: 解碼收的加密內容,回覆訊息的明文。
String method = httpServletRequest.getMethod(); if (!"POST".equals(method)) { if (StrUtil.isNotBlank(sVerifyEchoStr)) { String sEchoStr = ""; try { sEchoStr = wxcpt.VerifyURL(sVerifyMsgSig, sVerifyTimeStamp, sVerifyNonce, sVerifyEchoStr); log.info("verifyurl echostr: " + sEchoStr); } catch (Exception e) { //驗證URL失敗,錯誤原因請檢視異常 log.error("驗證失敗", e); } response.getWriter().print(sEchoStr); } }
回覆
因為等待GPT的時間比較長,所以我們查以先回復一條:“回覆正在生成中”,提升下體驗,本來可以使用被動回覆內容,一直沒成功,無奈也用主動回覆來完成了。 接著傳送一個非同步事件,完成介面呼叫與回覆。
String accessToken = this.getAccessToken(sendPersonMessageParam); String url = "https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=ACCESS_TOKEN".replace("ACCESS_TOKEN",accessToken); MessageBody messageBody = new MessageBody(); messageBody.setTouser(sendPersonMessageParam.getToUserId()); messageBody.setMsgtype("text"); messageBody.setAgentid(sendPersonMessageParam.getAgentId()); Text text = new Text(); text.setContent(sendPersonMessageParam.getContent()); messageBody.setText(text); messageBody.setSafe(0); String execute = HttpRequest.post(url).body(JSONObject.toJSONString(messageBody)) .execute().body(); JSONObject jsonObject = JSONObject.parseObject(execute);
CreateQuestionEvent createQuestionEvent = new CreateQuestionEvent(); createQuestionEvent.setQuestion(jsonObject.getString("Content")); createQuestionEvent.setUserId(jsonObject.getString("FromUserName")); createQuestionEvent.setTimestamp(System.currentTimeMillis()); context.publishEvent(createQuestionEvent);
主動推送訊息
不知不覺中,時間來到3點多,媳婦突然醒了,看我還在挑燈夜戰,給我一頓說,在這個關鍵時刻,我只能說再給我15分鐘就Ok了。結果,她還真是一直等著。建立一個事件監聽器,呼叫代理服務,回覆訊息。過程非常順利,一把透過。
log.info("收到問題建立事件:{}", event); JSONObject body = new JSONObject(); body.put("model", "gpt-3.5-turbo"); body.put("question", event.getQuestion()); String execute = HttpRequest.post(ProxyUrl).body(JSONObject.toJSONString(body)) .execute().body(); log.info("問題回答:{}", execute); SendPersonMessageParam sendPersonMessageParam = new SendPersonMessageParam(); sendPersonMessageParam.setCorpId(ReceiveWeComMsgController.corpId); sendPersonMessageParam.setSecret(ReceiveWeComMsgController.secret); sendPersonMessageParam.setAgentId(Integer.parseInt(ReceiveWeComMsgController.agentId)); sendPersonMessageParam.setToUserId(event.getUserId()); sendPersonMessageParam.setContent(execute); weWorkService.sendPersonMessage(sendPersonMessageParam);
趕在4點前完成了整個流程,整個過程大概不到5小時,主要時間用在對於企微應用的對接。
最後貼下企微應用對接工具類,希望大家可以比我更快的對接。
package com.stbella.base.server.qw.util; import com.google.common.io.BaseEncoding; import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.Charset; import java.util.Arrays; import java.util.Random; public class WXBizMsgCrypt { static Charset CHARSET = Charset.forName("utf-8"); byte[] aesKey; String token; String receiveid; /** * 建構函式 * @param token 企業微信後臺,開發者設定的token * @param encodingAesKey 企業微信後臺,開發者設定的EncodingAESKey * @param receiveid, 不同場景含義不同,詳見文件 * * @throws AesException 執行失敗,請檢視該異常的錯誤碼和具體的錯誤資訊 */ public WXBizMsgCrypt(String token, String encodingAesKey, String receiveid) throws AesException { if (encodingAesKey.length() != 43) { throw new AesException(AesException.IllegalAesKey); } this.token = token; this.receiveid = receiveid; aesKey = BaseEncoding.base64().decode(encodingAesKey + "="); } // 生成4個位元組的網路位元組序 byte[] getNetworkBytesOrder(int sourceNumber) { byte[] orderBytes = new byte[4]; orderBytes[3] = (byte) (sourceNumber & 0xFF); orderBytes[2] = (byte) (sourceNumber >> 8 & 0xFF); orderBytes[1] = (byte) (sourceNumber >> 16 & 0xFF); orderBytes[0] = (byte) (sourceNumber >> 24 & 0xFF); return orderBytes; } // 還原4個位元組的網路位元組序 int recoverNetworkBytesOrder(byte[] orderBytes) { int sourceNumber = 0; for (int i = 0; i < 4; i++) { sourceNumber <<= 8; sourceNumber |= orderBytes[i] & 0xff; } return sourceNumber; } // 隨機生成16位字串 String getRandomStr() { String base = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; Random random = new Random(); StringBuffer sb = new StringBuffer(); for (int i = 0; i < 16; i++) { int number = random.nextInt(base.length()); sb.append(base.charAt(number)); } return sb.toString(); } /** * 對明文進行加密. * * @param text 需要加密的明文 * @return 加密後base64編碼的字串 * @throws AesException aes加密失敗 */ String encrypt(String randomStr, String text) throws AesException { ByteGroup byteCollector = new ByteGroup(); byte[] randomStrBytes = randomStr.getBytes(CHARSET); byte[] textBytes = text.getBytes(CHARSET); byte[] networkBytesOrder = getNetworkBytesOrder(textBytes.length); byte[] receiveidBytes = receiveid.getBytes(CHARSET); // randomStr + networkBytesOrder + text + receiveid byteCollector.addBytes(randomStrBytes); byteCollector.addBytes(networkBytesOrder); byteCollector.addBytes(textBytes); byteCollector.addBytes(receiveidBytes); // ... + pad: 使用自定義的填充方式對明文進行補位填充 byte[] padBytes = PKCS7Encoder.encode(byteCollector.size()); byteCollector.addBytes(padBytes); // 獲得最終的位元組流, 未加密 byte[] unencrypted = byteCollector.toBytes(); try { // 設定加密模式為AES的CBC模式 Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding"); SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES"); IvParameterSpec iv = new IvParameterSpec(aesKey, 0, 16); cipher.init(Cipher.ENCRYPT_MODE, keySpec, iv); // 加密 byte[] encrypted = cipher.doFinal(unencrypted); // 使用BASE64對加密後的字串進行編碼 String base64Encrypted = BaseEncoding.base64().encode(encrypted); return base64Encrypted; } catch (Exception e) { e.printStackTrace(); throw new AesException(AesException.EncryptAESError); } } /** * 對密文進行解密. * * @param text 需要解密的密文 * @return 解密得到的明文 * @throws AesException aes解密失敗 */ String decrypt(String text) throws AesException { byte[] original; try { // 設定解密模式為AES的CBC模式 Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding"); SecretKeySpec key_spec = new SecretKeySpec(aesKey, "AES"); IvParameterSpec iv = new IvParameterSpec(Arrays.copyOfRange(aesKey, 0, 16)); cipher.init(Cipher.DECRYPT_MODE, key_spec, iv); // 使用BASE64對密文進行解碼 byte[] encrypted = BaseEncoding.base64().decode(text); // 解密 original = cipher.doFinal(encrypted); } catch (Exception e) { e.printStackTrace(); throw new AesException(AesException.DecryptAESError); } String xmlContent, from_receiveid; try { // 去除補位字元 byte[] bytes = PKCS7Encoder.decode(original); // 分離16位隨機字串,網路位元組序和receiveid byte[] networkOrder = Arrays.copyOfRange(bytes, 16, 20); int xmlLength = recoverNetworkBytesOrder(networkOrder); xmlContent = new String(Arrays.copyOfRange(bytes, 20, 20 + xmlLength), CHARSET); from_receiveid = new String(Arrays.copyOfRange(bytes, 20 + xmlLength, bytes.length), CHARSET); } catch (Exception e) { e.printStackTrace(); throw new AesException(AesException.IllegalBuffer); } // receiveid不相同的情況 if (!from_receiveid.equals(receiveid)) { throw new AesException(AesException.ValidateCorpidError); } return xmlContent; } /** * 將企業微信回覆使用者的訊息加密打包. * <ol> * <li>對要傳送的訊息進行AES-CBC加密</li> * <li>生成安全簽名</li> * <li>將訊息密文和安全簽名打包成xml格式</li> * </ol> * * @param replyMsg 企業微信待回覆使用者的訊息,xml格式的字串 * @param timeStamp 時間戳,可以自己生成,也可以用URL引數的timestamp * @param nonce 隨機串,可以自己生成,也可以用URL引數的nonce * * @return 加密後的可以直接回複使用者的密文,包括msg_signature, timestamp, nonce, encrypt的xml格式的字串 * @throws AesException 執行失敗,請檢視該異常的錯誤碼和具體的錯誤資訊 */ public String EncryptMsg(String replyMsg, String timeStamp, String nonce) throws AesException { // 加密 String encrypt = encrypt(getRandomStr(), replyMsg); // 生成安全簽名 if (timeStamp == "") { timeStamp = Long.toString(System.currentTimeMillis()); } String signature = SHA1.getSHA1(token, timeStamp, nonce, encrypt); // System.out.println("傳送給平臺的簽名是: " + signature[1].toString()); // 生成傳送的xml String result = XMLParse.generate(encrypt, signature, timeStamp, nonce); return result; } /** * 檢驗訊息的真實性,並且獲取解密後的明文. * <ol> * <li>利用收到的密文生成安全簽名,進行簽名驗證</li> * <li>若驗證透過,則提取xml中的加密訊息</li> * <li>對訊息進行解密</li> * </ol> * * @param msgSignature 簽名串,對應URL引數的msg_signature * @param timeStamp 時間戳,對應URL引數的timestamp * @param nonce 隨機串,對應URL引數的nonce * @param postData 密文,對應POST請求的資料 * * @return 解密後的原文 * @throws AesException 執行失敗,請檢視該異常的錯誤碼和具體的錯誤資訊 */ public String DecryptMsg(String msgSignature, String timeStamp, String nonce, String postData) throws AesException { // 金鑰,公眾賬號的app secret // 提取密文 Object[] encrypt = XMLParse.extract(postData); // 驗證安全簽名 String signature = SHA1.getSHA1(token, timeStamp, nonce, encrypt[1].toString()); // 和URL中的簽名比較是否相等 // System.out.println("第三方收到URL中的簽名:" + msg_sign); // System.out.println("第三方校驗簽名:" + signature); if (!signature.equals(msgSignature)) { throw new AesException(AesException.ValidateSignatureError); } // 解密 String result = decrypt(encrypt[1].toString()); return result; } /** * 驗證URL * @param msgSignature 簽名串,對應URL引數的msg_signature * @param timeStamp 時間戳,對應URL引數的timestamp * @param nonce 隨機串,對應URL引數的nonce * @param echoStr 隨機串,對應URL引數的echostr * * @return 解密之後的echostr * @throws AesException 執行失敗,請檢視該異常的錯誤碼和具體的錯誤資訊 */ public String VerifyURL(String msgSignature, String timeStamp, String nonce, String echoStr) throws AesException { String signature = SHA1.getSHA1(token, timeStamp, nonce, echoStr); if (!signature.equals(msgSignature)) { throw new AesException(AesException.ValidateSignatureError); } String result = decrypt(echoStr); return result; } }
結語
本來準備部門內先試用下,看看效果,也測測bug。老大把應用許可權變成全公司可見後,還沒發通知,大家就踴躍的用了起來,問啥的都有。 上傳自己企業的訓練資料,建立個性化的模型,可能企業內部真的能用起來
體驗了兩週Github Copilt 感覺真是可以少寫不少程式碼,以前,我認為程式設計是一件充滿創造性的工作,是一次次思維的旅行,不可取代。然而GPT出現了,大部分工種的工作方式都受到衝擊,非常多的機械的動作會被取代,包括程式設計。
思考自身的工作如果與GPT結合,如何擁抱Ai,讓他完成”低智”工作,讓自己從機械的工作中解放,提升自我效率,將會變得非常重要。
成為一名優秀的程式設計師!