Scala.jsでGoogle Apps Scriptを書いてみよう

8 min read読了の目安(約7200字

はじめに

あのプログラミング言語だったらこんなことするのラクなのに〜 を Scala でやってみる第三弾です。
Scala やっててこんなこと思ったりしませんか?

  • あー、Google Apps Script も Scala で書きたい
  • TypeScript のライブラリを Scala からも活用したい

それいい感じにできるソリューションあります!

完成品はコチラ ignission/scalajs-google-apps-script-facade

その他シリーズの記事はコチラ:

要約

  • Scala.js を使えば Google Apps Script だって書けちゃう
  • scala-js-ts-importer を使うことで、TypeScript の型定義を基に Scala.js から使えるようになる(変換後のものを facade と呼ぶらしい)
  • TypeScript の型定義がなくても手作業で作れる

説明

今回はせっかくなので、facade の作り方と実際の使い方について分けてみようと思います。

共通編

Scala.js を使用するので、以下のように sbt plugin を追加します。

project/plugins.sbt
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.5.1")

plugin を有効化します。

build.sbt
enablePlugins(ScalaJSPlugin)

facade を作る

まず、以下のものを用意します。

あとは scala-js-ts-importer の README に従って、コマンドを実行するだけで OK です。

sbt 'run some-lib.d.ts SomeLib.scala'

出来上がったSomeLib.scalaを、src/main/scalaディレクトリ以下に配置します。
私の場合、d.tsファイルが多かったので 雑なスクリプト を書きました(シェルスクリプト弱者なので...)

あとは生成済みの Scala コードをライブラリ化して maven 等に publish すれば、通常のライブラリ同様扱うことができます。
例:

build.sbt
libraryDependencies += "tech.ignission" %%% "google-apps-script-scalajs-facade" % "0.5.0"

package の命名に悩んだのですが、aws-sdk-scalajs-facade を参考に、facade.googleappsscriptから始まるようにしました。

facade を利用して簡単な bot を作ってみる

今回は Typetalk に投稿すると、その内容を Google Sheets に追記していき、リストを返す bot を作ってみます。
Main のソースコードはコチラ

clasp のインストール

clasp は Google Apps Script をコマンドラインから操作できるライブラリです。

npm install -g @google/clasp

Google Sheets の作成

clasp のインストールが終わったら、実際にスクリプトを実行するために Google Sheets を作成します。
--typesheetsを指定していますが、Google Sheets が必要なかったらwebappとしても OK です。

clasp create --type sheets --rootDir ./dist

appsscript.json

appsscript.json はスクリプトのマニフェストのことで、様々な設定を記述できます。公式ドキュメントはコチラ
oauthScopes はスクリプトに必要な承認スコープを定義します。外部との通信をする場合は https://www.googleapis.com/auth/script.external_request、Google Sheets を使用する場合は https://www.googleapis.com/auth/spreadsheets が必要です。

example/dist/appsscript.json
{
  "timeZone": "America/New_York",
  "dependencies": {
  },
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "webapp": {
    "access": "ANYONE_ANONYMOUS",
    "executeAs": "USER_DEPLOYING"
  },
  "oauthScopes": [
    "https://www.googleapis.com/auth/script.external_request",
    "https://www.googleapis.com/auth/spreadsheets"
  ]
}

試しにデプロイしてみる

一旦デプロイできるかチェックしましょう。
最小限の Scala コードを書きます。@JSExportTopLeveldoPostを指定しているので、デプロイ後にスクリプトに対して発行された Post リクエストを処理できるようになります。

example/src/main/scala/Main.scala
import facade.googleappsscript.DoPost
import facade.googleappsscript.GoogleAppsScript.ContentService
import facade.googleappsscript.content.{MimeType, TextOutput}

object Main {
  @JSExportTopLevel("doPost")
  def doPost(e: DoPost): TextOutput = {
    ContentService
      .createTextOutput(e.postData.contents)
      .setMimeType(MimeType.JSON);
  }
}

デプロイコマンドを実行します。

sbt example/deploy

ちなみにデプロイ周りを省力化できるように、sbt task をちょっと工夫してます。sbt に慣れてないのでツッコミどころ多いけどいろいろ改善できそうです。
通常であれば、clasp で push して Script Editor 上でデプロイをポチポチしないといけなくてすごく手間に感じました。

project/Clasp.scala
  def deploy(cwd: String): Unit = {
    val logger = new Logger
    val dir    = new File(cwd)

    Process("clasp deployments", dir) ! logger.log
    logger.print()

    if (logger.out.length <= 2) {
      logger.flush()
      Process("clasp deploy", dir) ! logger.log
      logger.print()
    }

    val deploymentRow = logger.out(logger.out.length - 1)
    val deployId      = deploymentRow.split(' ')(1)

    logger.flush()

    val deployCmd = s"clasp deploy -i $deployId"
    Process(
      Seq("clasp", "deploy", "-i", deployId, "-d", "deployed from sbt"),
      dir
    ) ! logger.log
    logger.print()
  }

この sbt task では、deployment 一覧を取得してなければ作成、そして最後の deployment に対して再度デプロイというステップを自動でやってます。手前味噌ですがすごく便利です。

スクリプトに権限を与える(初回のみ)

デプロイして発行された URL にアクセスしたらエラーになってしまってハマったんですけど、最初の 1 回だけスクリプトに権限を与える必要があります。

力技で承認画面を表示させるために、Script Editor を開いて以下の関数を記述します。

main.gs
function test() {
  return ""
}

スクリプト追加例
スクリプト追加例

次にtestメソッドを選択して、Runをクリックします。

Review permissionsをクリックして、問題なさそうだったら承認します。

モデルの定義

Typetalk から Outgoing Webhook がスクリプトに飛んでくるのですが、JSON 形式のため Scala で扱えるようにモデルを定義します。

example/src/main/scala/Main.scala
@js.native
trait PostData extends js.Object {
  val post: Post   = js.native
  val topic: Topic = js.native
}

@js.native
trait Post extends js.Object {
  val id: js.Any       = js.native
  val message: String  = js.native
  val account: Account = js.native
}

@js.native
trait Topic extends js.Object {
  val id: js.Any   = js.native
  val name: String = js.native
}

@js.native
trait Account extends js.Object {
  val id: js.Any   = js.native
  val name: String = js.native
}

これらのモデルがあれば、以下のようにして JSON を parse できます。

val postData = JSON.parse(e.postData.contents).asInstanceOf[PostData]

次にレスポンスデータの定義をします。こちらは Scala からインスタンス生成を行う必要があるので、@js.nativeアノテーションがついていないことにご注意ください。

trait TypetalkReply extends js.Object {
  val message: String
  val replyTo: js.Any
}

このようにしてインスタンス生成できます。

val reply = new TypetalkReply {
  val message: String = contents.mkString("\n")
  val replyTo: js.Any = data.post.id
}

JSON に変換できます。

val jsonString = JSON.stringify(reply)

動かしてみる

Script Editor からwebappの URL を取得して、Typetalk Bot を作ってみましょう。
Bot にメンションすると、その内容を Google Sheets に記録して、登録済みのリストを返します。
まったく使いどころがありませんね!

動作イメージ
寿司食べたい!

おわりに

これで Google Apps Script だって Scala で書けるので安心ですね!
コード書いてコンパイルしてデプロイまで一貫して行えるので、ストレスなく開発できるのではないでしょうか。
どうしてもjs.Anyとして表現せざるを得ない場面もありxxx.asInstanceOf[A]といったキャストをしないといけないのが気になりますが、まぁ仕方ないかと割り切っています(そうですよね)。
Bot を作ってみてもいいし、Google Sheets 以外とも連携させてもいいし、是非活用してみてください。

Scala って面白そうだなって思ってもらえると嬉しいです!

参考