Google Calendar の予定をつくるSlack ワークフロー（無料）をコードで表現する

こんにちは、Slack の[公式 SDK 開発](https://github.com/slackapi)と日本の Developer Relations を担当している瀬良 (@seratch) と申します :wave:

先日の記事でスタンダードワークフロー（無料）をコードで管理する方法について紹介しました。

https://qiita.com/seratch/items/27b33d82c96d715f59cd

その記事ではカバーできなかったコネクターステップを一つだけ使った無料ワークフロー（注：コネクターステップは一つだけ組み込む分には無料のままです。二つ目以降を追加すると従量課金のプレミアムワークフローになります）の定義方法について紹介していきます。

### 今回作るもの

今回は Google Calendar のコネクターステップを一つ組み込んだスタンダードワークフローを作ってみましょう。ワークフロービルダーで作る場合、ワークフローの全体像は以下のようになります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/f78995a6-57b7-a7d0-1fbc-a0a98b472cbd.png" width=500 />

コネクターステップを利用しているところは以下のような設定になっています。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/0743bd4d-b91e-4023-1855-df6a70b3c67c.png" width=400 />

このワークフローを使って Google Calendar に予定をつくってみましょう。リンクトリガーをクリックすると、以下のようなモーダルダイアログが開きますので、情報を入力します。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/23babba0-4cd2-c33d-a31c-ea84cc0ad6b7.png" width=400 />

送信すると、Google Calendar 側で予定が作られ、その URL を Slack チャンネルにシェアします。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/941547f3-c3b4-9063-314f-8d67c608ce4b.png" width=500 />

Google Calendar の UI 上だと参加者の予定も見られるので、もしもこのまま使うとしたら「普通に Google Calendar を開いてやった方が便利なのでは・・・？」という感じではあります。しかし、他のステップから受けた入力を使って予定を作るといったユースケースであればうまくハマることもあるでしょうし、今後、Slack のワークフロー基盤により便利なステップが追加されたときに組み合わせることもできるでしょう。

ということで、今回はこのワークフローをコードで表現してみることにします。

### 新しいプロジェクトを作る

Slack CLI で `slack create my-connectors` を実行して「Blank template」を選択してください。

```
$ slack create my-connectors
? Select a template to build from: Blank template
$ cd my-connectors/
```

### コネクターを参照できるようにする

コネクターステップの定義は、以下の Deno モジュールとして提供されていますが、

* https://github.com/slackapi/deno-slack-hub
* https://deno.land/x/deno_slack_hub

デフォルトのテンプレートでは参照が含まれていないので、追加します。

`import_map.json` というファイルを開いて `deno-slack-hub/` を追加します。各モジュールはこの記事投稿時点での最新バージョンですが、最新のものを使うようにしてください。

```json
{
  "imports": {
    "deno-slack-sdk/": "https://deno.land/x/deno_slack_sdk@2.5.0/",
    "deno-slack-api/": "https://deno.land/x/deno_slack_api@2.1.2/",
    "deno-slack-hub/": "https://deno.land/x/deno_slack_hub@0.1.0/"
  }
}
```

これで準備ができたのでワークフローとトリガーの定義を追加します。

### ワークフローを定義する

`workflows/` というディレクトリを作って、そこに `google_calendar.ts` というファイルを作り、以下の内容を貼り付けてください。

```typescript
import { DefineWorkflow, Schema } from "deno-slack-sdk/mod.ts";
import { Connectors } from "deno-slack-hub/mod.ts";

export const workflow = DefineWorkflow({
  callback_id: "Google-Calendar-Workflow",
  title: "Google Calendar コネクターを一つだけ無料で使う例",
  input_parameters: {
    properties: {
      interactivity: { type: Schema.slack.types.interactivity },
      user_id: { type: Schema.slack.types.user_id },
      channel_id: { type: Schema.slack.types.channel_id },
    },
    required: ["interactivity", "user_id", "channel_id"],
  },
});

const form = workflow.addStep(Schema.slack.functions.OpenForm, {
  title: "新しい予定を作成する",
  interactivity: workflow.inputs.interactivity,
  fields: {
    elements: [
      {
        name: "summary",
        title: "タイトル",
        type: Schema.types.string,
      },
      {
        name: "start_time",
        title: "開始日時",
        type: Schema.slack.types.timestamp,
      },
      {
        name: "end_time",
        title: "終了日時",
        type: Schema.slack.types.timestamp,
      },
      {
        name: "attendees",
        title: "参加者",
        type: Schema.types.array,
        items: { type: Schema.slack.types.user_id },
        default: [workflow.inputs.user_id],
      },
      {
        name: "location",
        title: "場所",
        type: Schema.types.string,
      },
      {
        name: "description",
        title: "概要",
        type: Schema.types.string,
        long: true,
      },
    ],
    required: ["summary", "start_time", "end_time", "attendees"],
  },
});

const gcal = workflow.addStep(Connectors.GoogleCalendar.functions.CreateEvent, {
  google_access_token: {
    credential_source: "END_USER",
  },
  summary: form.outputs.fields.summary,
  start_time: form.outputs.fields.start_time,
  end_time: form.outputs.fields.end_time,
  attendees: form.outputs.fields.attendees,
  location: form.outputs.fields.location,
  description: form.outputs.fields.description,
});

workflow.addStep(Schema.slack.functions.SendMessage, {
  channel_id: workflow.inputs.channel_id,
  message:
    `:white_check_mark: <@${workflow.inputs.user_id}> が新しい予定「${form.outputs.fields.summary}」を作成しました！ :point_right: ${gcal.outputs.event_link}`,
});
```

### トリガーを定義する

このワークフローを起動するためのトリガーを定義しましょう。`triggers/` というディレクトリを作って、そこに `google_calendar.ts` というファイルを以下の内容で配置します。

```typescript
import { Trigger } from "deno-slack-sdk/types.ts";
import { TriggerContextData, TriggerTypes } from "deno-slack-api/mod.ts";
import { workflow } from "../workflows/google_calendar.ts";

const trigger: Trigger<typeof workflow.definition> = {
  type: TriggerTypes.Shortcut,
  name: "Google Calendar の予定を作成する",
  description: "Slack から Google Calendar の予定を作成することができます",
  workflow: `#/workflows/${workflow.definition.callback_id}`,
  inputs: {
    interactivity: { value: TriggerContextData.Shortcut.interactivity },
    user_id: { value: TriggerContextData.Shortcut.user_id },
    channel_id: { value: TriggerContextData.Shortcut.channel_id },
  },
};

export default trigger;
```

### `manifest.ts` を更新する

先ほど定義したワークフローを有効にするために `manifest.ts` を編集します。

```typescript
import { Manifest } from "deno-slack-sdk/mod.ts";
import { workflow as GoogleCalendar } from "./workflows/google_calendar.ts";

export default Manifest({
  name: "My Connectors",
  description:
    "Demonstrating how to use a Connector step in a standard workflow",
  icon: "assets/default_new_app_icon.png",
  workflows: [GoogleCalendar],
  botScopes: [
    "commands",
    "chat:write",
    "chat:write.public",
  ],
});
```

これで準備は完了です。

### アプリをローカル起動してみる

準備ができたので `slack run` でアプリを起動してみてください。

接続しているワークスペースがアプリインストールに管理者の承認を必須としていて（普通はそうだと思います）、かつ Google Calendar のコネクターがまだ承認されていない場合、CLI が承認リクエストを送信するよう促してくるので、リクエストを送信して管理者の方に確認するようお願いしてください。

コネクターが承認されれば、そのままアプリは起動するようになるはずです。

CLI からトリガーを作成するよう促されますので、先ほど定義したリンクトリガーを作成して、その URL を Slack でシェアしてクリックします。

ワークフローが上のワークフロービルダーで作ったものと同様に動作することが確認できるはずです。

### メリット・デメリット

コードで管理されたコネクターステップのメリット・デメリットを整理しておきたいと思います。メリットは、以下の通りです。

* GitHub などで管理できるので通常のソフトウェア開発のプロセスに乗せられる（ユニットテスト；PR レビュー、デプロイの自動化など；詳細は前回の記事を参照）
* ワークフロービルダーではできない初期値の設定などが可能となる場合がある（今回のケースだと参加者にあらかじめ自分自身が追加されています）
* Slack CLI でエラーログの確認などの運用が可能となる
* ワークスペースを跨いだ再利用が容易にできる

デメリットは、プラットフォームの技術面での理解が求められること、コードを書かないといけないこと以外に、今回のケース固有のデメリットもあります。

* 一部、ワークフロービルダーでは可能なことができない（今回だと開始・終了日時をヒューマンリーダブルな形で文字列に埋めることがコード側ではできません）

今回の例、最後に投稿しているチャンネルメッセージが簡素だな？と思った方もいるかもしれません。実はこれはこの記事投稿時点で存在しているいくつかの制約から断念している結果です。

* コードだとメッセージテキストに日時を UNIX タイムスタンプ以外の形式で埋め込むことができない（WFB だと言語設定などの改善点はあるもののある程度できる）
* WFB もコードもメッセージテキストに参加者の一覧をメンション付きで埋め込むことができない（WFB は既知の不具合、コード側はそのような記法にまだ対応していない）

これらの件、担当チームは認識はしておりますので、（具体的な時期は未定ですが）将来改善されるはずです。


### おまけ: Giphy

日本語だと使いにくかったりもしますが Giphy のサンプルも置いておきます。

```typescript
import { DefineWorkflow, Schema } from "deno-slack-sdk/mod.ts";
import { Connectors } from "deno-slack-hub/mod.ts";

export const workflow = DefineWorkflow({
  callback_id: "Giphy-Workflow",
  title: "Giphy コネクターを一つだけ無料で使う例",
  input_parameters: {
    properties: {
      interactivity: { type: Schema.slack.types.interactivity },
      user_id: { type: Schema.slack.types.user_id },
      channel_id: { type: Schema.slack.types.channel_id },
    },
    required: ["interactivity", "user_id", "channel_id"],
  },
});

const form = workflow.addStep(Schema.slack.functions.OpenForm, {
  title: "Giphy の URL を投稿する",
  interactivity: workflow.inputs.interactivity,
  fields: {
    elements: [
      { name: "text", title: "検索ワード", type: Schema.types.string },
    ],
    required: ["text"],
  },
});

const gif = workflow.addStep(
  Connectors.Giphy.functions.GetTranslatedGif,
  {
    search_term: form.outputs.fields.text,
    weirdness: "0",
  },
);

workflow.addStep(Schema.slack.functions.SendMessage, {
  channel_id: workflow.inputs.channel_id,
  message:
    `<@${workflow.inputs.user_id}> が「${form.outputs.fields.text}」の GIF を投稿しました！\n${gif.outputs.gif_title_url}`,
});
```

```typescript
import { Trigger } from "deno-slack-sdk/types.ts";
import { TriggerContextData, TriggerTypes } from "deno-slack-api/mod.ts";
import { workflow } from "../workflows/giphy.ts";

const trigger: Trigger<typeof workflow.definition> = {
  type: TriggerTypes.Shortcut,
  name: "Giphy の URL を投稿する",
  description: "Giphy の URL を投稿する",
  workflow: `#/workflows/${workflow.definition.callback_id}`,
  inputs: {
    interactivity: { value: TriggerContextData.Shortcut.interactivity },
    user_id: { value: TriggerContextData.Shortcut.user_id },
    channel_id: { value: TriggerContextData.Shortcut.channel_id },
  },
};

export default trigger;
```

もし該当する GIF がなかった場合、画像なしでメッセージが投稿されてしまうのがあんまりイケてないですが、スタンダードワークフローとして利用する場合はやむをえない制約となります。。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/97a30b54-e040-801a-aeb0-75bf2b207ac2.png" width=400 />

### 終わりに

いかがだったでしょうか？

コードで表現する場合、ビルダー画面を使う場合、それぞれに一長一短はありますが、**コードでも管理できる**ということを知っておくと、いつか役に立つかもしれません。

また、言うまでもなく「**プレミアムワークフローにコネクターステップとカスタムステップを両方含めたい**」という場合には、このように全てをコードで表現することは最も自然な形になると思います（カスタムステップを `manifest.ts` の `functions` に追加して、WFB から利用可能にすることももちろん可能です）。

ぜひ快適なワークフロー活用を楽しんでみてください。それでは！ :rocket: