GoogleAppsScript
googleapi
GoogleSpreadSheet

Google Apps Script(GAS) で小さな Web アプリを作る


はじめに

何を今更な話ですがgasで作成したスクリプトを WEB から rest API として叩けるようになったので記録として残します。

ターゲットは前回Google ホームからGoogle スプレッドシートを中継して ツイートするでこさえた Twitter の送信スクリプトです。こいつを rest API に仕立ててiftttのWebhookから叩いてやれば Google Home かららくらくツイートできます。

gasでは実行可能 API としてサービスする方式と Web アプリケーションとしてサービスする方式の二つがありますが、今回はiftttと連携したいので後者を選びます。


Web アプリケーションとして導入

スクリプトエディタのメニューから公開 > Web アプリケーションとして導入 と進めば、 API の URL が決定されて API を叩く準備ができます。なお URL はスクリプトの ID から決定されます。

まだ何の実装もされていないので、ここではキャンセルでそっ閉じます。

キャプチャ.PNG

もちろんスクリプト側にも仕掛けが必要デス。

リクエストのメソッドごとに関数の名称が決められていて、公開URL に対して get メソッドであれば doGet 関数、Post メソッドであればdoPost関数が呼び出されます。


sample.gs

// Post メソッドでリクエストされた時に起動する関数 

function doPost(e) {
var res;
// メッセージを貯めるシートからツイートするメッセージを取得する
var sheet = getSheet();
var messages = getMessages(sheet);
if (messages.length < 1) return;
// メッセージの無駄な空白を取り除く
var smsg = strippedMessages(messages);
// メッセージをできるだけ140文字に収まるように結合する
var jmsg = joinMessages(smsg);
// 出来上がったツイートを投稿 送信に失敗したメッセージが返ってくる
var rmsg = postMessages(jmsg);
// 一度シートをクリアしてから投稿に失敗したメッセージをシートに戻す
sheet.clear();
putMessages(sheet, rmsg);
return makeContent('post ok');
}

一つのアプリケーションでひとつの機能しか実装できませんが、そこは割り切った思想となっています。これらの関数に好きなように処理を書いてレスポンスのページを返せば目的は達成です。


公開してみる

プロジェクトバージョンを新規作成、アプリケーションにアクセスできる匿名ユーザを含む全員に設定して公開します。プログラムを修正する都度プロジェクトバージョンをあげる必要があるので、機能テストをしてから公開した方が良いでしょう。

スプレッドシートからメッセージを取り出してツイートする処理はすでに動いているので、その処理の入り口をdoPost関数にするだけですね。

コードは長くなるので最後に記載します。


IFTTTから実行

iftttのthatにwebhooksを指定すれば、何らかのきっかけで Web API をコールすることができます。

webHook.PNG

もちろん Post メソッドにも対応しているので今回のケースでも適用できます。一段階ずつ確実に確認したい方は curl コマンドから叩いてくださいね。

ThatwebHook.PNG

やりたいことはthisにGoogle assistantから「メモをツイートして」とコマンドすれば、thatからメモをツイートするスクリプト実行することですね。

余談ですが、ステータスコードは200ではなく302が返されます。iftttのWebhookから呼び出すとアクティビティログにワーニング表示がされてしまいますが、そういうもんだと思うしかないようです。レスポンスBodyについては自分で好きなように生成できます。


スクリプトの安全性

自分のアカウントでツイートするボタンが Web で堂々と公開されているのは安全性としてどうなのよと思うところはあります。

API の URL がスクリプトの ID から決定されるので、スクリプトID がばれてしまったら外部から叩き放題ですね。

自分一人でひっそりと使っているスクリプトなので問題が起きても被害を受けるのは自分だけで済みますが、トラブルは避けられるものなら避けたいですね。

gas にはPropertiesServiceという機能があって、任意の値を自由に記録読み出しできます。

ここではユーザプロパティに何らかの暗号を設定して Post パラメータから渡ってくるキーと一致していれば許可されたユーザとして処理することにします。この仕組みがどれほど安全性を高めてくれるかは疑問が残ってますので、良い方法があれば教えて下さい。

※スクリプトの実行を自分のIDで呼び出しているのだからユーザプロパティは常に自分のものを参照するはずで、まったく意味のない施策ですね。

今回の最大ハマりポイントのですが、ファイルメニューのプロジェクトのプロパティから表示されるユーザのプロパティページは名前から期待する通りに動作してくれません。スクリプトのプロパティはUIから問題なく記録できて、それをスクリプトから呼び出しできるので非常に紛らわしいです。

キャプチャ.PNG

※ここでどれだけ設定してもgetUserProperties().getProperty("GAS_API_SECRET")はかなわない

UserPropertiesを利用したい場合、必ずsetProperty関数から 値を設定しなければなりません。その処理でキーをコードに生で書くのはあまりにも気持ち悪いので、次のような関数を用意して事前に実行しておくのが良いでしょう。


userprop.gs

function setUserProp() {

var up = Browser.inputBox('GAS_API_SECRET');
if (up == 'cancel') up = null;
PropertiesService.getUserProperties().setProperty("GAS_API_SECRET", up);
}

setUserProp.PNG

後は API を起動する Webhooksに秘密の文字列twitter apiのsecret? を添えて Post するようにすれば完成です。

webhookwithsecret.PNG

bodyはJSON 形式で記述します。content Type には application/json を選択してください。


tweet.gs

var API_KEY = PropertiesService.getScriptProperties().getProperty("TWITTER_API_KEY");

var API_SECRET = PropertiesService.getScriptProperties().getProperty("TWITTER_API_SECRET");
var SHEET_ID = PropertiesService.getScriptProperties().getProperty("SHEET_ID");
var SECRET = PropertiesService.getUserProperties().getProperty("GAS_API_SECRET");

function setUserProp() {
var up = Browser.inputBox('GAS_API_SECRET');
if (up == 'cancel') up = null;
PropertiesService.getUserProperties().setProperty("GAS_API_SECRET", up);
}

// 認証用インスタンス
function getService() {
return OAuth1.createService('Twitter')
.setAccessTokenUrl('https://api.twitter.com/oauth/access_token')
.setRequestTokenUrl('https://api.twitter.com/oauth/request_token')
.setAuthorizationUrl('https://api.twitter.com/oauth/authorize')
.setConsumerKey(API_KEY)
.setConsumerSecret(API_SECRET)
.setCallbackFunction('authCallback')
.setPropertyStore(PropertiesService.getUserProperties());
}

// 認証
function authorize() {
var twitter = getService();
Logger.log(twitter.authorize());
}

// 認証解除
function reset() {
twitter.reset();
}

// 認証後のコールバック
function authCallback(request) {
var service = getService();
var authorized = service.handleCallback(request);
if (authorized) return HtmlService.createHtmlOutput('認証成功');
}

// ツイートを投稿
function postUpdateStatus(message) {
var res = null;
var service = getService();
if (service.hasAccess()) {
var response = service.fetch('https://api.twitter.com/1.1/statuses/update.json', {
method: 'post',
payload: { status: message }
});
var status = response.getResponseCode();
if (status == 200) {
res = JSON.parse(response.getContentText());
// Logger.log(JSON.stringify(res, null, 2));
} else {
Logger.log(status);
}
}
return res;
}

// ツイートしたいメッセージを記入するシートをゲットする
function getSheet() {
try {
var ss = SpreadsheetApp.openById(SHEET_ID);
return ss.getSheetByName("messages");
} catch(e) {
Logger.log("Bad id");
}
return null;
}

// シートの B 列に記入された全ての文字列を配列にして返す
function getMessages(sheet) {
var rows = sheet.getLastRow();
if (rows < 1) {
return [];
} else {
var values = sheet.getRange(1,2,rows).getValues();
var messages = [];
for (var row = 0; row < values.length; row++) {
messages.push(values[row][0]);
// Logger.log(messages[messages.length-1]);
}
return messages;
}
}

// 配列で受け取った文字列内の余分なスペースを取り除く
function strippedMessages(messages) {
return messages.map(function (value, index, array) { return value.replace(/\s+/g, "")});
}

// 配列で受け取った文字列に句点を加えながら140文字を超えないように結合していく
function joinMessages(messages) {
var res = [];
var msg = "";
for each (var item in messages) {
var work = msg + item + "。";
// Logger.log(work.length);
if (work.length > 140) {
res.push(msg);
var msg = "";
msg = item;
} else {
msg = work;
}
}
res.push(msg);
return res;
}

// シートの B 列に配列で受け取ったツイート文字列を上書きしていく
function putMessages(sheet, messages) {
if (messages.length < 1) return;
var range = sheet.getRange(1,2,messages.length);
var values = [];
for(var i = 0; i < messages.length; i++){ values.push([messages[i]]) };
range.setValues(values);
}

// 配列で受け取ったツイート内容を順番に投稿する
function postMessages(messages) {
var res = null;
var left = [];
for(var i = 0; i < messages.length; i++) {
res = postUpdateStatus(messages[i]);
Logger.log(messages[i]);
if (!res) {
left.push(messages[i]);
Logger.log("failed");
}
};
return left;
}

function getPostParameters(ev) {
var params;
try {
params = JSON.parse(ev.postData.getDataAsString());
} catch(e) {
params = {};
}
return params;
}

function validParamsSecret(params) {
Logger.log(SECRET);
if (!params.GAS_API_SECRET) return '003 bad request';
if (SECRET != params.GAS_API_SECRET) return '003 invalid user';
return null;
}

// Post メソッドでリクエストされた時に起動する関数
function doPost(e) {
var res;
if (res = validParamsSecret(getPostParameters(e))) {
Logger.log(res);
return makeContent(res);
}
// メッセージを貯めるシートからツイートするメッセージを取得する
var sheet = getSheet();
if (!sheet) {
res = '001 messages sheet is not found';
Logger.log(res);
return makeContent(res);
}
var messages = getMessages(sheet);
if (messages.length < 1) return;
// メッセージの無駄な空白を取り除く
var smsg = strippedMessages(messages);
// メッセージをできるだけ140文字に収まるように結合する
var jmsg = joinMessages(smsg);
// 出来上がったツイートを投稿 送信に失敗したメッセージが返ってくる
var rmsg = postMessages(jmsg);
// 一度シートをクリアしてから投稿に失敗したメッセージをシートに戻す
sheet.clear();
putMessages(sheet, rmsg);
return makeContent('post ok');
}

function test() {
var sheet = getSheet();
var messages = getMessages(sheet);
var smsg = strippedMessages(messages);
var jmsg = joinMessages(smsg);
sheet.clear();
putMessages(sheet, jmsg);

for each (var item in jmsg) {
Logger.log(item);
}
}

function makeContent(content) {
return ContentService.createTextOutput(JSON.stringify({'content': content})).setMimeType(ContentService.MimeType.JSON);
}



最後に

これでスプレッドシートに貯められたメッセージを Google Home から一気にツイートすることが可能になりました。

これで音声入力を使った Twitter 利用が比較的少ない手順でなるべく正確に楽しめるようになりました。

Twitter API のキーやメモを取るスプレッドシートのIDなども Post パラメータで渡した方がいいかもしれませんね。

スクリプトプロパティはマジックナンバー的なものを記憶するためのものと考えるべきなのかも。正式に運用する場合はそのように変更すると思います