Slack Platformを使いワークフローからnotionのページ作成



## 概要
新しいSlack Platformを使いワークフローからNotionのページを作成します。
notionのAPIを使う部分はCustom Function(カスタムステップ)を使います。
Notion APIの使い方についてはこの記事では扱いません。
https://developers.notion.com/ を参考にしてからAPI用のシークレットトークンとデータベースのIDを取得しておいてください。



### はじめに
クイックスタートからサンプルのアプリ実行まで紹介しているので、一通り実行するとわかりやすいと思います。
https://api.slack.com/automation/quickstart


1. Slack CLIの導入, 承認
https://api.slack.com/automation/quickstart#install-cli
詳しくは省略します。`slack auth list`でワークスペースの情報が表示されるまで確認してください。

   

2. スターターテンプレートと共にアプリ作成

```bash
$ slack create slack-to-notion --template https://github.com/slack-samples/deno-starter-template
```

作成するとfunctions, triggers, workflowsのフォルダがあると思いますが、これら3つがSlack アプリの主な構成要素となります。

各機能のざっくりとした説明は下記の通り

### functions
Custom functions: https://api.slack.com/automation/functions/custom
自作のfunctionを作成する際はこちらのディレクトリの下で作成します。
Inputs(入力), outputs(出力)を定義して、SlackFunction内でやりたいことを実装します。
ワークフローのカスタムステップを作成する場合はこのCustom functionsを利用します。

### triggers
https://api.slack.com/automation/triggers
ワークフローの起動方法をこちらで決めます。
サンプルはLink triggerでパプリックチャンネルからワークフローを起動します。
日付指定で実行する Scheduled triggerもあります。

### workflows
https://api.slack.com/automation/workflows
functionを組み合わせて順次実行するワークフローを作成します。
カスタムステップを使う場合もこのワークフローにステップを追加します。

### 他の機能
#### datastores
https://api.slack.com/automation/datastores
アプリ内のデータを保存するデータベース。
今回は使用しません。

### 環境変数
https://api.slack.com/automation/environment-variables
.envがデフォルトで使えるためnotionのシークレットトークンとデータベースのIDを記述しておきます。
```
DATABASE_ID=XXX
NOTION_TOKEN=XXX
```

### デプロイ
`slack deploy` でデプロイ
環境変数を使っている場合下記のように追加する必要がある。
```
$ slack env add MY_ENV_VAR asdf1234
```

## ローカル実行
`slack run`でローカル環境の動作が確認できます。
ワークスペースを選択したら、ショートカット用のURLが表示されるので、slackチャンネルの上部の「＋ 関連ページを追加する」から表示されたリンクにURLを入力します。

`https://slack.com/shortcuts/XXXX`
![スクリーンショット 2023-10-04 13.50.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1734199/10490c06-29b0-c3d8-77b9-b316e0d020b1.png)
![スクリーンショット 2023-10-04 13.50.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1734199/5c4ebdeb-0f75-32d0-a8f4-f4e027463ec5.png)

## 作成したもの
ワークフロー上でモーダルを表示してステータス、タイトル、概要、内容を入力します。
ボタンを押すとnotionのDBにページが作成され、成功したらSlackのチャンネルにメッセージを送信します。


## 実際のコード

<details><summary>notion_workflow.ts</summary>

```typescript
import { DefineWorkflow, Schema } from "deno-slack-sdk/mod.ts";
import { NotionFunctionDefinition } from "../functions/notion_function.ts";
import Status from "../status.ts";

/// Workflow定義
const notionWorkflow = DefineWorkflow({
  callback_id: "request_workflow",
  title: "notionページ作成",
  description: "notionページ作成",
  input_parameters: {
    properties: {
      interactivity: {
        type: Schema.slack.types.interactivity,
      },
      channel_id: {
        type: Schema.slack.types.channel_id,
      },
      user_id: {
        type: Schema.slack.types.user_id,
      },
    },
    required: ["user_id", "interactivity"],
  },
});

const inputForm = notionWorkflow.addStep(
  // モーダル表示
  Schema.slack.functions.OpenForm,
  {
    title: "notionへの投稿",
    interactivity: notionWorkflow.inputs.interactivity,
    submit_label: "作成",
    fields: {
      elements: [
        {
          name: "status",
          title: "ステータス",
          type: Schema.types.string,
          enum: Object.values(Status),
          choices: [
            {
              value: Status.New,
              title: Status.New,
              description: "",
            },
            {
              value: Status.InProgress,
              title: Status.InProgress,
              description: "",
            },
            {
              value: Status.Closed,
              title: Status.Closed,
              description: "",
            },
          ],
        },
        {
          name: "title",
          title: "タイトル",
          type: Schema.types.string,
          description: "記事のタイトル",
        },
        {
          name: "summary",
          title: "概要",
          type: Schema.types.string,
          long: true,
          description: "記事の概要",
        },
        {
          name: "content",
          title: "内容",
          type: Schema.types.string,
          long: true,
          description: "記事の内容",
        },
      ],
      required: ["status", "title"],
    },
  },
);

/// notionに送信
const requestFunctionStep = notionWorkflow.addStep(NotionFunctionDefinition, {
  user_id: notionWorkflow.inputs.user_id,
  status: inputForm.outputs.fields.status,
  title: inputForm.outputs.fields.title,
  summary: inputForm.outputs.fields.summary,
  content: inputForm.outputs.fields.content,
});

// Slackメッセージ送信
notionWorkflow.addStep(
  Schema.slack.functions.SendMessage,
  {
    channel_id: notionWorkflow.inputs.channel_id,
    message: requestFunctionStep.outputs.result_message,
  },
);

export default notionWorkflow;

```

**タイトルのプロパティはnotionのDBのtitleと同じプロパティ名にする必要があります**
</details>

<details><summary>notion_trigger.ts</summary>

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

const notionTrigger: Trigger<typeof notionWorkflow.definition> = {
  type: TriggerTypes.Shortcut,
  name: "notionのページ作成",
  description: "notionのページ作成トリガー",
  workflow: `#/workflows/${notionWorkflow.definition.callback_id}`,
  inputs: {
    interactivity: {
      value: TriggerContextData.Shortcut.interactivity,
    },
    channel_id: {
      value: TriggerContextData.Shortcut.channel_id,
    },
    user_id: {
      value: TriggerContextData.Shortcut.user_id,
    },
  },
};

export default notionTrigger;
```
</details>

<details><summary>notion_function.ts</summary>

```typescript
import { DefineFunction, Schema, SlackFunction } from "deno-slack-sdk/mod.ts";

export const NotionFunctionDefinition = DefineFunction({
  callback_id: "notion_function",
  title: "notionへの投稿",
  description: "notionへの投稿",
  source_file: "functions/notion_function.ts",
  input_parameters: {
    properties: {
      user_id: {
        type: Schema.slack.types.user_id,
        description: "ユーザーID",
      },
      status: {
        type: Schema.types.string,
        description: "ステータス",
      },
      title: {
        type: Schema.types.string,
        description: "タイトル",
      },
      summary: {
        type: Schema.types.string,
        description: "概要",
        long: true,
      },
      content: {
        type: Schema.types.string,
        description: "内容",
      },
    },
    required: ["user_id", "status", "title"],
  },
  output_parameters: {
    properties: {
      notion_page_id: {
        type: Schema.types.string,
        description: "作成したnotionページのID",
      },
      result_message: {
        type: Schema.types.string,
        description: "結果のメッセージ",
      },
    },
    required: ["result_message"],
  },
});

function postNotion(notionToken: string, blockJson: string) {
  const url = "https://api.notion.com/v1/pages";
  const options = {
    "method": "POST",
    "headers": {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + notionToken,
      "Notion-Version": "2022-06-28",
    },
    "body": blockJson,
  };
  return fetch(url, options);
}

/// notionページに反映する内容
interface Request {
  userName: string;
  status: string;
  title: string;
  summary?: string;
  content?: string;
}

type SlackUser = {
  id: string;
  real_name: string;
};

type NotionResponse = {
  id: string;
  url: string;
};

export default SlackFunction(
  NotionFunctionDefinition,
  async ({ inputs, env, client }) => {
    let userName: string;

    // Slackユーザー情報取得
    try {
      const info = await client.users.info({
        user: inputs.user_id,
      });

      if (!info.ok) {
        throw new Error(info.error);
      }

      // Slackユーザー名設定
      const user = info.user as SlackUser;
      userName = user.real_name;
    } catch (error) {
      const resultMessage =
        `Slack APIからのエラーメッセージ:\n${error.message}`;
      return { outputs: { result_message: resultMessage } };
    }

    const request: Request = {
      userName: userName,
      status: inputs.status,
      title: inputs.title,
      summary: inputs.summary,
      content: inputs.content,
    };

    const blockJson = createBlockObjects(env["DATABASE_ID"], request);

    let resultMessage: string;

    try {
      // notionへの投稿
      const response = await postNotion(env["NOTION_TOKEN"], blockJson);
      if (!response.ok) {
        throw new Error(response.statusText);
      }

      const json = await response.json() as NotionResponse;
      resultMessage = `notionのページ: ${json.url}`;
    } catch (error) {
      resultMessage = `notionAPIからのエラーメッセージ:\n${error.message}`;
    }

    return {
      outputs: { result_message: resultMessage },
    };
  },
);

function createBlockObjects(databaseId: string, request: Request): string {
  const propertyBlock = (
    propertyName: string,
    type: string,
    content: string,
  ) => {
    return {
      [propertyName]: {
        [type]: [
          {
            "text": {
              "content": content,
            },
          },
        ],
      },
    };
  };

  const objectBlock = (type: string, content: string) => {
    return {
      "object": "block",
      "type": type,
      [type]: {
        "rich_text": [
          {
            "type": "text",
            "text": {
              "content": content,
            },
          },
        ],
      },
    };
  };

  const payload = {
    "parent": {
      "type": "database_id",
      "database_id": databaseId,
    },
    "properties": {
      ...propertyBlock("タイトル", "title", request.title),
      "ステータス": {
        "select": {
          "name": request.status,
        },
      },
    },
    "children": [
      objectBlock("heading_2", "概要"),
      objectBlock("paragraph", request.summary ?? ""),
      objectBlock("heading_3", "内容"),
      objectBlock("paragraph", request.content ?? ""),
    ],
  };
  return JSON.stringify(payload);
}

```
</details>


manifest.ts
```typescript
import { Manifest } from "deno-slack-sdk/mod.ts";
import notionWorkflow from "./workflows/notion_workflow.ts";

export default Manifest({
  name: "slack-to-notion",
  description: "Slackからnotionページ作成",
  icon: "assets/default_new_app_icon.png",
  workflows: [notionWorkflow],
  outgoingDomains: ["api.notion.com"],
  botScopes: [
    "commands",
    "chat:write",
    "chat:write.public",
    "users:read",
  ],
});

```
**outgoingDomainsに"api.notion.com"を指定しないとnotionのAPIにアクセスできないので注意**


status.ts

```typescript
enum Status {
  New = "新規",
  InProgress = "進行中",
  Closed = "完了",
}

export default Status
```

## 動作について

#### モーダル
![スクリーンショット 2023-10-04 13.01.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1734199/d079ae90-a482-f939-825e-39de6c1a1c1d.png)

#### Slackメッセージ
![スクリーンショット 2023-10-04 13.20.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1734199/d46e7a8c-4dfb-053b-1a81-d31372c978b2.png)

#### 作成したnotionページ
![スクリーンショット 2023-10-04 13.04.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1734199/6f3aeb0a-cdfa-2b64-f0e9-f205540dce5e.png)


## Custom funtionについて
上記の例ではワークフローごと作成して、最後にSlackメッセージを送信するものまで作成しましたが
Custom funtionではnotionの接続部分だけ作成することもでき、自作のワークフローに組み込むことができます。

![スクリーンショット 2023-10-04 15.32.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1734199/99c4a636-9ebe-3747-ce80-1a1dc563e168.png)