💬

ChatworkからChatGPTをセキュアに使ってみよう!

2023/09/10に公開

はじめに

2023年8月に、東京都が都の職員向けに「文章生成AI利活用ガイドライン」を策定して公開しました。非常にわかりやすい内容で、みなさんの会社の業務においても文章生成AIを利活用する上で参考になる点が多いのではないかと思います。

https://www.metro.tokyo.lg.jp/tosei/hodohappyo/press/2023/08/23/14.html

東京都はこの公開と合わせて、職員5万人がChatGPTをセキュアに利用できる環境を整備していますが、みなさんの会社ではChatGPTを業務に利用できますでしょうか?

ChatGPTの種類には、大きく次の3つがあります。

ChatGPTの種類 セキュリティ面 利用するためには
1. OpenAI社の個人向け「ChatGPT」(ブラウザ/スマホアプリ) 😣設定を間違えると入力内容が将来の学習や他の人への回答に使われてしまう 😄ユーザー登録すればすぐに使える
2. OpenAI社の法人向け「ChatGPT Enterprise」 😄入力内容が学習などの用途に使われることがない 🤔費用は非公開(個別対応)
3. OpenAI社やMicrosoft社のChatGPTのAPI 😄入力内容が学習などの用途に使われることがない 😣アプリの開発が必要

会社で使うには2.か3.が対象になりそうです。そこで今回は、お手軽に使えるGoogle Apps Scriptを利用して、3.のChatGPTのAPIを使ったChatworkのチャットボットを作ってみました。Chatworkに登録したチャットボットへ話かけると、ChatGPTが答えてくれる形です。

以下の「Watsonさん」が、所属会社内の実験で使っているチャットボットです。返信すれば直前の会話の内容を維持した会話が続けられます。他の人の会話と混ざることもありません。ChatGPTのAPIを利用しているので、入力内容が学習などの用途に使われてしまうこともなく安心です。

この記事では、このようなチャットボットの作り方を簡単にまとめます。

前提

この記事は、以下の環境や知識があることを前提にしています。

  1. Chatworkの利用環境
  2. Google Apps Script(GAS)を使うためのGoogleアカウント
  3. OpenAI社のAPIまたはMicrosoft Azure OpenAI ServiceのAPIを利用するためのアカウントがあり、実際にOpenAIのPlaygroundやAzure OpenAI StudioのプレイグラウンドでChatGPTのチャットを試すことができる方

なお、今回はChatworkをターゲットにしているので1.は必須ですが、2.と3.は世の中にたくさん情報があるので、なくても比較的簡単にキャッチアップできるのではないかと思います。

構築手順

1. Chatworkのチャットボット用アカウントの作成とAPIトークンの発行

まず、今回作るチャットボット用の「ユーザー」を作成します。指定したチャットグループ内でこのユーザーに話しかけるとChatGPTが応答する形になります。なお、既存のユーザーでも構わないのですが、Chatworkでは自分自身にToでメッセージを送ることができないので、そのユーザーでは今回のチャットボットを使うことができなくなくなってしまいます。

ユーザーを作ったら、そのユーザーでChatworkにログインしてAPIトークンを発行します。発行方法の詳細はChatwork Helpの「APIトークンを発行する」をご参照ください。

ここで発行するAPIトークンは、GASのスクリプトからChatworkにメッセージを投稿するために使います。

2.Google Apps Scriptのスクリプトの作成とデプロイ

ChatworkとChatGPTをつなぐプログラムをGASのスクリプトとして登録します。
まず、GASのスタート画面から新しいプロジェクトを作りましょう。

そして、適当なプロジェクト名を設定し、以下のスクリプトを貼り付けて保存します。

// ChatworkとGoogle Apps ScriptでChatGPTをセキュアに使ってみよう!  2023/09/03 segavvy

// Chatworkからの呼び出しの処理
function doPost(e) {
  // リクエストの署名を検証
  const contents = e.postData.contents;
  const signature = getSignature(contents);
  if (e.parameter.chatwork_webhook_signature !== signature) {
    console.log('リクエストの署名の検証に失敗しました。');
    return;
  }

  // Chatworkから送られてきた情報を取得
  const data = JSON.parse(e.postData.contents);
  let message = data.webhook_event.body;
  const roomId = data.webhook_event.room_id;
  const accountId = data.webhook_event.account_id;
  const messageId = data.webhook_event.message_id;

  // ボット宛のメッセージかチェック(Toや返信でこのアカウントIDに話しかけられた場合のみ応答します)
  const botAccountId = PropertiesService.getScriptProperties().getProperty('CHATWORK_BOT_ACCOUNT_ID');
  let reset = false
  if (message.includes(`[To:${botAccountId}]`)) {
    reset = true  // To:の場合は新規の会話なので過去の履歴はリセット
  } else if (message.includes(`[rp aid=${botAccountId} `)) {
    reset = false // rp:の場合は返信なので過去の履歴はリセットせずに継続
  } else {
    return; // 関係ないメッセージなので反応せずに終了
  }

  // メッセージから宛先や引用の記法を削除
  message = removeMessageTag(message)
  console.log(`ChatGPTへのメッセージ: ${message}`);

  // ChatGPTへ送るメッセージ構造を生成
  let messages = createChatMessages(message, accountId, reset)

  // ChatGPTへリクエスト
  let response = ""
  try {
    response = requestChatCompletion(messages);
    // 成功したらメッセージ履歴に今回の応答を追加して保存
    messages.push({'role': 'assistant', 'content': response})
    PropertiesService.getUserProperties().setProperty(`${accountId}`, JSON.stringify(messages));
  } catch (e) {
    console.log(e.toString());
    response = `ごめんなさい、エラーが発生しました。\n(${e.toString()})`
  }
  console.log(`ChatGPTからの応答: ${response}`);

  // Chatworkへ送信
  sendChatworkMessage(roomId,response,accountId,messageId);
}

// メッセージから宛先や引用の記法を削除
function removeMessageTag(message) {
  // 宛先の削除
  message = message.replace(/\[To:\d+\].*?さん/g, '');
  // 返信先の削除
  message = message.replace(/\[rp aid=\d+ to=\d+\-\d+\].*?さん/g, '');
  // 引用の削除
  message = message.replace(/\[qtmeta aid=\d+ time=\d+\]/g, '');
  message = message.replace(/\[\/?qt\]/g, '');
  return message;
}

// Chatworkへのメッセージ送信
function sendChatworkMessage(roomId,message,accountId,messageId) {
  // パラメーターを設定
  const headers = {
    'X-ChatWorkToken':PropertiesService.getScriptProperties().getProperty('CHATWORK_TOKEN'),
    'Content-type': 'application/json',
  };
  const payload = {
      'body': `[rp aid=${accountId} to=${roomId}-${messageId}][pname:${accountId}]さん\n${message}`
  }
  const options = {
    'muteHttpExceptions' : false,
    'headers': headers, 
    'method': 'POST',
    'payload': payload
  };

  // 実行
  const url = PropertiesService.getScriptProperties().getProperty('CHATWORK_API_URL');
  UrlFetchApp.fetch(`${url}/rooms/${roomId}/messages`, options)
}

// ChatGPTへのメッセージ生成
function createChatMessages(message,accountId,reset) {
  let messages;
  const history = PropertiesService.getUserProperties().getProperty(`${accountId}`);
  if (reset || !history) {
    // 新規メッセージ
    messages = [
      {'role': 'system', 'content': PropertiesService.getScriptProperties().getProperty('GPT_SYSTEM_PROMPT')}
    ];
  } else {
    // 過去の履歴を取得
    messages = JSON.parse(history);
  }
  messages.push({'role': 'user', 'content': message})
  return messages;  
}

// ChatGPT APIへのリクエスト
function requestChatCompletion(messages) {
  // パラメーターを設定
  let headers
  if (PropertiesService.getScriptProperties().getProperty('GPT_VENDER') == 'Microsoft') {
    headers = {
      'api-key':PropertiesService.getScriptProperties().getProperty('GPT_API_KEY'),
      'Content-type': 'application/json'
    };
  } else {
    headers = {
      'Authorization': `Bearer ${PropertiesService.getScriptProperties().getProperty('GPT_API_KEY')}`,
      'Content-type': 'application/json'
    };
  }
  const payload = JSON.stringify({
      'model': PropertiesService.getScriptProperties().getProperty('GPT_MODEL'),
      'max_tokens' : parseInt(PropertiesService.getScriptProperties().getProperty('GPT_MAX_TOKENS'), 10),
      'temperature' : parseFloat(PropertiesService.getScriptProperties().getProperty('GPT_TEMPERATURE')),
      'top_p' : parseFloat(PropertiesService.getScriptProperties().getProperty('GPT_TOP_P')),
      'messages': messages})
  const options = {
    'muteHttpExceptions' : true,
    'headers': headers, 
    'method': 'POST',
    'payload': payload
  };

  // 実行
  try{
    const url = PropertiesService.getScriptProperties().getProperty('GPT_API_URL');
    const response = JSON.parse(UrlFetchApp.fetch(url, options).getContentText());
    if(Object.keys(response).indexOf('error') !== -1){
      console.log(`エラー時のresponse: ${response}`);
      throw new Error(response.error.message);
    }
    return response.choices[0].message.content;
  } catch(e) {
    console.log(`エラー時のmessages: ${messages}`);
    throw e;
  }
}

// Webhook署名の取得
// Chatwork社のブログ「Google Apps ScriptでChatWorkのWebhook署名を検証する方法」より
// https://creators-note.chatwork.com/entry/2017/12/20/163128
function getSignature(contents) {
  const shaObj = new jsSHA("SHA-256", "TEXT");
  shaObj.setHMACKey(PropertiesService.getScriptProperties().getProperty('WEBHOOK_TOKEN'), "B64");
  shaObj.update(contents);
  return shaObj.getHMAC("B64");
}

// Copy from jsSHA/sha256.js at f812eb471428e9e00cc73c975a35bcc73fdacf2e · Caligatio/jsSHA https://github.com/Caligatio/jsSHA/blob/f812eb471428e9e00cc73c975a35bcc73fdacf2e/src/sha256.js
/*
 A JavaScript implementation of the SHA family of hashes, as
 defined in FIPS PUB 180-4 and FIPS PUB 202, as well as the corresponding
 HMAC implementation as defined in FIPS PUB 198a

 Copyright Brian Turek 2008-2017
 Distributed under the BSD License
 See http://caligatio.github.com/jsSHA/ for more information

 Several functions taken from Paul Johnston
*/
'use strict';(function(I){function w(c,a,d){var l=0,b=[],g=0,f,n,k,e,h,q,y,p,m=!1,t=[],r=[],u,z=!1;d=d||{};f=d.encoding||"UTF8";u=d.numRounds||1;if(u!==parseInt(u,10)||1>u)throw Error("numRounds must a integer >= 1");if(0===c.lastIndexOf("SHA-",0))if(q=function(b,a){return A(b,a,c)},y=function(b,a,l,f){var g,e;if("SHA-224"===c||"SHA-256"===c)g=(a+65>>>9<<4)+15,e=16;else throw Error("Unexpected error in SHA-2 implementation");for(;b.length<=g;)b.push(0);b[a>>>5]|=128<<24-a%32;a=a+l;b[g]=a&4294967295;
b[g-1]=a/4294967296|0;l=b.length;for(a=0;a<l;a+=e)f=A(b.slice(a,a+e),f,c);if("SHA-224"===c)b=[f[0],f[1],f[2],f[3],f[4],f[5],f[6]];else if("SHA-256"===c)b=f;else throw Error("Unexpected error in SHA-2 implementation");return b},p=function(b){return b.slice()},"SHA-224"===c)h=512,e=224;else if("SHA-256"===c)h=512,e=256;else throw Error("Chosen SHA variant is not supported");else throw Error("Chosen SHA variant is not supported");k=B(a,f);n=x(c);this.setHMACKey=function(b,a,g){var e;if(!0===m)throw Error("HMAC key already set");
if(!0===z)throw Error("Cannot set HMAC key after calling update");f=(g||{}).encoding||"UTF8";a=B(a,f)(b);b=a.binLen;a=a.value;e=h>>>3;g=e/4-1;if(e<b/8){for(a=y(a,b,0,x(c));a.length<=g;)a.push(0);a[g]&=4294967040}else if(e>b/8){for(;a.length<=g;)a.push(0);a[g]&=4294967040}for(b=0;b<=g;b+=1)t[b]=a[b]^909522486,r[b]=a[b]^1549556828;n=q(t,n);l=h;m=!0};this.update=function(a){var c,f,e,d=0,p=h>>>5;c=k(a,b,g);a=c.binLen;f=c.value;c=a>>>5;for(e=0;e<c;e+=p)d+h<=a&&(n=q(f.slice(e,e+p),n),d+=h);l+=d;b=f.slice(d>>>
5);g=a%h;z=!0};this.getHash=function(a,f){var d,h,k,q;if(!0===m)throw Error("Cannot call getHash after setting HMAC key");k=C(f);switch(a){case "HEX":d=function(a){return D(a,e,k)};break;case "B64":d=function(a){return E(a,e,k)};break;case "BYTES":d=function(a){return F(a,e)};break;case "ARRAYBUFFER":try{h=new ArrayBuffer(0)}catch(v){throw Error("ARRAYBUFFER not supported by this environment");}d=function(a){return G(a,e)};break;default:throw Error("format must be HEX, B64, BYTES, or ARRAYBUFFER");
}q=y(b.slice(),g,l,p(n));for(h=1;h<u;h+=1)q=y(q,e,0,x(c));return d(q)};this.getHMAC=function(a,f){var d,k,t,u;if(!1===m)throw Error("Cannot call getHMAC without first setting HMAC key");t=C(f);switch(a){case "HEX":d=function(a){return D(a,e,t)};break;case "B64":d=function(a){return E(a,e,t)};break;case "BYTES":d=function(a){return F(a,e)};break;case "ARRAYBUFFER":try{d=new ArrayBuffer(0)}catch(v){throw Error("ARRAYBUFFER not supported by this environment");}d=function(a){return G(a,e)};break;default:throw Error("outputFormat must be HEX, B64, BYTES, or ARRAYBUFFER");
}k=y(b.slice(),g,l,p(n));u=q(r,x(c));u=y(k,e,h,u);return d(u)}}function m(){}function D(c,a,d){var l="";a/=8;var b,g;for(b=0;b<a;b+=1)g=c[b>>>2]>>>8*(3+b%4*-1),l+="0123456789abcdef".charAt(g>>>4&15)+"0123456789abcdef".charAt(g&15);return d.outputUpper?l.toUpperCase():l}function E(c,a,d){var l="",b=a/8,g,f,n;for(g=0;g<b;g+=3)for(f=g+1<b?c[g+1>>>2]:0,n=g+2<b?c[g+2>>>2]:0,n=(c[g>>>2]>>>8*(3+g%4*-1)&255)<<16|(f>>>8*(3+(g+1)%4*-1)&255)<<8|n>>>8*(3+(g+2)%4*-1)&255,f=0;4>f;f+=1)8*g+6*f<=a?l+="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".charAt(n>>>
6*(3-f)&63):l+=d.b64Pad;return l}function F(c,a){var d="",l=a/8,b,g;for(b=0;b<l;b+=1)g=c[b>>>2]>>>8*(3+b%4*-1)&255,d+=String.fromCharCode(g);return d}function G(c,a){var d=a/8,l,b=new ArrayBuffer(d),g;g=new Uint8Array(b);for(l=0;l<d;l+=1)g[l]=c[l>>>2]>>>8*(3+l%4*-1)&255;return b}function C(c){var a={outputUpper:!1,b64Pad:"=",shakeLen:-1};c=c||{};a.outputUpper=c.outputUpper||!1;!0===c.hasOwnProperty("b64Pad")&&(a.b64Pad=c.b64Pad);if("boolean"!==typeof a.outputUpper)throw Error("Invalid outputUpper formatting option");
if("string"!==typeof a.b64Pad)throw Error("Invalid b64Pad formatting option");return a}function B(c,a){var d;switch(a){case "UTF8":case "UTF16BE":case "UTF16LE":break;default:throw Error("encoding must be UTF8, UTF16BE, or UTF16LE");}switch(c){case "HEX":d=function(a,b,c){var f=a.length,d,k,e,h,q;if(0!==f%2)throw Error("String of HEX type must be in byte increments");b=b||[0];c=c||0;q=c>>>3;for(d=0;d<f;d+=2){k=parseInt(a.substr(d,2),16);if(isNaN(k))throw Error("String of HEX type contains invalid characters");
h=(d>>>1)+q;for(e=h>>>2;b.length<=e;)b.push(0);b[e]|=k<<8*(3+h%4*-1)}return{value:b,binLen:4*f+c}};break;case "TEXT":d=function(c,b,d){var f,n,k=0,e,h,q,m,p,r;b=b||[0];d=d||0;q=d>>>3;if("UTF8"===a)for(r=3,e=0;e<c.length;e+=1)for(f=c.charCodeAt(e),n=[],128>f?n.push(f):2048>f?(n.push(192|f>>>6),n.push(128|f&63)):55296>f||57344<=f?n.push(224|f>>>12,128|f>>>6&63,128|f&63):(e+=1,f=65536+((f&1023)<<10|c.charCodeAt(e)&1023),n.push(240|f>>>18,128|f>>>12&63,128|f>>>6&63,128|f&63)),h=0;h<n.length;h+=1){p=k+
q;for(m=p>>>2;b.length<=m;)b.push(0);b[m]|=n[h]<<8*(r+p%4*-1);k+=1}else if("UTF16BE"===a||"UTF16LE"===a)for(r=2,n="UTF16LE"===a&&!0||"UTF16LE"!==a&&!1,e=0;e<c.length;e+=1){f=c.charCodeAt(e);!0===n&&(h=f&255,f=h<<8|f>>>8);p=k+q;for(m=p>>>2;b.length<=m;)b.push(0);b[m]|=f<<8*(r+p%4*-1);k+=2}return{value:b,binLen:8*k+d}};break;case "B64":d=function(a,b,c){var f=0,d,k,e,h,q,m,p;if(-1===a.search(/^[a-zA-Z0-9=+\/]+$/))throw Error("Invalid character in base-64 string");k=a.indexOf("=");a=a.replace(/\=/g,
"");if(-1!==k&&k<a.length)throw Error("Invalid '=' found in base-64 string");b=b||[0];c=c||0;m=c>>>3;for(k=0;k<a.length;k+=4){q=a.substr(k,4);for(e=h=0;e<q.length;e+=1)d="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".indexOf(q[e]),h|=d<<18-6*e;for(e=0;e<q.length-1;e+=1){p=f+m;for(d=p>>>2;b.length<=d;)b.push(0);b[d]|=(h>>>16-8*e&255)<<8*(3+p%4*-1);f+=1}}return{value:b,binLen:8*f+c}};break;case "BYTES":d=function(a,b,c){var d,n,k,e,h;b=b||[0];c=c||0;k=c>>>3;for(n=0;n<a.length;n+=
1)d=a.charCodeAt(n),h=n+k,e=h>>>2,b.length<=e&&b.push(0),b[e]|=d<<8*(3+h%4*-1);return{value:b,binLen:8*a.length+c}};break;case "ARRAYBUFFER":try{d=new ArrayBuffer(0)}catch(l){throw Error("ARRAYBUFFER not supported by this environment");}d=function(a,b,c){var d,n,k,e,h;b=b||[0];c=c||0;n=c>>>3;h=new Uint8Array(a);for(d=0;d<a.byteLength;d+=1)e=d+n,k=e>>>2,b.length<=k&&b.push(0),b[k]|=h[d]<<8*(3+e%4*-1);return{value:b,binLen:8*a.byteLength+c}};break;default:throw Error("format must be HEX, TEXT, B64, BYTES, or ARRAYBUFFER");
}return d}function r(c,a){return c>>>a|c<<32-a}function J(c,a,d){return c&a^~c&d}function K(c,a,d){return c&a^c&d^a&d}function L(c){return r(c,2)^r(c,13)^r(c,22)}function M(c){return r(c,6)^r(c,11)^r(c,25)}function N(c){return r(c,7)^r(c,18)^c>>>3}function O(c){return r(c,17)^r(c,19)^c>>>10}function P(c,a){var d=(c&65535)+(a&65535);return((c>>>16)+(a>>>16)+(d>>>16)&65535)<<16|d&65535}function Q(c,a,d,l){var b=(c&65535)+(a&65535)+(d&65535)+(l&65535);return((c>>>16)+(a>>>16)+(d>>>16)+(l>>>16)+(b>>>
16)&65535)<<16|b&65535}function R(c,a,d,l,b){var g=(c&65535)+(a&65535)+(d&65535)+(l&65535)+(b&65535);return((c>>>16)+(a>>>16)+(d>>>16)+(l>>>16)+(b>>>16)+(g>>>16)&65535)<<16|g&65535}function x(c){var a=[],d;if(0===c.lastIndexOf("SHA-",0))switch(a=[3238371032,914150663,812702999,4144912697,4290775857,1750603025,1694076839,3204075428],d=[1779033703,3144134277,1013904242,2773480762,1359893119,2600822924,528734635,1541459225],c){case "SHA-224":break;case "SHA-256":a=d;break;case "SHA-384":a=[new m,new m,
new m,new m,new m,new m,new m,new m];break;case "SHA-512":a=[new m,new m,new m,new m,new m,new m,new m,new m];break;default:throw Error("Unknown SHA variant");}else throw Error("No SHA variants supported");return a}function A(c,a,d){var l,b,g,f,n,k,e,h,m,r,p,w,t,x,u,z,A,B,C,D,E,F,v=[],G;if("SHA-224"===d||"SHA-256"===d)r=64,w=1,F=Number,t=P,x=Q,u=R,z=N,A=O,B=L,C=M,E=K,D=J,G=H;else throw Error("Unexpected error in SHA-2 implementation");d=a[0];l=a[1];b=a[2];g=a[3];f=a[4];n=a[5];k=a[6];e=a[7];for(p=
0;p<r;p+=1)16>p?(m=p*w,h=c.length<=m?0:c[m],m=c.length<=m+1?0:c[m+1],v[p]=new F(h,m)):v[p]=x(A(v[p-2]),v[p-7],z(v[p-15]),v[p-16]),h=u(e,C(f),D(f,n,k),G[p],v[p]),m=t(B(d),E(d,l,b)),e=k,k=n,n=f,f=t(g,h),g=b,b=l,l=d,d=t(h,m);a[0]=t(d,a[0]);a[1]=t(l,a[1]);a[2]=t(b,a[2]);a[3]=t(g,a[3]);a[4]=t(f,a[4]);a[5]=t(n,a[5]);a[6]=t(k,a[6]);a[7]=t(e,a[7]);return a}var H;H=[1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,
2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,3584528711,113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,2177026350,2456956037,2730485921,2820302411,3259730800,3345764771,3516065817,3600352804,4094571909,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,2227730452,2361852424,2428436474,
2756734187,3204031479,3329325298];"function"===typeof define&&define.amd?define(function(){return w}):"undefined"!==typeof exports?("undefined"!==typeof module&&module.exports&&(module.exports=w),exports=w):I.jsSHA=w})(this);

このスクリプトは、Chatworkの特定のチャットグループに誰かがメッセージを投稿すると自動的に実行されるようになります。スクリプトの内部では、チャットボット宛のメッセージなのかどうかを判断し、もしそうだった場合はChatGPTのAPIを呼び出してその応答文を受け取り、ChatworkのAPIを呼び出して応答文のメッセージを投稿する形になってします。スクリプトについては後半で解説します。

次に、このスクリプトをウェブアプリとしてデプロイすることで、Chatworkのサービス側から呼び出せるようにします。

説明文は任意の内容で構いません。ウェブアプリの実行ユーザーは「自分」、アクセスできるユーザーは「全員」にします。

本当はChatworkのサービス以外からは呼ばれたくないので、アクセスできるユーザーをChatworkのサービスに限定したいところなのですが、その手段は用意されていない模様です。そのため、ここでは「全員」として公開します。

初めてデプロイする時は、このウェブアプリに自分のアカウントを使わせることを承認する必要があります。

ウェブアプリの実行ユーザーである自分のアカウントを選択します。

このウェブアプリは全員に公開しようとしているので、Googleに怪しいと判断された場合は警告がでてきます。出てきた場合は左下の「Advanced」を選択します。

そうすると、下部に追加のメッセージが表示されます。自分で作っているウェブアプリなので、一番下のリンクをクリックして次に進みます。

今回のウェブアプリに自分のアカウントを使わせていいのか確認してきますので、右下の「Allow」で許可します。

これでウェブアプリを使うためのURLが発行され公開完了です。このURLをChatwork側に設定して呼び出してもらう形になるので、URLをコピーしておいてください。

これでGASのスクリプトの作成とデプロイは完了です。今回作ったプロジェクトは、GASのホーム画面からいつでもアクセスできます。

3. Chatworkのグループチャット(ルーム)の作成とWebhookの作成

続いてChatwork側の設定です。

まず、今回のチャットボットを参加させるグループチャットを決めてください。新規に作っても既存のものでも構いません。そして、そのグループチャットのメンバーに、1.で作ったチャットボットのユーザーを加えてください。

次にWebhookの設定をします。Webhookは何かイベントが発生した時に外部のAPIを呼び出す仕組みのことで、今回は、Chatworkの特定のグループチャットに誰かがメッセージを投稿した時に、2.でデプロイしたウェブアプリを呼び出すように設定します。

Webhookの設定は、Chatworkの画面右上のユーザーアイコンから「サービス連携」を選択し、左のメニューで「Webhook」を選択して「新規作成」を選ぶと設定できます。

「Webhook名」は任意の内容で構いません。「Webhook URL」は2.でウェブアプリ公開時に発行されたURLを設定します。イベントは「ルームイベント」を選び、「メッセージ作成」にチェックをいれて、「ルームID」に対象のグループチャットのルームIDを設定します。画面の下部にも説明がありますが、ルームIDはChatworkでそのグループチャットを選んだ時のURLの#!rid以降の数字です。

「作成」するとトークンが発行されます。このトークンは、ウェブアプリがChatworkのこのWebhookにより呼び出されたことを検証するために必要になるので、コピーしておいてください。

これで、対象のグループチャットにおいてメッセージが作成されると、2.で公開したウェブアプリが自動的に呼び出されるようになります。

4. 必要なURLやAPIキーの設定

最後にGASのホーム画面から今回作ったプロジェクトを選択し、左側の歯車アイコンを選んで「プロジェクトの設定」画面を表示し、画面下部の「スクリプト プロパティ」に必要なURLやAPIキーを設定します。

設定項目が多くて大変ですが、「スクリプト プロパティを追加」ボタンを押して、以下の内容を設定してください。

プロパティ
CHATWORK_BOT_ACCOUNT_ID 今回作ったチャットボット用のユーザーアカウントのIDです。このID宛のメッセージかどうかをチェックして、そうだった場合のみチャットボットが応答します。アカウントIDの確認方法は、Chatwork Helpの https://help.chatwork.com/hc/ja/articles/360000142962-アカウントIDを確認する をご参照ください。
CHATWORK_TOKEN 1.で発行した、チャットボット用のユーザーアカウントのAPIトークンです。これを使うことで、そのユーザーとしてChatworkにメッセージを投稿します。
CHATWORK_API_URL Chatworkにメッセージを投稿するためのAPIのエンドポイントです。ここにはベースURLを設定してください。ベースURLはChatwork APIのサイトの https://developer.chatwork.com/docs/endpoints で確認できます。2023年8月時点ではhttps://api.chatwork.com/v2です。
WEBHOOK_TOKEN 3.でChatworkのWebhookを設定した際に発行されたトークンです。ウェブアプリのスクリプト内部で、この情報を使って処理依頼を検証することにより、Chatworkの正しいWebhookによる依頼であることを確認します。
GPT_VENDER 使うChatGPTのAPIのベンダーを設定します。
OpenAI社提供のAPIを使う場合はOpenAIを設定してください。
Microsoft Azure OpenAI Serviceの場合はMicrosoftを設定してください。
GPT_API_URL ChatGPTのAPIのエンドポイントです。
以降のGPT関連の設定値については後述します。
GPT_API_KEY ChatGPTのAPIのAPIキーです。
GPT_MODEL ChatGPTの利用モデルです。
GPT_MAX_TOKENS ChatGPTに生成させる応答文の最大のトークン数です。
GPT_TEMPERATURE ChatGPTの温度の指定です。
GPT_TOP_P ChatGPTの上位Pの指定です。
GPT_SYSTEM_PROMPT ChatGPTへの振る舞いの指示(システムメッセージ)です。

なお、GTP_で始まる後半の項目は、利用されるAPIで変わります。

(a)OpenAI社のAPIを利用する場合

「GPT_API_URL」は、2023年8月時点ではhttps://api.openai.com/v1/chat/completionsです。詳細は API reference > ENDPOINTS > Chat > Create chat completion で確認できます。

「GPT_API_KEY」は、OpenAIで発行したものを指定してください。後からの確認はできない模様です。

それ以外の値はPlaygroundで確認できます。Playgroundで使いたいモデルを選択し、実際に試して良い感じになったパラメーターを設定してください。

(b)Microsoft Azure OpenAI ServiceのAPIを利用する場合

Azure OpenAI StudioのChatGPTプレイグラウンドの画面で確認できます。プレイグラウンドで使いたいデプロイを選択し、実際に試して良い感じになったパラメーターを設定してください。なお、「GPT_API_URL」と「GPT_API_KEY」は、画面中央上部の「チャット セッション」の「コードの表示」を選ぶと、画面下部でコピーできます。

これで「スクリプトプロパティを保存」すれば設定は完了です。お疲れさまでした!
Chatworkで動作を試してみてください。

スクリプトの概要

関数ごとの概要をまとめます。

doPost()

このウェブアプリのPOSTリクエストに対するエントリポイントで、ChatworkのWebhookにより呼び出されます。

  1. リクエストの署名を検証し、ChatworkのWebhookからのリクエストかどうかをチェックします。
  2. 宛先や返信かどうかなどをチェックして、このチャットボットが応答すべきメッセージかどうかを判定します。
  3. メッセージから邪魔になりそうなChatworkの記法を除去します。
  4. ChatGPTへリクエストして応答文を受け取ります。
  5. ここまでのChatGPTとのやり取りが詰まったmessagesをGASのユーザープロパティに保存し、次回の会話の継続に備えます。
  6. ChatGPTからの応答文を、メッセージへの返信としてChatworkに投稿します。

removeMessageTag()

受け取ったメッセージからChatworkの記法を除去します。Chatwork API > メッセージ記法について を見ながら、邪魔になりそうな記法を除去しています。引用の除去は開始と終了を2段階で除去していたり(ペアになっていなくても除去してしまう)と少し簡易的な実装です。

sendChatworkMessage()

Chatworkへメッセージを投稿します。APIの詳細は Chatwork API > エンドポイント一覧 > https://api.chatwork.com/v2/rooms/{room_id}/messages > チャットにメッセージを投稿する で確認できます。

createChatMessages()

ChatGPTへ送るmessagesの構造を作り上げます。新規メッセージの場合はシステムプロンプトを設定し、前回の会話の継続の場合は、GASのユーザープロパティに保存してある前回のmessagesを読み戻します。そして、そこに今回のメッセージを追加します。

requestChatCompletion()

ChatGPTのAPIを呼び出します。APIの詳細は、OpenAI社提供のものは API reference > ENDPOINTS > Chat > Create chat completion で、Microsoft Azure OpenAI Serviceの場合は Azure OpenAI Service のドキュメント > リファレンス > REST API(完了と埋め込み) > チャット入力候補 で確認できます。

getSignature()

ChatworkのWebhook署名を取得します。このコードは、Chatwork社のブログChatwork Creator's Note > Google Apps ScriptでChatWorkのWebhook署名を検証する方法 をほぼそのまま利用させていただきました。以降のBSD Licenseで提供されているjavascriptの埋め込みコードも同様です。

制限事項など

文脈の維持と永続化は簡易的な実装です

チャットボットへの返信時に前回の会話の内容を維持するため、GASのユーザープロパティに、メッセージ送信者のアカウントIDをキーとしてmessagesを保存しています。そのために、いくつか制限があります。

  • アカウントIDをキーとしているので、保存しているのはその利用者との最後の一連の会話です。Chatworkでは、過去のやり取りに対して返信することもできますが、その場合でも、このアプリでは最新の会話の続きだと思ってChatGPTに投げてしまいます。

  • 本来、ユーザープロパティに情報を永続化するのは適切ではありません。特に容量の制限が厳しく、現時点では合計で500KBしか保存できません(Google サービスの割り当て-現在の制限を参照してください) 。4Kや8Kのモデルならまだ良いのですが、GPT-4の32Kモデルをハードに使うと、10数人が使うだけで溢れてしまう可能性があります。大人数で使う場合は適切な場所への保存が必要です。

ログ出力について

スクリプトではconsole.log()を使っていますが、これはGoogle Cloud Platform(GCP)との連携を前提としたコードのため、ここまでの手順だけではログが出力されません。もし、Google Workspace(旧G Suite)をご利用の場合は、不具合を調査したりログを蓄積したりするためにGCPとの連携をお勧めします。

手順については、hidetoshl.comのGoogle Apps ScriptのログをGoogle Cloud Platformで確認する方法の記事がわかりやすくてお勧めです。ただし、GCPの画面が少し新しくなっているので、適宜読み替えてください。

GASでの実装は小規模での利用が前提です

前述のユーザープロパティへの保存の話と同様ですが、GASにはいろいろな制限があります。同時に何人も利用するような負荷の高いサービスには向きません。要件に合わせて適切なサービスを選択してください。

おわりに

ChatworkとGoogle Apps ScriptでChatGPTのセキュアな利用に挑戦してみました。何かChatGPTの業務利用促進の参考になることがありましたら幸いです。

最後までお読みいただき、ありがとうございました。

(おまけ)

最後に、次のようなお悩みをお持ちの皆さんにむけて、少しだけ宣伝を。

そもそもAIやChatGPTがなんなのかわからない

https://segavvy.hatenablog.com/entry/2023/04/01/134729

どうやって導入を検討すればいいのかわからない

https://cogmo.iact.co.jp/sol/chatgpt_consulting

単にChatGPTを社内利用するだけでなく、社内の独自の情報も活用したい

https://cogmo.iact.co.jp/news/20230427

RAGでベクトル検索のチューニングに疲弊しています……

代わりにランキング調整機能や同義語登録機能などを備えたAI検索との連携はいかがでしょう?以下、連携事例です。
https://speakerdeck.com/segavvy/chatgpttoibm-watsonde-saitonei-jian-suo-wojin-hua-sasetemita-zeng-bu-ban

所属会社では、ChatGPTやAI検索を企業内で活用する各種サービスを展開しております。お気軽にお声がけください!

Discussion