【GAS】Slack絵文字リアクションによるNotion Web Clipperへの保存

# はじめに
この記事は [ZOZO Advent Calendar 2022](https://qiita.com/advent-calendar/2022/zozo) カレンダーVol.6の15日目の記事です。
こんにちは、初投稿です。新卒一年目で、普段はデータ基盤の運用を行なっております。

私は普段、[Notion](https://www.notion.so/ja-jp)を用いて毎日のインプットを整理しています。
その中でも後述するWeb Clipperを用いて、日々参考となったWebページを管理しています。（便利）

また会社では気になるネタ記事を共有するSlackのチャンネルがあるのですが、「あとで読も〜」と思って絵文字リアクションのみしてしまい、見逃す事案が多発しています。
Web ClipperはChromeの拡張機能なので一度リンクを開けばいいのですが、それすらも億劫なのです。
なので特定の絵文字でリアクションをすると、Web Clipperのデータベースに登録されるBotを作ります。


# 今回作るもの
SlackでURLが含まれるメッセージに、 絵文字でリアクションすると、
![random_-_Slack_-_Slack.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272362/9529bc88-6dc7-63b2-8700-2867af8a68a6.png)

NotionのWeb Clipper用のデータベースに登録されます。
![Web_Clip.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272362/1e6e411b-1690-fdfa-8e79-6832c2350913.png)




# Notion Web Clipperについて
https://www.notion.so/ja-jp/web-clipper

Notion Web ClipperはウェブページをNotionに保存する機能です。
こんな感じ。

![スクリーンショット 2022-12-14 17.30.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272362/5d86e995-cd7b-128b-a9b2-252977f911bd.png)

それだけです。ただそれでいいのです。Notionのデータベースが優秀だから。
Notionのデータベースの検索は全てのカラム情報に全文検索が走るため、自動保存である保存したwebページのタイトルでも検索にかかるし、任意に入力したタグやキーワードに対してもヒットします。
またビューを用いて任意のフィルター条件を登録しておけば、体験の良い参照ができます。（「高頻度アクセスビュー」や「Python関連ビュー」など）

その他にもデータベースのリレーション機能使ってタグをライブラリ化している人もいたりします。

https://note.com/ko_nyaku/n/nbb0355b099ba


最近では、とにかくバンバンWeb Clipperに保存しています。
そしてググる前にNotion内で検索し、それでもない場合はGoolge先生に聞きます。
「お、いい感じの記事〜登録〜」と思ってポチったら、数ヶ月前に既に登録されていることとかもめっちゃあります。

#### 余談: Save to Notionが良さげ
この記事を書く前まで、Notionの公式が出している[Web Clipper](https://chrome.google.com/webstore/detail/notion-web-clipper/knheggckgoiihginacbkhaalnibhilkk?hl=ja)の拡張機能を用いてましたが、調べてみるとサードパーティ製の[Save to Notion](https://chrome.google.com/webstore/detail/save-to-notion/ldmmifpegigmeammaeckplhnjbbpccmm)というものが出ていました。
Save to Notionではページ保存時にプロパティを設定できたり、ハイライト機能があったり、使い勝手が良かったので私もこちらに乗り換えました。


https://pouhon.net/notion-webclip2/5709/


# Slackの準備
Slack Appの準備は主に以下です。

1. [Events API](https://api.slack.com/apis/connections/events-api) を用いて、絵文字リアクションがある度にGAS のURLにイベントを送信する
1. [Conversations API](https://api.slack.com/docs/conversations-api) を用いて、メッセージ内容を取得するので、権限を付与する
1. 必要な情報を控える


## Event APIの設定
適当なSlack Appを作成し、ワークスペースにインストールします。
また今回のAppは完全にプライベート用なので、Appの配布はスキップします。

![Slack_API__Applications___Slack.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272362/69929aff-41e0-5dcf-ecaf-2136367f9fe4.png)
![Slack_API__Applications___Slack_Slack.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272362/96aee096-407d-36cf-6ac4-599df29c9b70.png)

次に**Event Subsriptions**の設定です。
まず、`Request URL`については後述するGASのデプロイURLなので、GAS実装後に入力します。
次に、どのイベントに対してサブスクライブする設定ですが、今回は「絵文字のリアクションが追加された時」なので、下記画像のように `reaction_added` を選択します。
![Slack_API__Applications___Slack_Slack.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272362/e66004b0-84a2-8f83-be78-971d12b002fe.png)


## 権限の設定
次にメッセージ内容を取得するための権限設定です。
今回は、 `https://slack.com/api/conversations.replies` ([リファレンス](https://api.slack.com/methods/conversations.replies))を用いるかつ、チャンネルへの投稿メッセージを対象としていますので、
[channels:history](https://api.slack.com/scopes/channels:history) 権限を与えます。

Features >> OAuth & PermissionsのScopeより設定できます。
![Slack_API__Applications___Slack_Slack.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272362/28a0f3dd-38d5-5036-49bb-e3f3fb776fb1.png)
（`reactions:read` はEvent Subsriptionsの設定で自動追加されたものです）


## 必要な情報
以下の情報を控えます。

- Bot User OAuth Token
  - Slack api画面の Features >> OAuth & Permissionsより取得できます
- 自身のメンバーID
  - [この記事](https://scheduling.help.receptionist.jp/slack-id)を参考にしました
  - 自身のリアクションのみ保存する時の判別として使います


# Notionの準備
Notionの準備は主に以下です。

1. [インテグレーションを作成する](https://developers.notion.com/docs/create-a-notion-integration)
1. 対象のデータベースに対して、[コネクトの追加](https://developers.notion.com/docs/create-a-notion-integration#step-2-share-a-database-with-your-integration)を行う
1. 必要な情報を控える

## インテグレーションの作成
[公式の通り](https://developers.notion.com/docs/create-a-notion-integration#step-1-create-an-integration)進めていきます。
ブラウザからNotionにログインし、https://www.notion.so/my-integrations にアクセスします。
「新しいインテグレーションを作成する」を選択し、適当なNameを入力します。その他はデフォルト値のまま進めました。

インテグレーションを作成すると、Secretsのセクションに`Internal Integration Token`が表示されるの控えます。


## コネクトの追加
こちらも[公式通り](https://developers.notion.com/docs/create-a-notion-integration#step-2-share-a-database-with-your-integration)に。
次に対象のデータベースに対して、作成したインテグレーションをコネクト追加します。
![Web_Clip.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272362/1a12fcc0-1481-52ea-c589-9182dc62fb6f.png)


## 必要な情報
- 上述した`Internal Integration Token`
- データベースID
    - [こちら](https://developers.notion.com/docs/create-a-notion-integration#step-3-save-the-database-id)に取得方法が記載されています


# GAS
やっと本題です。
こういった自動化Botを作るときはGAS (Google Apps Script) を用いるのが定番らしいので、GASで作りました。


```javascript
// Slack Setting
const SLACK_APP_TOKEN = "Bot User OAuth Token入れる"
const MEMBER_ID = "自身のメンバーID入れる"

// Notion Setting
const DATABASE_ID = "データベースID入れる"
const NOTION_TOKEN = "Internal Integration Token入れる"

function doPost(e) {
  // Event APIの検証時はchallengeを返す
  try {
    const json = JSON.parse(e.postData.getDataAsString());
    if (json.type == "url_verification") {
      return ContentService.createTextOutput(json.challenge);
    }
  }
  catch (ex) {
    Logger.log(ex);
  }

  const json = JSON.parse(e.postData.getDataAsString());
  const channel = json.event.item.channel;
  const ts = json.event.item.ts;
  const reaction = json.event.reaction;
  const memberId = json.event.user

  if (reaction != "linked_paperclips" || memberId != MEMBER_ID) {
    return
  }


  const message = getMessage(ts, channel);
  const urls = extractUrlslFromMessage(message);
  const permalink = getPermalink(ts, channel);

  for (url of urls) {
    postNotionDB(url.title, url.url, permalink)
  }

  const output = ContentService.createTextOutput(JSON.stringify({ result: "Ok" }));
  output.setMimeType(ContentService.MimeType.JSON);
  return output;
}

function getMessage(ts, channel) {
  const url = "https://slack.com/api/conversations.replies";
  const options = {
    "method": "get",
    "contentType": "application/x-www-form-urlencoded",
    "payload": {
      "token": SLACK_APP_TOKEN,
      "channel": channel,
      "ts": ts
    }
  };
  const response = UrlFetchApp.fetch(url, options);
  const json = JSON.parse(response);
  // スレッド返信は考慮しないので最初のmessageのみ
  const message = json.messages[0]
  return message
}

function getPermalink(ts, channel) {
  const url = "https://slack.com/api/chat.getPermalink"
  const options = {
    "method": "get",
    "contentType": "application/x-www-form-urlencoded",
    "payload": {
      "token": SLACK_APP_TOKEN,
      "channel": channel,
      "message_ts": ts
    }
  };
  const response = UrlFetchApp.fetch(url, options);
  const json = JSON.parse(response);
  const permalink = json.permalink
  return permalink
}

function extractUrlslFromMessage(message) {
  const urls = []

  // attachments
  if ("attachments" in message) {
    for (attachment of message.attachments) {
      urls.push({
        title: attachment.title,
        url: attachment.original_url
      })
    }
  }


  // blockのelements見る。attachmentsにあればskipする
  for (element of message.blocks[0].elements[0].elements) {
    if (element.type != "link") {
      continue
    }

    if (urls.some(url => url.url != element.url) || urls.length == 0) {
      urls.push({
        title: element.url,
        url: element.url
      })
    }
  }

  return urls
}

function postNotionDB(title, url, permalink) {
  const post_url = 'https://api.notion.com/v1/pages';

  const json = {
    parent: {
      database_id: DATABASE_ID,
    },
    properties: {
      "Name": {
        "title": [{
          "text": {
            "content": title
          }
        }]
      },
      "URL": {
        "url": url
      },
      "SlackURL": {
        "url": permalink
      }
    }
  }

  const options = {
    "method": "POST",
    "headers": {
      "Content-type": "application/json",
      "Authorization": "Bearer " + NOTION_TOKEN,
      "Notion-Version": '2022-06-28',
    },
    "payload": JSON.stringify(json),
  };

  UrlFetchApp.fetch(post_url, options);
}


```

とくに難しいことはしていないので詳しい解説は省略します。

若干、工夫した点は絵文字リアクションを通してWeb Clipperのデータベースに登録するときは、
`SlackURL` というカラムに、該当スレのパーマリンクも一緒に登録します。
スレッド内で議論などがあった場合、Notionからそのパーマリンクを参照することで、あとで見返すときに便利そうだなと思いつけました。

### 注意点と修正予定箇所
勢いで作ったため、いくつか注意点があります。
まず、Slack上でのWebページのプレビューがない場合は、タイトルがURLになります。
上記コードの `attachments` がプレビューの情報を含まれるのですが、これがない場合はURLの文字列の情報しか取得できなかったので、タイトルをURLにしました。

またプレビューがない場合はblocksの値を取得して、URLを取得しています。
しかし、この中身はメッセージの書式によって再帰的な構造をもつ仕様になっており、現在のコードだとリストなどの書式が入ると上手く取得できません。
こちら (多分)修正予定です。（レスポンス仕様のリファレンスが探しても無いのだが、どこにあるのやら、、、）


# さいごに
今回は初投稿で本来、データエンジニアリング業務の小ネタを書こうと思ってたのですが、
あんまりネタが無かったのと、今回作ったBotは数ヶ月前から構想はあったので、勢いでこの題材にしました。
次回からはそっちのネタ書けるといいなぁ〜


# 参考URL
- https://qiita.com/thomi40/items/fe2a828746f31ad827ba
- https://qiita.com/DEmodoriGatsuO/items/e71e9348632efb53310c
- https://blog.serverworks.co.jp/slack-api-curl
- https://zenn.dev/barusu/articles/35cfe61d6e846f
- https://qiita.com/mpyw/items/5a8a90713a56196a55ae