導入の背景
最近、ChatGPTの需要が急増しています。同僚にも簡単に利用できるようにしたいと考え、自らの体験を共有することにしました。しかし、V-P-Nや国際電話番号の確認といった手間は多くのユーザーにとって障壁になります。現在では多くの小規模アプリや公式アカウントでこの機能が提供されていますが、仕事においては企業微信(WeCom)が主流です。そこで、従業員が業務ツール上で直接利用できるようにするという考えに至りました。
週末の子供との外出後、疲労を感じながらも深夜に気付き、プロジェクトを開始しました。
企業微信アプリの作成
会社は最近、コミュニケーションプラットフォームを钉钉から企業微信に移行しました。最初は、群チャットのロボット経由でメッセージを解析し、自動応答する方法を検討しました。しかし、企業微信ではこの機能が限られているため、代替案として自作アプリの開発に移行しました。最終的には、独自アプリ経由でのメッセージ受信と応答が可能であることが判明し、プロジェクトを進めるきっかけとなりました。(国内からのアクセス制限により、プロキシサーバーが必要)
企業微信の管理画面にアクセスし、「アプリ管理」セクションで自作アプリを作成します。以下に作成手順を示します。私は「GTP機械学習アプリ」として作成し、メッセージコールバックURLとIP制限を設定しました。URLの検証には別途の処理が必要です。
プロキシサーバーの構築
国内からの直接アクセスが困難なため、香港に設置されたサーバーを使用しました。JDKをインストールし、RestTemplateを用いたHTTPリクエストのテストコードを記述しました。以下がコード例です。
RequestHeaders requestHeaders = new RequestHeaders();
requestHeaders.setContentType("application/json;charset=UTF-8");
requestHeaders.setAuthorization("Bearer key");
requestHeaders.setOpenAIorganization("org-id");
MessageRequest messageRequest = new MessageRequest();
messageRequest.setModel(question.getModel());
messageRequest.setTemperature(0.7);
MessageContent messageContent = new MessageContent();
messageContent.setRole("user");
messageContent.setContent(question.getQuestion());
List<MessageContent> messages = new ArrayList<>();
messages.add(messageContent);
messageRequest.setMessages(messages);
HttpEntity<String> requestEntity = new HttpEntity<>(messageRequest.toString(), requestHeaders);
ResponseEntity<String> responseEntity = restTemplate.postForEntity(URL, requestEntity, String.class);
JsonNode responseBody = objectMapper.readTree(responseEntity.getBody());
String answer = responseBody.path("choices").get(0).path("message").get("content").asText();
System.out.println("APIレスポンス:" + responseBody.toString());
ここで使用するkeyはAPIキーから生成され、org-idは組織設定で取得できます。
アプリケーションをビルドし、nohup java -jar gpt-test.jar &で起動させました。この処理だけで3時間近くかかりましたが、実践的な理解が深まりました。
メッセージ応答処理
企業微信との接続はすでに完了しているため、GPTのAPI呼び出しが遅いことから、非同期処理で応答を送信する必要があります。
URL検証処理
コールバックURLの有効性検証では、暗号化されたデータを復号し、平文を返却します。
String method = httpServletRequest.getMethod();
if (!"POST".equals(method)) {
if (StringUtils.isNotEmpty(sVerifyEchoStr)) {
String sEchoStr = "";
try {
sEchoStr = wxcpt.validateUrl(sVerifyMsgSig, sVerifyTimeStamp,
sVerifyNonce, sVerifyEchoStr);
log.info("URL検証結果: " + sEchoStr);
} catch (Exception e) {
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);
メッセージの送信
次に、イベントリスナーを作成し、プロキシサービスを呼び出してメッセージを送信します。この処理は非常にスムーズに完了しました。
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);
全体的な処理は5時間以内に完了し、企業微信アプリとの接続に主な時間を費やしました。
最後に、企業微信アプリとの接続をサポートするユーティリティクラスを紹介します。
package com.stbella.base.server.qw.util;
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 企業微信の設定で指定したトークン
* @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 タイムスタンプ
* @param nonce ランダム文字列
*
* @return 加密された応答メッセージ
* @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);
// XML形式の生成
String result = XMLParse.generate(encrypt, signature, timeStamp, nonce);
return result;
}
/**
* メッセージの正当性を検証し、復号化後の平文を取得
* <ol>
* <li>受信した暗号文からセキュリティ署名を生成し、署名検証</li>
* <li>検証が通った場合、XML内の暗号メッセージを取得</li>
* <li>メッセージを復号化</li>
* </ol>
*
* @param msgSignature 署名文字列
* @param timeStamp タイムスタンプ
* @param nonce ランダム文字列
* @param postData 暗号文
*
* @return 復号化された平文
* @throws AesException 実行中にエラーが発生した場合
*/
public String DecryptMsg(String msgSignature, String timeStamp, String nonce, String postData)
throws AesException {
// 密钥
// 暗号文の抽出
Object[] encrypt = XMLParse.extract(postData);
// セキュリティ署名の検証
String signature = SHA1.getSHA1(token, timeStamp, nonce, encrypt[1].toString());
// URLの署名と比較
if (!signature.equals(msgSignature)) {
throw new AesException(AesException.ValidateSignatureError);
}
// 復号化
String result = decrypt(encrypt[1].toString());
return result;
}
/**
* URLの検証
* @param msgSignature 署名文字列
* @param timeStamp タイムスタンプ
* @param nonce ランダム文字列
* @param 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;
}
}