Open155

firstSlackApp

sezemi_adminsezemi_admin

Readme

チームの心理的安全性に効く Slack App を作りたいと思い、その作り方を勉強することにした。これはその学習メモを残していく用のスクラップ。
ちなみに times チャンネルにログを残しているのだが、それだとコンテキストが一発で辿れないため、中断すると、そのコンテキストを理解するだけで勉強時間が終わってしまう。

このため、スクラップにログを残しておいて、スグにコンテキストがわかるようにする

sezemi_adminsezemi_admin

What to learn

Slack App は色々作り方があるのだけど、今回は Bolt という Slack 謹製の SDK ? Framework ?? を使う。

Slack | Bolt for JavaScript

で、基本的にはこの Bolt のチュートリアルを使って進めている

sezemi_adminsezemi_admin

で、今日ようやく時間が取れて、再開したところ、一応 Slack App 動くか試してみるかと思って 3. ローカルプロジェクトの設定 をやると、見事にコケてしまい、エラーを見ると、以下の環境変数が無いよ、という話

  • SLACK_SIGNING_SECRET
  • SLACK_BOT_TOKEN

環境変数を .bash_profile で設定したところで、今日は終了。

sezemi_adminsezemi_admin

またまた時間が取れてやってみたところ、$node app.jsが環境変数未設定のエラーを吐いていたので、 .bash_profile を調べていたところ、 export が抜けていたので ↓ のように修正。

SLACK_SIGNING_SECRET=<your-signing-secret>
export SLACK_SIGNING_SECRET
SLACK_BOT_TOKEN=xoxb-<your-bot-token>
export SLACK_BOT_TOKEN

これで無事に $node app.js が動いたところで完了。また一個も進んでない ...

sezemi_adminsezemi_admin
  1. アプリを作成する
  2. トークンとアプリのインストール
  3. ローカルプロジェクトの設定
  4. イベントの設定 (HTTP)

この 4. で Request URL を発行するのにあたって、 ↓ の記述に従って、

ローカル開発では、ngrokのようなプロキシサービスを使って公開URLを作成し、リクエストを開発環境にトンネリングすることができます。ローカル開発のためのSlackでのngrokの使用については別のチュートリアルがありますので、そちらを参照してください。

このチュートリアルを進めることに。

読んでいると、ソケットモードというのを enable にすると、 HTTP Request URL を使わなくても、 WebSocket URL とやりとりが出来るとのこと。なる~

というわけで、このチュートリアルの手順を進める

  1. 自身の Slack App の設定画面で Socket Mode というメニューがあるので enable にする
  2. Token が発行されるので、任意の名前 "socket_mode_token" をつけてメモる
  3. その Token を SLACK_APP_TOKEN という環境変数に設定する export SLACK_APP_TOKEN='xapp-***'
  4. ↑ を .bash_profile に追加して、 source ~/.bash_profile をした

今日はここで終了。
続きは、 Next make a simple change to your basic Bolt initialisation code: の下にあるように、 app.js にソケットモードを有効にする設定を書くところから

sezemi_adminsezemi_admin

app.js のコンストラクタに SLACK_APP_TOKEN の環境変数を読んで、ソケットモードを有効にする設定を追加

const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  signingSecret: process.env.SLACK_SIGNING_SECRET,
  appToken: process.env.SLACK_APP_TOKEN,
  socketMode: true,
});

一応 node app.js で動かして、無事に動作した

というわけで、 Socket Mode のチュートリアル ではこのあと、 Events API にリクエストするやつをやるんだけど、これは飛ばして、本線に戻った。

イベントの設定 (HTTP)

  1. Event Subscriptionsの下にある、Enable Eventsというラベルの付いたスイッチを切り替え -> 完了
  2. Enable Events スイッチの下に Request URL の入力ボックスに URL を貼り付け -> ?

画面に Request URL ボックスが表示されてないやんって思ったんだが、

Socket Mode is enabled. You won’t need to specify a Request URL.

と Enable Events の設定画面にあったのを見落としていた。というわけで Request URL は無しで次に進む

  1. Subscribe to Bot Events までスクロールします。メッセージに関するイベントが4つから選択:
  • message.channels あなたのアプリが追加されているパブリックチャンネルのメッセージをリッスン
  • message.groups あなたのアプリが追加されている🔒プライベートチャンネルのメッセージをリッスン
  • message.im あなたのアプリとユーザーのダイレクトメッセージをリッスン
  • message.mpim あなたのアプリが追加されているグループ DM をリッスン

ここまで読んだところで今日はおしまい。続きはこのイベントを選択するところから


あと、今後ハマったときに参考にするかもと思った記事があったので、メモっておく

Slack ソケットモードの最も簡単な始め方 - Qiita

sezemi_adminsezemi_admin

続きで、 Subscribe to Bot Events を選択

Subscribe to Bot Events

  • チュートリアルに挙がってた 4 つを選択して save
  • save すると alert が出た

You’ve changed the permission scopes your app uses. Please reinstall your app for these changes to take effect (and if your app is listed in the Slack App Directory, you’ll need to resubmit it as well).
アプリが使用する権限スコープを変更しました。これらの変更を有効にするには、アプリを再インストールしてください(アプリがSlack App Directoryに掲載されている場合は、再提出してください)。

alert にあったリンクをたどると permission を聞かれたので、 allow したら無事に save できた。

今日はここでおしまい

sezemi_adminsezemi_admin

今日から次のステップに進む

  1. メッセージのリスニングと応答

今度は作った bot = Slack App がいるチャンネルや DM で "hello" が含まれるメッセージがあると、 bot が反応して "Hey there @user" と返すやつをつくる。

サンプルは ↓

app.js
// "hello" を含むメッセージをリッスンします
app.message('hello', async ({ message, say }) => {
  // イベントがトリガーされたチャンネルに say() でメッセージを送信します
  await say(`Hey there <@${message.user}>!`);
});

message() の仕様は↓。まぁ言うて、 JavaScript はあんま知らんので雰囲気でフムフムしてる

(method) App.message(pattern: string | RegExp, ...listeners: MessageEventMiddleware[]): void (+4 overloads)
@param pattern
Used for filtering out messages that don't match. Strings match via String.prototype.includes.

@param listeners — Middlewares that process and react to the message events that matched the provided patterns.

これを追加して ↓ で動作を確認。

  1. アプリを再起動
  2. bot をチャンネル or DM に追加
  3. hello を送信すると bot が反応

そして、見事にウンともスンとも動かん結果に ...
listener ...

まぁ、最初はこんなもんだよね。というわけで続きはここから

sezemi_adminsezemi_admin

"hello" に Bot が反応しないやつ、

  1. アプリの再起動

というのを、 Slack そのものを再起動するのかと勘違いしたけど、そうではなく Slack App を再起動なのではと思いやってみた。

$ node app.js
⚡️ Bolt app is running!

で、 Bot がいるチャンネルでやってみると ...

Bot が反応

動いたやん!いえい :+1:

というわけで次ぃ

  1. アクションの送信と応答

次にやるのは、ボタン、選択メニュー、日付ピッカー、モーダルなどの機能を使用する とのこと

手順は↓

  1. Slack App の Menu で Interactivity を有効にする
  2. Request URL を設定する

Interactivity の画面

すでに有効になっていたのと、 Socket Mode が有効なのでパス。

では、イベントを app.js に書く。

app.js
// "hello" を含むメッセージをリッスンします
app.message('hello', async ({ message, say }) => {
  // イベントがトリガーされたチャンネルに say() でメッセージを送信します
  await say({
    blocks: [
      {
        "type": "section",
        "text": {
          "type": "mrkdwn",
          "text": `Hey there <@${message.user}>!`
        },
        "accessory": {
          "type": "button",
          "text": {
            "type": "plain_text",
            "text": "Click Me"
          },
          "action_id": "button_click"
        }
      }
    ],
    text: `Hey there <@${message.user}>!`
  });
});

say() ってそもそも何してるんだろうと思って見てみると、

(parameter) say: SayFn
(message: string | SayArguments) => Promise<ChatPostMessageResponse>

引数を入れてやると、 Promise が動くのかなぁ、と考えていると時間切れ。

これを深堀りすると、ちょっと時間がかかりそうなので、そんなもんかと割り切って、次に進も

sezemi_adminsezemi_admin

チュートリアルで、加えた blocks[] の配列(オブジェクト)の中身を解説してくれていたのでメモ

app.js
    blocks: [
      {
        "type": "section",
        "text": {
          "type": "mrkdwn",
          "text": `Hey there <@${message.user}>!`
        },
        "accessory": {
          "type": "button",
          "text": {
            "type": "plain_text",
            "text": "Click Me"
          },
          "action_id": "button_click"
        }
      }
    ],
  • Slack メッセージを構成するコンポーネント
    • テキストや画像、日付ピッカーなど、さまざまなタイプがある
  • このサンプルは accessory というボタンを含むセクションブロック
    • accessory オブジェクトに action_id を割り当て
    • どのアクションに応答するかを指定できる

なんとなく言わんとしていることはわかるが、これでひとまず動くので、やってみる

ボタンが表示された

ふむふむ、なんとなく blocks とはメッセージの内容を JSON で書くような感じかと、やってることが掴めてきた。

ちなみに blocks は ↓ で作れるとのことで、サンプルを見てみると、なるほど感がある


Block Kit ビルダー

blocks の editor


ただ、ボタンを押しても何も起こらないので、これにハンドラーを追加して、ボタンを押すと "@user clicked the button" と返すようにする

app.js
app.action('button_click', async ({ body, ack, say }) => {
  // アクションのリクエストを確認
  await ack();
  await say(`<@${body.user.id}> clicked the button`);
});

なるほど。 コールバックを入れるような感じか。

ちなみに各メソッドなりの仕様は↓

  • action()
(method) App.action<SlackAction>(actionId: string | RegExp, ...listeners: Middleware<SlackActionMiddlewareArgs<SlackAction>>[]): void (+1 overload)
  • ack()
(parameter) ack: (response: void & Pick<ChatPostMessageArguments, "token" | "text" | "as_user" | "attachments" | "blocks" | "icon_emoji" | "icon_url" | "link_names" | "mrkdwn" | ... 5 more ... | "username"> & {
    ...;
} & DialogValidation) => Promise<...>
  • body
(parameter) body: SlackAction

ack() がちょっとわからんな、と思いながら、とりあえずこれで動かしてみる

ハンドラーを追加

無事にボタンを押すと、メッセージが表示された。

一旦、今日はここまで。次は先に進むか、一応、この追加したハンドラーが何しているのか調べるか、どっちかから

sezemi_adminsezemi_admin

とりあえず先に進めてみた。

  1. 次のステップ

なんとこれで終わりだった ...

もうおしまい

で、これからは興味のあるものを進めてね、ということで挙がっていたリストが ↓ だった。

こっから先は作りたいやつのユーザーストーリーを眺めて、勉強するものを決める。なので、この前のハンドラーが何をしているのかは本筋から外れるかも知れないので、また出てきたら調べてみよう。

というわけで、ユーザーストーリーをまとめる。

ユーザーストーリー

  1. Slack のメンバーを取得する
  2. App が特定の曜日になるとランダムでメンバーを選びメッセージする
    1. メッセージ内容は固定でフィードバックの趣旨を説明するもの
    2. リストを出すボタンを用意する
  3. フィードバックを受ける人がボタンを押して App がフィードバックする候補リストをモーダルで出す
  4. フィードバックを受ける人がフィードバックしてもらいたい人を 5 人選ぶ
  5. App が Google Form API にフィードバックフォームの作成リクエストを出す
  6. App が Google Form API から完了のステータスとフォームの URL のメッセージをリッスンする
    1. これは Google Form の Bot を用意してメッセージを吐いてもらったほうがよいかもな
  7. App がフィードバックする人にフォーム URL をメッセージする
  8. App が Google Form API から 5 人のレスポンスの完了のメッセージをリッスンする
  9. App がフィードバックを受ける人にフィードバック終了と Google Form の URL をメッセージする

続きは整理したユーザーストーリーをもとに、次のステップで挙がっていたリストをザッとみて、勉強することを決める。

sezemi_adminsezemi_admin

昨日挙がっていた次のステップに挙がってたリストをしげしげと眺める

ザッと見てユーザーストーリーに必要そうなものは ↓ だったけど、全然足りない ...

まずはなにはともあれ、チームのメンバー情報を得たいので、 Web API を眺めてみるかなぁ。

ただ、ちょっとチュートリアルのメニューの下に "リファレンス" とあったので、こっちを先に見てみたほうが良い気もする。

リファレンス(Appインターフェイスと設定)

うーむ ... 次はリファレンスをザッと見てから、いらなかったら Web API を眺めよう

sezemi_adminsezemi_admin

リファレンス(Appインターフェイスと設定) を読んでみたんだけど、文字通りリファレンスで一覧でまとめたものだったので、ザッと見て、 Web API を読むことに。

Web API の使用

とりあえず慣れる上で、そこにあった日付と時刻をもとにしたサンプルをアレンジして書いてみることにした。

app.js
// 勉強の開始時間 2022/01/22 09:30 を Unix エポックタイムで表示
const whenLearningStart = '1643157000';

app.message('wake me up', async ({ message, context, logger }) => {
  try {
    // トークンを用いて chat.scheduleMessage 関数を呼び出す
    const result = await app.client.chat.scheduleMessage({
      // アプリの初期化に用いたトークンを 'context' オブジェクトに保存
      token: context.botToken,
      channel: message.channel,
      post_at: whenLearningStart,
      text: 'Start learning'
    });
  }
  catch (error) {
    logger.error(error);
  }
});

ただ、 result を使ってないので、これ動かないのでは、と感じてる。。

次にちょっと調べてみよう

sezemi_adminsezemi_admin

とりあえず、この前書いたコードで動くかどうか、 Unix エポックタイムを変えてやってみた

const whenLearningStart = '1643330100';

で、 "wake me up" というメッセージをリッスンするので、それを post して待っていると、ナント動いた ... 。

practice_web_api

この result はなんだろうと思ってググってみたりしたんだけど、その途中で、同じようにスクラップで Bolt の学習ログを取っていた人がいて、なかなか参考になった。というより、同じことをやっているはずなのに、格段にメモを取る内容と、参考になりやすさが違った ... 。

Slack Boltをためす

そうここにも書いてるんだけど、 app.client で Web API を呼び出すのです。

で、それはそれとして、結局 result がなぜ動くのかわからんので、 console.log して出してみた。

{
  ok: true,
  scheduled_message_id: 'Q030YN7SJHF',
  channel: '***********',
  post_at: 1643331180,
  message: {
    bot_id: 'B02RG11KGEL',
    type: 'message',
    text: 'Start learning',
    user: '***********',
    team: '***********',
    bot_profile: {
      id: 'B02RG11KGEL',
      deleted: false,
      name: 'practice_make_slack_app',
      updated: 1639529612,
      app_id: 'A02QP9SNF5K',
      icons: [Object],
      team_id: ' ***********'
    }
  },
  response_metadata: {
    scopes: [
      'chat:write',
      'channels:history',
      'groups:history',
      'im:history',
      'mpim:history'
    ],
    acceptedScopes: [ 'chat:write' ]
  }
}

JSON だったので、これを何かがパースして動かしているんだろうけど、さぱーりわからん。

というわけで、続きもこの result がなぜ動くのか、というところから

sezemi_adminsezemi_admin

$result が特別なグローバルな変数なんかな、って思って、違う名前にして試しみたら、動いたので、変数名は関係なかった。

今日はそれだけ~

sezemi_adminsezemi_admin

$result という変数そのものがいらんのでは、と思って、削ってみた。

app.js
app.message('wake me up', async ({ message, context, logger }) => {
  try {
    // トークンを用いて chat.scheduleMessage 関数を呼び出す
    await app.client.chat.scheduleMessage({
      // アプリの初期化に用いたトークンを 'context' オブジェクトに保存
      token: context.botToken,
      channel: message.channel,
      post_at: whenLearningStart,
      text: 'Start learning'
    });
  }
  catch (error) {
    logger.error(error);
  }
});

無事に動いたので、何ら気にすることではなかった ... 。時間がもったいなかったが、まぁ、いいや。

というわけで、 Web API を読み進める。

Using the Slack Web API | Slack

が、目的は "メンバーリストを取得する" なので、読み進めるよりググったほうが速いんじゃね、と思って検索してみるとあった。

users.list method | Slack

しかもサンプルコードがあるので、このコードを使おうと思って読み進めてるところで、時間終了。

// Require the Node Slack SDK package (github.com/slackapi/node-slack-sdk)
const { WebClient, LogLevel } = require("@slack/web-api");

// WebClient instantiates a client that can call API methods
// When using Bolt, you can use either `app.client` or the `client` passed to listeners.
const client = new WebClient("xoxb-your-token", {
  // LogLevel can be imported and used to make debugging simpler
  logLevel: LogLevel.DEBUG
});

// You probably want to use a database to store any user information ;)
let usersStore = {};

try {
  // Call the users.list method using the WebClient
  const result = await client.users.list();

  saveUsers(result.members);
}
catch (error) {
  console.error(error);
}

// Put users into the JavaScript object
function saveUsers(usersArray) {
  let userId = '';
  usersArray.forEach(function(user){
    // Key user info on their unique user ID
    userId = user["id"];
    
    // Store the entire user object (you may not need all of the info)
    usersStore[userId] = user;
  });
}
sezemi_adminsezemi_admin

コメント部分、

// WebClient instantiates a client that can call API methods
// When using Bolt, you can use either `app.client` or the `client` passed
// WebClient は、API のメソッドを呼び出すことができるクライアントのインスタンスを作成します。
// Bolt を使用する場合、`app.client` または渡された `client` のどちらかを使用することができます。

とのことなので、 import と LogLevel 部分は一旦コメントアウトした。

続きは userStore のところから

sezemi_adminsezemi_admin

引き続き users.list() のサンプルを読み進める。

await だけが宣言されて、 async がないので、動かんのじゃないかなと思って動かしてみると、やっぱりエラーだった。

$ node app.js
/home/hoppers/first-bolt-app/app.js:78
  const result = await app.client.users.list();
                 ^^^^^

SyntaxError: await is only valid in async function
    at wrapSafe (internal/modules/cjs/loader.js:1001:16)
    at Module._compile (internal/modules/cjs/loader.js:1049:27)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1114:10)
    at Module.load (internal/modules/cjs/loader.js:950:32)
    at Function.Module._load (internal/modules/cjs/loader.js:790:14)
    at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:76:12)
    at internal/main/run_main_module.js:17:47

サンプルがデータストアするものなので、ちょっと試す、というのではない。
なので、適当に書いてみよう、と思って考えたお題が ↓ 。

"このチャンネルに誰がいるの?" とメッセージすると、 Bot がメンバーを返すやつ

で、書き始めてみると、全然エラーが多発してて、これは書き方わからんってなったところで今日はおしまい

app.js
app.message('Who are in this channel?', async ({ message, context, logger }) => {
  try {

    let members = app.client.users.list();
    console.log(members);

    await app.client.chat.postMessage{{
      token: context.botToken,
      channel: message.channel,
      text: {members},
    }}
  }
  catch (error) {
    logger.error(error);
  }
}

ちなみに、最後の起動のところもエラーが出てる。

sezemi_adminsezemi_admin

もうちょっと、単純な例から始めたほうがよいと思って、いろいろググると、簡単なやつがあったので、それを試してみる

How To List Slack Users With Node | by Phil Andrews | Level Up Coding

これによると users を見るには permittion が無いとダメということだったので、 Slack App の設定画面から、 OAuth & Permissions を開き、 Scope という項目から users:read を OK にした。

で、サンプルコードを元に書いたのが ↓ 。

app.js
(async () => {
  const members = await app.client.users.list();
  console.log(members);
})();

クロージャで動かせばよかったんやん。ということで、無事に members オブジェクトが出力された。

{
  ok: true,
  members: [
    {
      id: 'USLACKBOT',
      team_id: '***********',
      name: 'slackbot',
      deleted: false,
      color: '757575',
      real_name: 'Slackbot',
      // 略
    },
    {
      id: '***********',
      team_id: '***********',
      name: '***********',
      deleted: false,
      color: '9f69e7',
      real_name: '***********',
      tz: 'Asia/Tokyo',
      // 略
    },
  ],
  cache_ts: 1643935502,
  response_metadata: {
    next_cursor: '',
    scopes: [
      'chat:write',
      'channels:history',
      'groups:history',
      'im:history',
      'mpim:history',
      'users:read'
    ],
    acceptedScopes: [ 'users:read' ]
  }
}

で、もっかい前回のお題、

"このチャンネルに誰がいるの?" とメッセージすると、 Bot がメンバーを返すやつ

に挑戦しようとコードを書き始めたところで終了。

app.js
app.message('who is in channnel?', async ({ say, logger }) => {
  try {
    let members = await app.client.users.list();

    await say({
      blocks: [
        {
          "type": "section",
          "text": {
            "type": "mrkdwn",
            "text": `Hey there <${members}>!`
          },
        }
      ],
      text: `Hey there <${members}>!`
    });
  }
  catch (error) {
    logger.error(error);
  }
});

続きは members オブジェクトから name を取り出すところから

sezemi_adminsezemi_admin

変数名を members にしていると、 users.list() で members の要素とゴッチャになるので、 users に変えて書くことにした。

で、 name だけを出そうとすると users.list() のサンプルコードのように forEach で取り出すしか無いので、ちょっと関数を分けて、 say に渡すように変更。

app.js
let userNames = {};

app.message('who is in channnel?', async ({ say, logger }) => {
  try {
    let users = await app.client.users.list();
    userNames = retrieveUsersName(users.members);

    await say({
      blocks: [
        {
          "type": "section",
          "text": {
            "type": "mrkdwn",
            "text": `Hey there <${userNames}>!`
          },
        }
      ],
      text: `Hey there <${userNames}>!`
    });
  }
  catch (error) {
    logger.error(error);
  }
});

// Put User names into the Objext
function retrieveUsersName(usersArray) {
  let userName = '';

  usersArray.forEach(function(user) {
    userName = user["name"];


  });
}

最後、 usersName オブジェクトにするところを書いてないので、続きはそこから

sezemi_adminsezemi_admin

ようやく、 1 ヶ月ぶりぐらいに時間が取れたので再開。スクラップに書いておいてよかった。たどりやすい

というわけで、 say() にわたす userNames を取り出す retrieveUsersName() を書くところから。

で userNames は配列で渡したほうがよいかと思い、 ↓ のように書いてみた

app.js
function retrieveUsersName(usersArray) {
  usersArray.forEach(function(user) {
    userNames.unshift(user['name']);
  });
}

と書いてから、文字列で連結したものが良いかと思い直してる ... 。 そっちのほうがいいな

sezemi_adminsezemi_admin

retrieveUsersName() の戻り値は文字列にしようと思って ↓ のように変更

app.js
function retrieveUserNames(usersArray) {
  usersArray.forEach(function(user) {
    userNames += user['name'] + ', ';
  });
}

これで動かしてみた!

見事に undefined

まぁ、そんなもんだよな、と思って、 retrieveUserNames() で、

app.js
  usersArray.forEach(function(user) {
    userNames += user['name'] + ', ';
    console.log(userNames);
  });

デバッグすると、

slackbot,
slackbot, hirose,
slackbot, hirose, practicemakeslackapp,

と出てくるので、ちゃんと出来てるんだけどなぁ、と思っていたら、「あー、返却してないやん」という初歩に気付いた。 ぐぬう

というわけで、 return を入れて完了。

app.js
function retrieveUserNames(usersArray) {
  usersArray.forEach(function(user) {
    userNames += user['name'] + ', ';
  });
  return userNames;
}

というわけで動かしてみると、

Good!

無事にチャンネルに入っているやつの名前が出てきたぞい。いえーい :tada:

続きは、メンバーリストが出せるようになったので、次の

App が特定の曜日になるとランダムでメンバーを選びメッセージする

これを書くぞ

sezemi_adminsezemi_admin

次のユーザストーリーを進める

  1. App が特定の曜日になるとランダムでメンバーを選びメッセージする
  • メッセージ内容は固定でフィードバックの趣旨を説明するもの
  • リストを出すボタンを用意する

特定の曜日にメッセージできるメソッドなりが無いかなと思って、いろいろググっていると、ドンピシャのやつがあった

Scheduling messages | Slack

とりあえず今日はこれを調べたところでオシマイ

続きは載っているサンプルコードを動かすぞ

sezemi_adminsezemi_admin
// Unix timestamp for tomorrow morning at 9AM
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(9, 0, 0);

// Channel you want to post the message to
const channelId = "C12345";

try {
  // Call the chat.scheduleMessage method using the WebClient
  const result = await client.chat.scheduleMessage({
    channel: channelId,
    text: "Looking towards the future",
    // Time to post message, in Unix Epoch timestamp format
    post_at: tomorrow.getTime() / 1000
  });

  console.log(result);
}
catch (error) {
  console.error(error);
}

サンプルコードを動かそうと思ってみてみると、特定の "日付" だったので、用途に合わない。
特定の "曜日" にできるか、調査することに。

ちょっとググっていると、埒があかない感じだったので、 vscode から参照して、 Date() を辿ろうとしたんだけども、、、見事に interface で、実装がどこにあるかは辿れんかった(探せばあるんだろうけどねぇ)

とりあえず、 console.log で見てみるかと思って出力してみると、現在時刻が出ただけだった。

で、今度は、同じく vscode の補完を使ってメソッドを辿るといいかも、と思ってやり始めたところで、今日はおしまい。 補完で辿るやつは ↓ みたいな感じ。 vscode 便利よね

hint

続きは、この補完を使っていい感じに特定の "曜日" がセットできるメソッドを見つけるところから

sezemi_adminsezemi_admin

Date() のメソッドを確認するため、試しに new Date().getDate; を書いて、 getDate から参照を辿ってみると、 interface だけどメソッドの定義があった。 いえい

lib.es5.d.ts
/** Enables basic storage and retrieval of dates and times. */
interface Date {
    // 中略
    getUTCDate(): number;
    /** Gets the day of the week, using local time. */
    getDay(): number;
    /** Gets the day of the week using Universal Coordinated Time (UTC). */
    // 中略
}

getUTCDate() で曜日を判定して、指定曜日に合致したら setDate setHours して、 scheduleMessage() の処理をするという感じで良さそう。

というわけで、何が出力されるのか確かめてみると、

app.js
console.log(new Date().getUTCDate()); // 15

15 って ... day が出てきてるやん。 ぐぬうってなったところでオシマイ

sezemi_adminsezemi_admin

書きながら思っていたんだけど、 getUTCDate() の引数に today の UNIX TIME を入れないといかんのかも

sezemi_adminsezemi_admin

もしかしてと思ってみると、ナント、定義がメソッドの上のコメントにあったのに、メソッドの下に定義があると勘違いしてしまった模様。 アホアホ

lib.es5.d.ts
    /** Gets the day-of-the-month, using Universal Coordinated Time (UTC). */
    getUTCDate(): number;
    /** Gets the day of the week, using local time. */
    getDay(): number;
    /** Gets the day of the week using Universal Coordinated Time (UTC). */
    getUTCDay(): number;
    /** Gets the hours in a date, using local time. */

ただしくは getUTCDay() を使うべきだった orz ...

sezemi_adminsezemi_admin

定義が書かれているコメントの場所を間違えて getUTCdate() を使ってたけど、正しくは getuUTCDay() だったことに気付いたので変更

app.js
console.log(new Date().getUTCDay());

これで動かしてみたら ...

$ node app.js 
3
⚡️ Bolt app is running!

今日は水曜日なので、無事に出力された模様。

月曜スタートとして、

  1. Mon ・・・ 1
  2. Tue ・・・ 2
  3. Wed ・・・ 3
  4. Thu ・・・ 4
  5. Fri ・・・ 5
  6. Sat ・・・ 6
  7. Sun ・・・ 7

かな? Sun が 0 な可能性もあるな。

この出力だと具体的な日時がないので、 setDate()setHours() で日時を指定して、 scheduleMessage() の post_at のパラメータに入れればよいな。

というわけで、続きは setDate()setHours() を使って具体的な日時を作るところから。 進んできたぞ~

sezemi_adminsezemi_admin

setDate()setHours() で日付をセットするところから開始

app.js
dateOfWeek = new Date().getUTCDay();

if ( dateOfWeek = 4) {
  // 日時を設定
  dateOfWeek.setDate(dateOfWeek.getDate());
  dateOfWeek.setHours(9, 5 ,0);
  console.log(dateOfWeek.getTime() / 1000);

  // const channelId = "";

  // メッセージを送信
}

で動かしてみたら、

TypeError: dateOfWeek.getDate is not a function

とエラー。

あ、そうか、 dateOfWeek は int だったよな。ごめんなさい ... 。

app.js
date = new Date();

// 曜日を取得
dateOfWeek = date.getUTCDay();

// 指定曜日ならメッセージを送信
if ( dateOfWeek = 4) {
  // 日時を設定
  date.setDate(date.getDate());
  date.setHours(9, 5 ,0);
  console.log(date.getTime() / 1000);

  // const channelId = "";

  // メッセージを送信

}

で直したらエラーは収束して、 1647475500.589 が出力された。 unixtime なのでわからんと思って変換ツールで調べてみると、あれ、エラー ... 。

変換失敗

from Unixtime相互変換ツール | konisimple tool

なんでやろ。 そもそも、なぜ 1000 で割ってるのかと調べてみたら、なるほどね。 桁数が違うときがあるのね

Unix timestampの罠。 | ららベル

なら変換ツールを変えたらいけるかもと思って違うツールを試してみたら、無事、意図通りの時間になってた。

2022-03-17 9:05

from UNIX時間変換ツール - instant tools

ここまで調べたところで今日は時間切れ。

続きは scheduleMessage() を使って、いよいよメッセージを送信してみるぞ

sezemi_adminsezemi_admin

scheduleMessage() の処理を書くところから。

app.js
    try {
      const result = await app.client.chat.scheduleMessage({
        channel: channelId,
        text: "Today is Wed.",
        post_at: date.getTime() / 1000,
      });

      console.log(result);
    }
    catch(error) {
      console.error(error);
    }

これで実行してみると見事、、、エラー!

SyntaxError: await is only valid in async functions and the top level bodies of modules

ええ、サンプル通りなのに ... などと思っていても仕方がないので、 async() を入れて書き直し

app.js
  async () => {
    try {
      const result = await app.client.chat.scheduleMessage({
        channel: channelId,
        text: "Today is Wed.",
        post_at: date.getTime() / 1000,
      });

      console.log(result);
    }
    catch(error) {
      console.error(error);
    }
  }

エラーは出ず、実行開始したので、ドキドキしながらメッセージを待つ。

いつまで経ってもメッセージがこないので、 reload したり時刻を変えたりしながらやっても、うんともすんとも言わない。

で、引数を確かめていると、

post_at: date.getTime() / 1000,

これが現在時刻になっていることが判明。

この前までセットできていた setDate()setHours() が効いていない模様。 なんでじゃ

というわけで、次回は、またまたこの調査から

sezemi_adminsezemi_admin

getTime() などの関数の参照ができなくなっていて、 lib.es5.d.ts というファイルも無い。 どういうこった

sezemi_adminsezemi_admin

そもそも

$ tsc --version

コマンド 'tsc' が見つかりません。次の方法でインストールできます:

sudo apt install node-typescript

となるので、 TypeScript の環境が壊れているかも知れない。

一方で、

$ npm ls -g typescript
/usr/local/lib
└─┬ ts-node@10.7.0
  └── typescript@4.6.2

これは出るので、なんだろうなぁという気持ち

sezemi_adminsezemi_admin
app.js
date = new Date();

これは /home/user_name/.vscode-server/bin/c722ca6c7eed3d7987c0d5c3df5c45f6b15e77d1/extensions/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts で参照ができるのに、

app.js
dateOfWeek = date.getUTCDay();

getUTCDay() は参照ができない。

ただ ↑のパスから辿るとちゃんと、 /home/user_name/.vscode-server/bin/c722ca6c7eed3d7987c0d5c3df5c45f6b15e77d1/extensions/node_modules/typescript/lib/lib.es5.d.ts があって、そこにメソッドもあったんだよなぁ

sezemi_adminsezemi_admin

久々に再開。

ts のライブラリが参照できなかったので、一旦、グローバルインストールしてた typescript などは一旦アンインストール。

ちなみに WSL でやると、どうやらパーミッションが上手いこと設定されず、何度も chown をやることになった。 なんでやねん

で、再度 ts をローカルにインストール

  1. $ npm install --save-dev typescript
  2. ./node_modules/.bin/tsc --version -> Version 4.6.3
  3. $ ./node_modules/.bin/tsc --init -> tsconfig.json
  4. tsconfig.json を編集

ここまでやったところで終了。

それでも Date のメソッドを参照できなければ、最終的には import で解決する

sezemi_adminsezemi_admin

やっぱり Date のメソッドを参照できないので、 import で lib を直接入れてみる

import { Date } from "./node_modules/typescript/lib/lib.es5.d.ts";

としてみても、 Date オブジェクトが認識されてない ...

うーん、どういうことだろう

sezemi_adminsezemi_admin

ちょっとラチがあかないので、一旦、別ディレクトリで一から構築する。

といっても、 npm install @slack/bolt するだけの簡単なお仕事なので、スグに完了

で、動かしてみると、やっぱり Date.setHours() で指定した時刻にならない ...

VScode 上でも正しく lib を参照できず、 any になってしまっている。 なんでじゃ~

結局進捗せず、今日はここまで

sezemi_adminsezemi_admin

js から ts のライブラリを呼び出すときに、何かお作法があるのかと思って、調べていると、そもそも es2015 の lib (今回欲しい es5 のやつではない)を読んでいるから、それを変える方法がないか、ということに調査の方法を切り替え。

で、調べていると、 tsconfig で lib の設定を追加すると、使っているライブラリを変えられるとのこと。 ts のファイルだけじゃないかなぁと思っているんだが、物は試しでやってみることにした。

参考にしたのは ↓

で、一応、↓のように変えてみた。

tsconfig.json
{
  "compilerOptions": {
    "target": "ES5",
    "lib": ["ES2020", "DOM"],
    "module": "ES2015",
}

これで次回、参照できるか確認するゾイ

sezemi_adminsezemi_admin

やっぱり tsconfig を設定してみても参照できず ...

一旦、ちょっと離れて JS の Date オブジェクトを使えるか試してみることにした。

date = new Date();
console.log(date.getDate());

これで 25 が返ってきたので、動いてるっぽい。

次回、ちょっとこっちのメソッドに切り替えてやってみよう

sezemi_adminsezemi_admin

ようやく時間が取れたので、 JS の Date オブジェクトでいろいろやってみる

const date = new Date();
console.log(date); // 2022-05-20T01:00:20.931Z
const dateOfWeek = date.getDay();
console.log(dateOfWeek); // 5

date.setHours(11, 50, 0);
console.log(date); // 2022-05-20T02:50:00.931Z

setHours() して、ちゃんと時刻設定が出来たゾイ。 続きはこれをもとに指定曜日にメッセージを出すやつで試す。

久々やって前進するといいなぁ

sezemi_adminsezemi_admin

T02 と出力されるのは JS の format の問題みたい。 Slack App って UNIX 時間だったので、いけんのかな

sezemi_adminsezemi_admin

Slack App は UNIX 時間で動くので、 setHours() の UTC 版、もしくは UNIX 時間への変換ができるか調べてみると、 setUTChours() というメソッドがあるのがわかった。

↑によると引数は同じなので、早速試してみる。

trigger = date.setUTCHours(09, 50, 0);
console.log(trigger / 1000);

でやってみると、 1653299400.156 -> 2022/05/23 18:50 でなぜか時間がズレる。 他の hour も試して見るんだけど、ズレる。

なんでじゃと思ってググってみると、

日本のLocal timeの8時をセットした時と、UTCの8時をセットした時では9時間の時差があるため結果が変わります。
Date.prototype.setUTCHours() - UTCで時をセットする | JavaScriptリファレンス

なるほど~。

というわけで、引数を変えて、

trigger = date.setUTCHours(00, 50, 0);
console.log(trigger / 1000); // 1653267000.082 -> 2022/05/23 9:50

で無事出力でけた。 yes !

というわけで、次回は指定曜日にメッセージを送信するやつに再チャレ

sezemi_adminsezemi_admin

指定日時が設定できるようになったので、いよいよメッセージを送信するやつをやる。

app.js
const date = new Date();
const dateOfWeek = date.getDay();
console.log(dateOfWeek);

// 指定曜日ならメッセージを送信
if (dateOfWeek = 4) {
  // 日時を設定
  const trigger_time = date.setUTCHours(00, 50, 0);
  console.log(trigger_time / 1000);

  // channelId を定義
  const channelId = "**********";

  // メッセージを送信
  async () => {
    try {
      const result = await app.client.chat.scheduleMessage({
        channel: channelId,
        text: "Today is Thu.",
        post_at: trigger_time,
      });

      console.log(result);
    }
    catch(error) {
      console.error(error);
    }
  }
}

と書いたところ、 dateOfWeek = 4 でエラー。 おーけー、 ==== だったね。

気を取り直して、動かしてみると、無事に ⚡️ Bolt app is running! の表示。 ドキドキしながら指定日時 9:50 を待っていると、待てど待てどメッセージが来ない ... 。

指定曜日は 2022 05 26 09:50 で間違いは無いのに ... と思っていたら、発見!

  const trigger_time = date.setUTCHours(00, 50, 0);
  console.log(trigger_time / 1000);

デバッグしたところだけ 1000 で割ってるだけやん ...

  const trigger_time = date.setUTCHours(01, 05, 0) / 1000;

変更し、改めて日時を 2022 05 26 10:05 でセットして 10:05 を待つ。

...
......
......... 来ない、来ないぞ~、なんでや~ (T ^ T)

特に送信結果 result も、 error も吐いてないので、動いてない状態。 うぇーい。

ということで、今日は時間切れ。 続きはこの調査

というわけで

sezemi_adminsezemi_admin

scheduleMessage() の仕様を見ても引数の漏れはなく、単純な typo も無かった

UTC で 9 時間の時差を入れていたけど、これ入れないほうが良いのではと思ったので、やってみても動かんかった ... 。

うーむ、あとは仕様を確認したときに気になったんだけど、 post_at が int なので、今のやつだと float になってしまってるので、それがいかんのかも。

次は UNIXTIME で float にしないやり方があるか、調査してみよう

sezemi_adminsezemi_admin

setUTChours() の戻り値がミリ秒単位で戻るので、 / 1000 してるんだけど、これで float になって動いてないのかもと推測( scheduleMessage() の仕様では int )。

Required arguments

というわけで、

  • setUTChours() の戻り値をミリ秒単位にしないやり方

これ調査してみたけど、引数の option は特に無いし、他のメソッドも無さそうだった。

うーむ、他に無いかなぁと考えたところ、小数点切り捨てで良いのでは ... と閃いた(そんな大層なものではないが w)。

そう思って、試しに小数点を切り捨てた数値で UNIX 時間から時間に変換すると、

  • 1653991200.188 -> 1653991200 -> 2022-05-31 19:00:00

となったので、行けそう!

というわけで、小数点切り捨ては色々やり方があるんだけど、 メジャーっぽい Mathtrunc() が良さそうだった。

[Math.trunc() - JavaScript | MDN](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Math/trunc}

というわけで trigger_time を出すプログラムを以下のように変更。

const trigger_time = Math.trunc(date.setUTCHours(10, 00, 0) / 1000); // 1653991200

次はこれで動かして見るぞい

sezemi_adminsezemi_admin

動かしてみたところ、、、ぐぬう、動かんかった。 error 吐いてくれれば、まだ何か掴めんだけど。

と仕様を見ていると、 api が test 出来る様子

chat.scheduleMessage method | Slack

api test

これで token, channelId, post_at, text を入れてテストしてみると、ナント

{
    "ok": false,
    "error": "invalid_auth"
}

ええー、そもそも認証でコケてたんかい ...

で、ここで時間切れ。 次回はこの test を通すところから

sezemi_adminsezemi_admin

久々に再開。

API のテストをするところから。

api_test

パラメータを↑で設定テストしたところ、

{
    "ok": true,
    "scheduled_message_id": "********",
    "channel": "************",
    "post_at": 1655945100,
    "message": {
        "bot_id": "******,
        "type": "message",
        "text": "Today is Thr.",
        "bot_profile": {
            // 略
            },
            "team_id": "*********"
        },
        "blocks": [
            {
                "type": "rich_text",
                "block_id": "Lw0dv",
                "elements": [
                    {
                        "type": "rich_text_section",
                        "elements": [
                            {
                                "type": "text",
                                "text": "Today is Thr."
                            }
                        ]
                    }
                ]
            }
        ]
    }
}

で無事に通った!

で、投稿する時間を待っていると、

result

無事に投稿された! ヤター! 時差を考慮した UTC にしないといけないこともわかったので、これでアプリを動かしてみる。

ドキドキしながら待っていると、、、 orz 今日もアカンかった。

もしかすると、ちゃんとインスタンス化できてないのかもと思って、 init の処理を追加

app.js
const { WebClient, LogLevel } = require("@slack/web-api");

const client = new WebClient(process.env.SLACK_BOT_TOKEN, {
  // LogLevel can be imported and used to make debugging simpler
  logLevel: LogLevel.DEBUG
});

ちゃんと log に web client initialized と出るようになったけど、それでもアカンかった ... 。

なぜじゃ ...

sezemi_adminsezemi_admin

とりあえず slack/web-api の簡単なサンプルを動かしてみることにした。

(async () => {
  const result = await client.chat.postMessage({
    text: 'WebClient test.',
    channel: conversationId,
  });
})

これがまた動かない ... orz 。 と思って、サンプルをしげしげと見てみると、

(async () => {
  // 
}}();

の最後の (); が無いことに気づいた。

あー、これだ! と思って、書き換えて実行してみると、

$ node app.js 
[DEBUG]  web-api:WebClient:0 initialized
[DEBUG]  web-api:WebClient:0 apiCall('chat.postMessage') start
[DEBUG]  web-api:WebClient:0 will perform http request

ちゃんとレスがきて、

webclient_test

動いた!

Slack の API じゃなくて、俺の async が間違っていたのか ... orz

というわけで、指定曜日にメッセージを出すプログラムを変更。

app.js
if ( dateOfWeek === 5) {
  // 日時を設定
  const trigger_time = Math.trunc(date.setUTCHours(00, 55, 0) / 1000);
  console.log(trigger_time);

  // channelId を定義
  const channelId = "**************";

  // メッセージを送信
  (async () => {
    try {
      const result = await client.chat.scheduleMessage({
        channel: channelId,
        text: "Today is Fri.",
        post_at: trigger_time,
      });

      console.log(result);
    }
    catch(error) {
      console.error(error);
    }
  })();
}

動かしてみると、素晴らしい! ちゃんと result が出力されて(!)、

$ node app.js 
[DEBUG]  web-api:WebClient:0 initialized
[DEBUG]  web-api:WebClient:0 apiCall('chat.scheduleMessage') start
[DEBUG]  web-api:WebClient:0 will perform http request
⚡️ Bolt app is running!
[DEBUG]  web-api:WebClient:0 http response received
{
  ok: true,
  scheduled_message_id: '***********',
  channel: '***************',
  post_at: 1656032100,
  message: {
    bot_id: '**********************,
    type: 'message',
   //
    },
    blocks: [ [Object] ]
  },
  response_metadata: {
    scopes: [
      'chat:write',
      'channels:history',
      'groups:history',
      'im:history',
      'mpim:history',
      'users:read'
    ],
    acceptedScopes: [ 'chat:write' ]
  }
}

メッセージがポストされたゾイ!

scheduledMesaage

いえーい !!

このログを振り返ってみると、 3 ヶ月かかってた ... 。 トホホ。 まぁ前に進めて良かった。

次は text じゃなくて blocks を送るかな。 とりあえず次に考えよう~

sezemi_adminsezemi_admin
  1. App が特定の曜日になるとランダムでメンバーを選びメッセージする
  • メッセージ内容は固定でフィードバックの趣旨を説明するもの
  • リストを出すボタンを用意する
  1. フィードバックを受ける人がボタンを押して App がフィードバックする候補リストをモーダルで出す
  2. フィードバックを受ける人がフィードバックしてもらいたい人を 5 人選ぶ
sezemi_adminsezemi_admin

次のユーザーストーリーはこちら

  1. App が特定の曜日になるとランダムでメンバーを選びメッセージする
    • メッセージ内容は固定でフィードバックの趣旨を説明するもの
    • リストを出すボタンを用意する
  2. フィードバックを受ける人がボタンを押して App がフィードバックする候補リストをモーダルで出す
  3. フィードバックを受ける人がフィードバックしてもらいたい人を 5 人選ぶ

'1.' のメンバーへのメッセージの中に 2. 3. まで含めて Block を送れるとよいと思って調べる。

調べてみると、以下のようなサンプルがあったが、これはメッセージにボタンを入れて、そのボタンからモーダルを開くというもの。

これもありっちゃありなんだが、できるだけ 1 回で済ませたいので、引き続き調べる

そもそも scheduleMessage() の引数で Block を取れるので、その仕様を確認してみた。

A JSON-based array of structured blocks, presented as a URL-encoded string.
Example

[{"type": "section", "text": {"type": "plain_text", "text": "Hello world"}}]

その欄にあったリンクをたどると、良さげな感じのやつが出てきた。

Blocks are a series of components that can be combined to create visually rich and ompellingly interactive messages.
Read our guide to building block layouts to learn where and how to use each of these components. You can include up to 50 blocks in each message, and 100 blocks in modals or home tabs.
The lists of fields and values below describe the JSON that apps can use to generate each block:

そのまま送れそうぜよ。

次はこれを詳しく読んで、 Block のサンプルをとりあえず送ってみよう

sezemi_adminsezemi_admin

まず使いたいコンポーネントを整理する。

  • メンバーリストのチェックボックス(レビュアーを複数選択)
  • ボタン(依頼するボタン)

これで Refernce を辿ってみると、ドンピシャで users_list を出せて複数選択できるやつがあった。

Reference: Block elements | Slack

で、ボタンは特に button と書けば良いっぽいので、 ↑ をもとに Block Kit Builder を使って書いてみた

{
	"blocks": [
		{
			"type": "actions",
			"block_id": "actions1",
			"elements": [
				{
					"type": "section",
					"block_id": "section678",
					"text": {
						"type": "mrkdwn",
						"text": "Pick users from the list"
					},
					"accessory": {
						"action_id": "text1234",
						"type": "multi_users_select",
						"placeholder": {
							"type": "plain_text",
							"text": "Select users"
						}
					}
				},
				{
					"type": "button",
					"text": {
						"type": "plain_text",
						"text": "Cancel"
					},
					"value": "cancel",
					"action_id": "button_1"
				}
			]
		}
	]
}

これだとエラーが出ていて、候補で出てきているものを入力してもエラーになる。

error

うーむ、やっぱり一足飛びにはできないなぁ。 続きはもう少しちゃんと Block Kit の初歩から始めてみよう。

sezemi_adminsezemi_admin

久々に時間が取れた。 Block Kit Builder で試すところからスタート。

と思ったけど、これまた API と同じで上から順番に読んでも仕方がなく、自分のやりたいことから調べる。

メッセージで出したいものをまとめる

  • 説明文
  • チェックボックス
    • ユーザーリスト
  • 送信ボタン(選択したユーザーをフィードバックフォーム送信処理に渡す)

これを Block Kit Builder で作ろう。

まず Block type で使えるものを整理

Block type

Actions にチェックボックスやボタンっぽいのがありそうなので見てみると、 elements という key にそれが指定できそう。

Reference: Block elements | Slack

なんだ actions だけでなく、 section, context, input の Block type で使えるやん。

というところで、ここまで調べたところで終了。 とりあえず次はボタン出して、説明文を載せる Block を作るゾイ

sezemi_adminsezemi_admin

フィードバックをリクエストする説明文を書くところから。

section はシンプルなテキストを送る Block なので、これを使う。 で、 textplain textmkdwn を選べる。 まぁ、普通に Slack のマークダウンを使えば良いので、これは mkdwn で良い

Reference: Layout blocks | Slack

というわけで説明文を書いてみた。

{
	"blocks": [
		{
			"type": "section",
			"text": {
				"type": "mrkdwn",
				"text": "毎週月曜は *フィードバックリクエスト* を送る日だよ\n今回は誰にフィードバックをもらおうか?\nメンバーから *3 人* 以上選んでね"
			}
		},
		{
			"type": "divider"
		}
	]
}

で、 Block Kit から送信。

sendingtogeneral

無事に受信できた。

recieveatgeneral

今日はここまで。 次は Button ではなく、 user_list をチェックボックスで出すのをやる

sezemi_adminsezemi_admin

Checkbox を出せる Block type はなにかなと見ていると、 input がそれっぽかったので、見てみるとビンゴ。

https://api.slack.com/reference/block-kit/blocks#input

A block that collects information from users - it can hold a plain-text input element, a checkbox element, a radio button element, a select menu element, a multi-select menu element, or a datepicker.

あとはチェックボックス

https://api.slack.com/reference/block-kit/block-elements#checkboxes

これを見て見よう見まねで書いてみたんだけども、、、案の定エラーでまくり

		{
			"type": "input",
			"label": "plain_text",
			"options": [
				{
					"value": "A",
					"text": {
						"type": "plain_text",
						"text": "checkbox 1"
					}
				},
				{
					"value": "B",
					"text": {
						"type": "plain_text",
						"text": "checkbox 2"
					}
				}
			],
			"element": {
				"type": "checkboxes",
				"action_id": "this_is_an_action_id"
			},
			"initial_options": [
				{
					"value": "A1",
					"text": {
						"type": "plain_text",
						"text": "Checkbox 1"
					}
				}
			]
		}

Block Error

うーむ ... 。

というところで時間切れ。 ちょっと input のサンプルの JSON から始めてみよう

sezemi_adminsezemi_admin

引き続き、 Block Kit Builder を使ってメッセージのエラーを修正。

エラーメッセージが "element" にもあってそこに "options が無いよ" ということだったので、 element キーにネストで入れてみると、エラーが消えた。 はよ、言うて~

で、書いたところ、無事に表示されたゾイ

{
	"blocks": [
		{
			"type": "input",
			"label": {
				"type": "plain_text",
				"text": "メンバーを 3 人以上選んでね",
				"emoji": true
			},
			"element": {
				"type": "checkboxes",
				"action_id": "this_is_an_action_id",
				"options": [
					{
						"value": "member_id_1",
						"text": {
							"type": "plain_text",
							"text": "リーダー"
						}
					},
					{
						"value": "member_id_2",
						"text": {
							"type": "plain_text",
							"text": "メンバーさん"
						}
					}
				]
			}
		}
	]
}

checkboxes

というわけで、前回までに作ったメッセージを入れてみた。

message

ええやんええやん。

というわけで、 User List を出すやつを取り組む。

ちょっと時間が無かったので、軽くドキュメントを読んだんだけど、 element で指定せよ、とのこと。 マジか。 check box やらんでよかった ...

The type of element. In this case type is always multi_users_select .

https://api.slack.com/reference/block-kit/block-elements#users_multi_select

次回は実際にこの User List を使って見るところから

sezemi_adminsezemi_admin

User List のサンプル通りに書いてみたら、なんというかでけた

user_list

{
	"blocks": [
		{
			"type": "input",
			"label": {
				"type": "plain_text",
				"text": "メンバーを 3 人以上選んでね",
				"emoji": true
			},
			"element": {
				"action_id": "this_is_an_action_id",
				"type": "users_select",
				"placeholder": {
					"type": "plain_text",
					"text": "メンバーは3人以上"
				}
			}
		}
	]
}

ん? これだと単一ユーザやんと思って見てみると、 multi があった

Reference: Block elements | Slack

で、このサンプル通り書いてみたら、これまたスンナリできてしまった ...

multi_users_select

{
	"blocks": [
		{
			"type": "input",
			"label": {
				"type": "plain_text",
				"text": "メンバーを 3 人以上選んでね",
				"emoji": true
			},
			"element": {
				"action_id": "select_member",
				"type": "multi_users_select",
				"placeholder": {
					"type": "plain_text",
					"text": "メンバーは5人まで選べるよ"
				},
				"max_selected_items": 5
			}
		}
	]
}

いや、スンナリすぎる。。

というわけで、次は下に送信ボタンを作って、押すと、この選んだメンバーメッセージを送信するやつをやる

sezemi_adminsezemi_admin

選んだメンバーにメッセージを送信する処理について調べてみると、 app.action() が適任っぽい。

Slack | Bolt for JavaScript - レファレンス - リスナー関数 - メソッド

で、これどうやって使うのかと調べていると、

  1. multi_users_select などから action_id や、 button から block_id をリッスン
  2. action の引数にリッスンしたものと、やりたい処理を入れて実行

という流れかな。

アクションのリスニング

ほどよいサンプルコードを探したんだけど、探しきれず、今日はタイムオーバー。

sezemi_adminsezemi_admin

メッセージでインタラクティブなやり取りをする流れをまとめたリファレンスを見つけたので読む

Creating interactive messages | Slack

The payload will be sent to your configured Request URL in an HTTP POST. The body of that request will contain a payload parameter. Your app should parse this payload parameter as JSON.
HTTP POST で JSON で書かれた payload が送られてくる

The most important is the required acknowledgment response. This response is sent back to the same HTTP POST that delivered the interaction payload.
同じ HTTP POST のレスポンスで返す

この payload をどこで受信するのか、今回は Socket Mode を使っているので、調べたらこれもリファレンスもあった

Socket Mode implementation | Slack

続きはこれを読んで payload を確認するところから

sezemi_adminsezemi_admin

前回見つけた Socket Mode implementation のリファレンスを読んでいると、序盤に intro があったので、まずそっちを先に読む。

Intro to Socket Mode

ざっと関連することをメモ

  • Slack will use a WebSocket URL to communicate with your app.
  • It's created at runtime by calling the apps.connections.open method, and it refreshes regularly.
  • Your app-level token allows your app, either directly or with an SDK, to generate a WebSocket URL for communication with Slack via the apps.connections.open method.
  • Here's some code to get your app running in Socket Mode using Bolt:
// Require the Node Slack SDK package (github.com/slackapi/node-slack-sdk)
const { WebClient, LogLevel } = require("@slack/web-api");

// WebClient instantiates a client that can call API methods
// When using Bolt, you can use either `app.client` or the `client` passed to listeners.
const client = new WebClient("xoxb-your-token", {
  // LogLevel can be imported and used to make debugging simpler
  logLevel: LogLevel.DEBUG
});
const { App } = require('@slack/bolt');

const app = new App({
  token: process.env.BOT_TOKEN,
  appToken: process.env.SLACK_APP_TOKEN,
  socketMode: true,
});

(async () => {
  await app.start();
  console.log('⚡️ Bolt app started');
})();

Socket Mode はすでに enable しているで、これは OK 。

肝心なのが、この中にある apps.connections.open() で、これは初めてみた。リンクされたドキュメントはこちら。

apps.connections.open method | Slack

このメソッドはまた今度試してみるとして、これでイントロはオシマイ。

で、続きは implementation のリファレンスに戻って読むところから

sezemi_adminsezemi_admin

Socket Mode implementation | Slack を読むところから再開。

読んでいると、いきなり apps.connections.open() の話が出てきた。

Call the apps.connections.open endpoint with your app-level token to receive a WebSocket URL:

ふむ、 apps.connections.open のエンドポイントにアプリの token と一緒に POST すると、 WebSocket URL を受け取れるとのこと。

curl -X POST "https://slack.com/api/apps.connections.open" \
-H "Content-type: application/x-www-form-urlencoded" \
-H "Authorization: Bearer xapp-1-123"

ただ、このコマンドのあとにあった説明、

Remember to send the token in the Authorization header, not as a parameter.

一瞬この意味がわからんかったが、 curl -H でヘッダーを指定するオプションだったので、

-H "Authorization: Bearer xapp-1-123"

のお話だった。 token は xapp-1-123

一応 token を bash_profile で確認できたので、次回はこの curl コマンドを叩いてみよう

sezemi_adminsezemi_admin

curl コマンドを叩くところから再開

$ curl -X POST "https://slack.com/api/apps.connections.open" \
> -H "Content-type: application/x-www-form-urlencoded" \
> -H "Authorization: Bearer xapp-1-2345"
{"ok":true,"url":"wss:\/\/wss-primary.slack.com\/link\/?ticket=12345&app_id=abcd"}

おっけー、無事に帰ってきた!

で、続きにこの WebSocket に繋いでみるサンプルコードがあった

if (response.ok) {
  let wssUrl = response.url;
  let socket = new WebSocket(wssUrl);

  socket.onopen = function(e) {
    // connection established
  }

  socket.onmessage = function(event) {
    // application received message
  }
}

が、この response って、すでに endpoint に送って JSON が返ってきてるって前提なんだろうけど ...

さっきの curl コマンドを JS で実行する、っちゅうことなんだろうなぁ、と思って調べてみる。

Fetch API がいい感じっぽい。

JavaScript による HTTP(S)リクエスト送信のいろいろな書き方 – ラボラジアン

ここまで調べたところで時間終了。

つづきはこの Fetch API を使って request を出すところから

sezemi_adminsezemi_admin

Fetch API で request を出すコードを書くところから再開。

見様見真似で書いてみた。

app.js
fetch('https://slack.com/api/apps.connections.open', {
  headers: {
    Accept: "Content-type: application/x-www-form-urlencoded",
    Authorization: "Bearer " + process.env.SLACK_APP_TOKEN,
  }
}).then(response => {
  if (response.ok) {
    let wssUrl = response.url;
    // let socket = new WebSocket(wssUrl);

    console.log(wssUrl);

    /*
    socket.onopen = function(e) {
      // connection established
    }

    socket.onmessage = function(event) {
      // application received message
    }
    */
  }
  throw new Error('Network response was not ok.');
})

動かしてみると、壮大にエラー。

fetch('https://slack.com/api/apps.connections.open', {
^

ReferenceError: fetch is not defined
    at Object.<anonymous> (/home/hoppers/first-bolt-app/app.js:122:1)
    at Module._compile (node:internal/modules/cjs/loader:1103:14)
    at Object.Module._extensions..js (node:internal/modules/cjs/loader:1155:10)
    at Module.load (node:internal/modules/cjs/loader:981:32)
    at Function.Module._load (node:internal/modules/cjs/loader:822:12)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:77:12)
    at node:internal/main/run_main_module:17:47

しかも fetch is not defined って ...

調べてみると、

fetch is not a standard nodejs method - you need node-fetch
javascript - ReferenceError: fetch is not defined - Stack Overflow

そうなのか ... うーむ。

引き続き、ちょっと調べていると、参考になるものがあったので、次回やってみよう。

fuwamaki-media | Slack APIを用いてnode.jsスクリプトでメッセージ送信

sezemi_adminsezemi_admin

ちょこちょこ調べてたんだけど、ようやくまとまった時間が取れた。

で、そのちょこちょこ調べているときに、そもそも今みてるドキュメント、 Socket Mode implementation は Bolt など SDK 使わずに実装する人向けだった。 あたしゃ、そもそもせーへんやつ ... なんというムダなことを。 ちゃんと読もう。

さらに、 apps.connections.open メソッドも Websocket URL が必要な場合であって、 Bolt にはリクエストの payload をリッスンするメソッドがそもそも用意されているのであった ... 。

アクションのリスニング

メソッドはその名の通り action()action_id と行う処理を引数にするもの。

というわけで、この action() を試せるコードが無いかと調べてみる。

調べてみると、そもそも参考にしていた記事にいい感じに試しているコードがあった。 うーむ、ちゃんと読もう 第 2 弾。

アクションの追加

という軌道修正をしたところで、タイムアップ。

続きは、この action() のサンプルをもとに自分で試してみるところから

sezemi_adminsezemi_admin

サンプルコードを試すところから、と言っていたら、前に書いたことがあったのだった ... 。 スクラップに残しても、こういうことは忘れてしまう。

サンプルコードを見るに、message を送る処理で block 内に action_id を入れて、 action は別に切り出すのね。

あとは入力されたメンバーを確認するのにどうするか、調べてみると、同じように options() を使うみたい。

ん? 違うな

外部データソースを使用するセレクトメニューなどから送られる選択肢読み込みのリクエストをリッスンします。

と思って調べると、そうか、そもそも action() で取れる引数は action_id だけじゃないのね。

リスナー関数の引数

というわけで、この前の特定曜日にメッセージを送る処理に、 block を追加したところでおしまい。

app.js
  (async () => {
    try {
      const result = await client.chat.scheduleMessage({
        blocks: [
          {
            "type": "header",
            "text": {
              "type": "plain_text",
              "text": "Request Feedback",
              "emoji": true
            }
          },
          {
            "type": "divider"
          },
          {
            "type": "section",
            "text": {
              "type": "mrkdwn",
              "text": "*Today is a Feedback Day!* Select members you want feedback from."
            }
          },
          {
            "type": "input",
            "element": {
              "type": "multi_users_select",
              "placeholder": {
                "type": "plain_text",
                "text": "Select users",
                "emoji": true
              },
              "action_id": "multi_users_select_action"
            },
            "label": {
              "type": "plain_text",
              "text": "フィードバックを依頼するメンバーを 3 人以上選んでね",
              "emoji": true
            }
          },
          {
            "type": "actions",
            "elements": [
              {
                "type": "button",
                "text": {
                  "type": "plain_text",
                  "text": "フィードバックを依頼する :Envelope:",
                  "emoji": true
                },
                "value": "requests",
                "action_id": "feedback_requests"
              }
            ]
          }
        ],
        channel: channelId,
        post_at: trigger_time,
      });

      console.log(result);
    }
    catch(error) {
      console.error(error);
    }
  })();

続きは、この action_id をリッスンする action() を書くところから

sezemi_adminsezemi_admin

action() の処理を書くところから再開。 と言っても、ほぼコピペ

app.js
app.action("multi_users_select-action", async({ ack, body }) => {
  await ack();
  console.log(body);
});

この前 blocks を入れた scheduledMessage() を動かして、実際に body を確認してみる。

と動かすと、 scheduledMessage() 引数に text 無いでってエラー。

$ node app.js 
[DEBUG]  web-api:WebClient:0 initialized
:
:
[DEBUG]  web-api:WebClient:0 apiCall('chat.scheduleMessage') start
[WARN]  web-api:WebClient:0 The `text` argument is missing in the request payload for a chat.scheduleMessage call - It's a best practice to always provide a `text` argument when posting a message. The `text` is used in places where the content cannot be rendered such as: system push notifications, assistive technology such as screen readers, etc.
:
:
[ERROR]  web-api:WebClient:0 missing required field: text
Error: An API error occurred: invalid_arguments
    at platformErrorFromResult (/home/user/first-bolt-app/node_modules/@slack/web-api/dist/errors.js:51:33)
    at WebClient.apiCall (/home/user/first-bolt-app/node_modules/@slack/web-api/dist/WebClient.js:167:56)
    at processTicksAndRejections (node:internal/process/task_queues:96:5)
    at async /home/hoppers/first-bolt-app/app.js:108:22 {
  code: 'slack_webapi_platform_error',
  data: {
    ok: false,
    error: 'invalid_arguments',
    response_metadata: { messages: [Array], scopes: [Array], acceptedScopes: [Array] }
  }
}

うーん、 block があるんだけどな、と思って、 scheduledMesaage() のドキュメント見てみると、 text は必須だった。

chat.scheduleMessage method | Slack

その text のところに注釈があったので見てみると、ちゃんと block を使ったときのことを書いてた。

The usage of the text field changes depending on whether you're using blocks. If you are using blocks, this is used as a fallback string to display in notifications. If you aren't, this is the main body text of the message. It can be formatted as plain text, or with mrkdwn.
テキストフィールドは、ブロックを使用しているかどうかで使い方が変わります。ブロックを使用している場合、これは通知で表示されるフォールバック文字列として使用されます。ブロックを使用していない場合は、これがメッセージの本文になります。プレーンテキスト、またはmrkdwnでフォーマットすることができます。

ふむふむ ... フォールバック文字列とは?

調べてみると、文字化けしたときの代替表示ということだったので、適当に "ミスったよ" みたいな文字列入れて text を引数に追加。

というわけで、メッセージ送信日時を設定して、メッセージを待つ(一応、動かして No Error だった)。

いえーい、キタ!

message

えへへ。 うれしいねぇ

実際にメンバーを入力してボタンを押すと ... ちゃんとリッスンして body が出てきたぞい

{
  type: 'block_actions',
  user: {
    id: '****************',
    username: '****',
    name: '****',
  },
  container: {
    type: 'message',
    is_ephemeral: false
  },
  team: { id: '******', domain: 'sandboxapi' },
  channel: { id: '******', name: 'general' },
  message: {
    bot_id: 'B02RG11KGEL',
    type: 'message',
    text: 'Woops, something wrong.',
    blocks: [ [Object], [Object], [Object], [Object], [Object] ]
  },
  actions: [
    {
      type: 'multi_users_select',
      action_id: 'multi_users_select-action',
      block_id: 'DIr4',
      selected_users: [Array],
      action_ts: '1661993163.947008'
    }
  ]
}

えー、 [Array] って ... 。そこは出して。。

あと、ボタン押したあと、何もリアクション無いのはやっぱ辛いので、追加しよう。

アクションへの応答

これを見ると、 say() とか respond() 使えるみたいね。

というわけで、続きはリアクションを書くところから

sezemi_adminsezemi_admin

ボタンなどを押したアクションへの応答を書いてみた。

app.js
app.action("feedback_requests", async({ body, ack, say }) => {
  await ack();
  console.log(body);
  await say("フィードバックのリクエスト受け取りました!")
});

とりあえず say() でやってみたんだけど、どうも Button 要素の action_id = feedback_resquests だと選んだユーザが取れず、ユーザを選択する action multi_users_select-action にするとユーザが取れた。

で、挙動はやっぱり ユーザ選択 > ボタン という流れが自然なので、 input type を actions type に入れられるよう、 block を編集する。

調べていると、なんと actions の button だと input の payload を受け取れないっぽい。。 そんなん、ある?

app.action(actionId, fn);
... Note that action elements included in an input block do not trigger any events.
なお、 input ブロックに含まれる action エレメントは、イベントを発生させない。
Listener functions

どうやら、 modal でしか、選んだユーザをボタン submit で確定させるということができないっぽい。

If you're using any input blocks, you must include the submit field when defining your view.
Gathering user input

えー、マジで。 次回もちょっと他に手がないか調べてみて無かったら、モーダルでユーザを開くようにメッセージを変更する。

ぐぬう

sezemi_adminsezemi_admin

section タイプで accessory にするといい感じにいけるっぽい。

Section Block - Example

{
	"blocks": [
		{
			"type": "header",
			"text": {
				"type": "plain_text",
				"text": "This is a header block",
				"emoji": true
			}
		},
		{
			"type": "section",
			"text": {
				"text": "*Sally* has requested you set the deadline for the Nano launch project",
				"type": "mrkdwn"
			},
			"accessory": {
				"type": "multi_users_select",
				"action_id": "selected_users",
				"placeholder": {
					"type": "plain_text",
					"text": "Select Users"
				}
			}
		}
	]
}
sezemi_adminsezemi_admin

前回チラッと書いた section と accesory を組み合わせた block をちゃんと編集する。

{
	"blocks": [
		{
			"type": "header",
			"text": {
				"type": "plain_text",
				"text": "フィードバックをリクエストしよう!",
				"emoji": true
			}
		},
		{
			"type": "section",
			"text": {
				"text": "今日はフィードバックをリクエストする日です。\nフィードバックをもらいたいメンバーを *3 名* 以上選んでください。",
				"type": "mrkdwn"
			},
			"accessory": {
				"type": "multi_users_select",
				"action_id": "selected_users",
				"placeholder": {
					"type": "plain_text",
					"text": "メンバーを選ぶ"
				}
			}
		}
	]
}

これでメッセージも変更

app.js
      const result = await client.chat.scheduleMessage({
        channel: channelId,
        post_at: trigger_time,
        text: "Woops, something wrong.",
        blocks: [
          {
            "type": "header",
            "text": {
              "type": "plain_text",
              "text": "フィードバックをリクエストしよう!",
              "emoji": true
            }
          },
          {
            "type": "divider"
          },
          {
            "type": "section",
            "text": {
              "text": "今日はフィードバックをリクエストする日です。\nフィードバックをもらいたいメンバーを *3 名* 以上選んでください。",
              "type": "mrkdwn"
            },
            "accessory": {
              "type": "multi_users_select",
              "action_id": "selected_users",
              "placeholder": {
                "type": "plain_text",
                "text": "メンバーを選ぶ"
              }
            }
          }
        ],
      });

これで日時を設定して実際に payload を確認してみる。

無事にメッセージを受信。

message

メンバーを選んでみる。 say() も動いた。 挙動としてはバッチリ!

say

payload は ...

{
  type: 'multi_users_select',
  action_id: 'selected_users',
  block_id: '****',
  selected_users: [ 'U02Q******, 'U02Q******' ],
  action_ts: '*******************'
}

ヤター、取れた!

次はこの users を repond() で出してみて、その選んだユーザにメッセージを送信する処理を書こう。

いえーい

sezemi_adminsezemi_admin

先日の payload だとリクエストを出した user が取れないことに気づいたので、他で試してみる。

で、 action()block_id を設定してみたり、単に block_actions にしてみたけど、まったくレスポンスが取れない。 ぐぬう。

結局、 action_id に戻して body からアクセスして取ることに。

app.js
app.action("selected_users", async({ body, ack, say }) => {
  await ack();
  console.log(body["user"];
  console.log(body["actions"][0]["selected_users"]);
  await say("フィードバックのリクエスト受け取りました!")
  // await respond('フィードバックを依頼する相手は <@${action.}>')
});

結果がこちら。

{
  id: 'U02Q********',
  username: '*****',
  name: '*****',
  team_id: '*******'
}
[ 'U02Q********, 'U02Q*******' ]

無事に取得できたぞい。 ようやく続きは respond() を書くところから

sezemi_adminsezemi_admin

ちょこちょこやってたんだけど、まとめて時間が取れたので更新

  1. multi_users_select で取れたのが、 id だけだったので、これを名前にする必要があった
  2. API に users.info() というのがあるので、それを使ってみる

users.info method | Slack

こんな感じで書いた。

app.js
app.action("selected_users", async({ body, ack, respond }) => {
  await ack();
  const selected_users_id = body["actions"][0]["selected_users"];
  await selected_users_id.forEach(selected_user_id => {
    const user = client.users.info({
      user: selected_user_id
    });
    respond(`フィードバックを依頼した相手は <@${user.user.name}> さんですね`);
});

でも undefined エラーで一旦、 users.info() のレスポンスを出力したところ、

Promise { <pending> }

で Promise が返ってきてしまっていた ... 。ぐぬう。

await 内 に aync - await を持たせたいんだけど、 forEach でどう書くのだろうと思って調べるとあったあった。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach#解説

これを真似して書いてみた。

app.js
app.action("selected_users", async({ body, ack, say, respond }) => {
  await ack();
  const selected_users_id = body["actions"][0]["selected_users"];
  await selected_users_id.forEach(async (selected_user_id) => {
    const user = await client.users.info({
      user: selected_user_id
    });
    await respond(`フィードバックを依頼した相手は <@${user.user.name}> さんですね`);
    console.log(user.user.name);
  });
});

これでやってみたところ ...

respond

practicemakeslackapp
****se

でけました。

次は、 select したユーザにメッセージを送る処理を追加するところから。

sezemi_adminsezemi_admin

選んだユーザにメッセージを送るには、 chat.postMessage() を使う。

Post to a direct message channel

channel に user_id を入れれば OK 。ただし、注意点がこちら

You might receive a channel_not_found error if your app doesn't have permission to enter into a DM with the intended user.
アプリが目的のユーザーとのDMに入る権限を持っていない場合、channel_not_foundエラーが表示される場合があります。

ちょっとパーミッションの与え方がどうなるか不明なので、今後確認が必要

というわけで書く。

app.js
app.action("selected_users", async({ body, ack, respond }) => {
  await ack();
  const selected_users_id = body["actions"][0]["selected_users"];
  await selected_users_id.forEach(async (selected_user_id) => {
    const user = await client.users.info({
      user: selected_user_id
    });
    await respond(`フィードバックを依頼した相手は <@${user.user.name}> さんですね`);
    console.log(user.user.name);

    try {
      const result = await client.chat.postMessage({
        channel: user.user.id,
        text: "テストメッセージだよ"
      });
      console.log(result);
    }
    catch (error) {
      console.log(error);
    }
  });
});

forEach の中で一応 try catch で書いた。

次回はこれを試して見るところから。

sezemi_adminsezemi_admin

前回書いた、選んだ相手にメッセージを送信する処理を試してみる。

無事に送れた~。

test_message

ただ、 respond() で送る相手を返すところ、どうも 2 人目も同じ名前を返していて、おかしい。

hatena

うーん、ただ選んだ相手の名前を伝えるだけだから、 say() でやってみるかと変えてみた。

app.js
  await selected_users_id.forEach(async (selected_user_id) => {
    const user = await client.users.info({
      user: selected_user_id
    });
    console.log(user.user.name);
    await say(`フィードバックを依頼した相手は <@${user.user.name}> さんですね`);

    try {
      const result = await client.chat.postMessage({
        channel: user.user.id,
        text: "テストメッセージだよ"
      });
      console.log(result);
    }
    catch (error) {
      console.log(error);
    }
  });

動かしてみると、うまく出た。

select_user

なぜかは知らん。。。

というわけで、次は送った相手にフィードバックフォームのモーダルを block kit で作る。 いよいよ佳境だ

sezemi_adminsezemi_admin

フィードバック入力フォームを作る前に、先にフィードバックをリクエストしたユーザを、依頼メッセージにいれることをやる

app.js
app.action("selected_users", async ({ body, ack, say }) => {
  await ack();
  const requested_user = body.user.username;
  // 中略

    try {
      const result = await client.chat.postMessage({
        channel: user.user.id,
        text: `<@${requested_user}> さんからのテストメッセージだよ`
      });
      console.log(result);
    }
    catch (error) {
      console.log(error);
    }
  });
});

無事にメッセージに入れられた。

message_by

続いて、 modal のリファレンスを調べた。

Using modals in Slack apps | Slack

次はこれを読むところから

sezemi_adminsezemi_admin

modal のリファレンスから、まず modal の open -> submit までをまとめた。

  1. trigger_id で open
  2. initial view を表示
  3. submit すると状態がかわる

で、 1. で使うのは views.open()

views.open API method

サンプルコードもあるので、これを試してみたいのだけど、見慣れぬコンストラクタ引数があった。

signingSecret: "your-signing-secret",

調べてみると、 User Token のことで new App() するときに、ちゃんと使ってた。 よく見よう n 回目。

というわけで、続きはこの views.open() のサンプルをもとに試してみるところから。 ちょっと気になってるのは、変数 requested_user をモーダルに引き継げるのかどうか

sezemi_adminsezemi_admin

views.open() の前にモーダルの開き方がわからなかったので、調べてた(世の中のサンプルコードはだいたい / コマンドから開く感じだった)。

開くには trigger_id が必要だったんだけど、 button 要素で trigger_id を指定すると、 invalid になってしまう。

悩んでいたところ、 Bolt のリファレンスにあった。

trigger_id はスラッシュコマンド、ボタンの押下、メニューの選択などによって Request URL に送信されたペイロードの項目として入手することができます。
モーダルの開始

ボタンを押すと自動的に trigger_id が発行されるので、それを payload で受け取ればいいのねと理解。

というわけで、 views.open() を書いてみた。

app.js
app.action("open_modal", async ({ body, ack, client, logger}) => {
  await ack();

  try {
    const result = await client.views.open({
      trigger_id: body.trigger_id,
      view: {
        type: 'modal',
        callback_id: "intial_view",
        title: {
          type: "plain_text",
          text: "フィードバック入力フォーム"
        }
      },
    });
    logger.info(result);
  } catch (error) {
    logger.info(error);
  }
});

これで動かしてみると、いえい、動いたぞい。

というわけで、モーダルを Block Kit Builder でこんな感じでデザイン。

feedback_form

これを view.open() に入れる。

app.js
app.action("open_modal", async ({ body, ack, client, logger}) => {
  await ack();

  try {
    const result = await client.views.open({
      trigger_id: body.trigger_id,
      view: {
        type: 'modal',
        callback_id: "intial_view",
        title: {
          type: "plain_text",
          text: "フィードバック入力フォーム"
        },
        submit: {
          type: "plain_text",
          text: "送信",
          emoji: true
        },
        blocks: [
          {
            type: "section",
            text: {
              type: "mrkdwn",
              text: "フィードバックを受け取ると、 *自信* に繋がり、新しい *チャレンジ* を生み出すので、ぜひご協力をお願いします :pray:"
            }
          },
          {
            type: "divider"
          },
          {
            type: "section",
            text: {
              type: "mrkdwn",
              text: "フィードバックする相手を選んでください"
            },
            accessory: {
              type: "users_select",
              placeholder: {
                type: "plain_text",
                text: "メンバー",
                emoji: true
              },
              action_id: "users_select-action"
            }
          },
          {
            type: "input",
            element: {
              type: "plain_text_input",
              multiline: true,
              action_id: "keep_input-action"
            },
            "label": {
              type: "plain_text",
              text: "「いいな」と思うところや見習いたいところを挙げてください",
              emoji: true
            }
          },
          {
            type: "input",
            element: {
              type: "plain_text_input",
              multiline: true,
              action_id: "problem_input-action"
            },
            "label": {
              type: "plain_text",
              text: "ここを変えるともっといいなと思うところを挙げてください",
              emoji: true
            }
          }
        ]
      },
    });
    logger.info(result);
  } catch (error) {
    logger.info(error);
  }
});

これで動かしてみると、

message

open_modal

いえーい、いい感じにできたぞい! マジで、いよいよ感がある。

というわけで、次回はこの payload を受け取る action を書くところから。

sezemi_adminsezemi_admin

modal からの payload を受け取るには app.view() を使うのだけど、この引数が何かわからん。。。

一応、 Bolt のリファレンスを見ると、それらしい記述があるのだが ... 。

モーダルでの送信のリスニング

app.view('view_b', async ({ ack, body, view, client, logger }) => {
  // 処理
});

view_b とは何かわからず、ものは試しで callback_id かと思ってやってみると、取れた!

app.js
// modal の view.open() に callback_id を post_feedback を追加
app.action("open_modal",  async ({ body, ack, client, logger}) => {
  console.log(body);
});

というわけで、 payload から目的の、

  • フィードバックした人
  • フィードバックした相手
  • フィードバック内容
    • Keep なこと
    • Problem なこと

を取得する。

で、この payload がややこしい。 一応ドキュメントにサンプルがあったので、それを参考にする。

Example

ただ、いろいろ書いていない( or ちゃんと見てない)罠があった。

  • action_id に Block Kit のサンプルにあった input-action のように入れてると、 action は予約語らしく denied される
  • block_id が無いと input が取れない
    • view.state.values.block_id.action_id.value のように取る
  • users_select を input にしようとする例がなく、怒られまくりながら modal を変更
    • element を users_select にして label も必須
  • users_select の値を取るのが罠で、 value と書くのでは NG で view.state.values.block_id.acktion_id.selected_user で指定する

というわけで、書いたコードがこちら。

  • modal を表示するやつ
app.js
// 入力フォームを開く
app.action("open_modal", async ({ body, ack, client, logger}) => {
  await ack();

  try {
    const result = await client.views.open({
      trigger_id: body.trigger_id,
      view: {
        type: 'modal',
        callback_id: "post_feedback",
        title: {
          type: "plain_text",
          text: "フィードバック入力フォーム"
        },
        submit: {
          type: "plain_text",
          text: "送信",
          emoji: true
        },
        close: {
          type: "plain_text",
          text: "あとで入力する",
        },
        blocks: [
          {
            type: "section",
            text: {
              type: "mrkdwn",
              text: "フィードバックを受け取ると、 *自信* に繋がり、新しい *チャレンジ* を生み出すので、ぜひご協力をお願いします :pray:"
            }
          },
          {
            type: "divider"
          },
          {
            type: "input",
            block_id: "select_user",
            element: {
              type: "users_select",
              placeholder: {
                type: "plain_text",
                text: "メンバー",
                emoji: true
              },
              action_id: "feedback_user"
            },
            label: {
              type: "plain_text",
              text: "フィードバックする相手を選んでください",
              emoji: true
            }
          },
          {
            type: "input",
            block_id: "keep_value",
            element: {
              type: "plain_text_input",
              multiline: true,
              action_id: "keep_input"
            },
            label: {
              type: "plain_text",
              text: "「いいな」と思うところや見習いたいところを挙げてください",
              emoji: true
            }
          },
          {
            type: "input",
            block_id: "problem_value",
            element: {
              type: "plain_text_input",
              multiline: true,
              action_id: "problem_input"
            },
            label: {
              type: "plain_text",
              text: "ここを変えるともっといいなと思うところを挙げてください",
              emoji: true
            }
          }
        ]
      },
    });
    logger.info(result);
  } catch (error) {
    logger.info(error);
  }
});
  • フォームフォームの入力を受け取って、フィードバック結果を送るやつ
app.js
// フィードバックが入力されたらフィードバック結果をメッセージする
app.view('post_feedback', async ({ ack, body, view, client, logger }) => {
  await ack();

  const feedback_reciever = view.state.values.select_user.feedback_user.selected_user;
  const keep_value = view.state.values.keep_value.keep_input.value;
  const problem_value = view.state.values.problem_value.problem_input.value;
  const feedback_sender = body['user']['name'];
  const feedback_sender_id = body['user']['id'];

  try {
    const result_message_feedback_reciever = await client.chat.postMessage({
      channel: feedback_reciever,
      text: "Woops, something wrong.",
      blocks: [
        {
          "type": "header",
          "text": {
            "type": "plain_text",
            "text": `<@${feedback_sender}> さんからフィードバックが届きました`,
            "emoji": true
          }
        },
        {
          "type": "divider"
        },
        {
          "type": "section",
          "text": {
            "type": "mrkdwn",
            "text": ":one: *「いいな」と思うところや見習いたいところ*"
          }
        },
        {
          "type": "section",
          "text": {
            "type": "plain_text",
            "text": keep_value
          }
        },
        {
          "type": "divider"
        },
        {
          "type": "section",
          "text": {
            "type": "mrkdwn",
            "text": ":two: *ここを変えるともっといいなと思うところ*"
          }
        },
        {
          "type": "divider"
        },
        {
          "type": "section",
          "text": {
            "type": "plain_text",
            "text": problem_value
          }
        }
      ]
    });
    logger.error(result_message_feedback_reciever);
  }
  catch (error) {
    logger.error(error)
  };

  try {
    const result_message_feedback_sender = await client.chat.postMessage({
      channel: feedback_sender_id,
      text: `<@${feedback_reciever}> さんにフィードバックしました`
    });
    logger.error(result_message_feedback_sender);
  }
  catch (error) {
    logger.error(error)
  };
});

これで動かしてみた!

request_feedback

post_feedback

get_feedback

いえーい、これでほぼ完了。

あとは、ランダムにフィードバックを受けるユーザを選ぶところの処理だけ。

sezemi_adminsezemi_admin

ユーザリストを保持するときに、 Slack API client.users.list() を使うんだけど、あくまで Promise を返すので、その処理に詰まっていたら、ハタと気づく。

「あれ、アプリケーションが動いても 1 回実行されたら終わりじゃね」

そう指定曜日にフィードバックを受けるユーザを選んで送っても、そのプログラムは起動時に一度しか動かないので、繰り返し動かないのだった ... 😱😱😱

一応、定期実行するための解決策を探ってみたところ、いくつかあった。

  • GAS のトリガーに定期実行があるので、そこから Slack API を叩いて、 / コマンドを打ち込み起動する
  • Slack のワークフロービルダーで動かす(有料プランのみ)
  • Slack API の Next-gen platform だと Scheduled Triggers が使える
    • 有料プランのみ
    • Bolt だとデプロイとデータストアができない
    • 他の選択肢としては Node.js ではなく Deno を使う
  • Cron 実行する
    • node-cron のようなライブラリでやる

Next-gen にめっちゃ惹かれるが、そもそも MVP を作っているので、ちょっと見合わない。

というわけで、

  • GAS だとスプシを仮のデータストアにできる
    • ユーザリストを保持するのに良さそう( Promise からデータ出せないのでは問題)
  • Cron が一番開発コストが低い
    • ユーザリスト問題は解決できてない

この 2 つかな。

とりあえず、次回もうちょっと考えてみる。

sezemi_adminsezemi_admin

前回の 2 つの課題について、一応 ↓ のようにすることに決めた。

  • ユーザリストを保持するために GAS のスプシにデータストアする
    • ただし定期実行には使わない(特定のプログラムだけ実行したい)
  • 定期実行には Cron を使う

Cron の実装

まず Cron から実装。まずはライブラリを見てみる。

Cron のライブラリ

GitHub と npm で調べたところ、以下 3 つが代表的だった。

使い方はだいたい同じで、クロージャ内に実行したい処理を書く感じだった。

機能もほとんど同じだったんだけど、 Timezone の指定が楽ちんだったのが、 node-cron/node-cron だったので、これを使う。

node-cron のインストールから実行までをやる

Readme にある手順でインストールから動かすところまでやってみる。

$ sudo npm install --save node-cron

up to date, audited 162 packages in 620ms

37 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
let cron = require('node-cron');

cron.schedule('* * * * * *', () => {
  console.log('running a task every minute.');
});

で、実行すると、

$ node app.js 
[DEBUG]  web-api:WebClient:0 initialized
1
⚡️ Bolt app is running!
running a task every minute.
running a task every minute.
running a task every minute.
running a task every minute.
running a task every minute.
running a task every minute.

無事に動いたゾイ。

次に タイムゾーンが東京で、 月曜 9:50 で動かしてみる。

cron.schedule('00 50 09 * * 1', () => {
  console.log('today is Monday.');
}, {
  timezone: "Asia/Tokyo"
});
$ node app.js 
[DEBUG]  web-api:WebClient:0 initialized
1
⚡️ Bolt app is running!
today is Monday.

スムーズ、スムーズ( date で悩んだ過去が嘘みたいなやつ)。

メッセージ送信処理に書き換え

console.log の処理を指定チャンネルにメッセージを送信するものに書き換え。

app.js
cron.schedule('00 50 09 * * 1', () => {
  (async () => {
    try {
      const result = await client.chat.scheduleMessage({
        channel: channelId,
        post_at: trigger_time,
        text: "Woops, something wrong.",
        blocks: [
          {
            "type": "header",
            "text": {
              "type": "plain_text",
              "text": "フィードバックをリクエストしよう!",
              "emoji": true
            }
          },
          {
            "type": "divider"
          },
          {
            "type": "section",
            "text": {
              "text": "今日はフィードバックをリクエストする日です。\nフィードバックをもらいたいメンバーを *3 名* 以上選んでください。",
              "type": "mrkdwn"
            },
            "block_id": "request_feedback",
            "accessory": {
              "type": "multi_users_select",
              "action_id": "selected_users",
              "placeholder": {
                "type": "plain_text",
                "text": "メンバーを選ぶ"
              }
            }
          }
        ],
      });
      console.log(result);
    }
    catch(error) {
      console.error(error);
    }
  })();
}, {
  timezone: "Asia/Tokyo"
});

が、エラー。

ReferenceError: trigger_time is not defined
    at /home/hoppers/first-bolt-app/app.js:38:18
    at Task.cron.schedule.timezone [as _execution] (/home/hoppers/first-bolt-app/app.js:75:5)
    at Task.execute (/home/hoppers/first-bolt-app/node_modules/node-cron/src/task.js:17:25)
    at ScheduledTask.now (/home/hoppers/first-bolt-app/node_modules/node-cron/src/scheduled-task.js:38:33)
    at Scheduler.<anonymous> (/home/hoppers/first-bolt-app/node_modules/node-cron/src/scheduled-task.js:25:18)
    at Scheduler.emit (node:events:520:28)
    at Timeout.matchTime [as _onTimeout] (/home/hoppers/first-bolt-app/node_modules/node-cron/src/scheduler.js:30:26)
    at listOnTimeout (node:internal/timers:559:17)
    at processTimers (node:internal/timers:502:7)

せやせや、 trigger_time いらんかった。 あと、そもそも sceduledMessage() いらんのだった。

というわけで以下を書き換え、実行。

  (async () => {
    try {
      const result = await client.chat.postMessage({
        channel: channelId,
        text: "Woops, something wrong.",
        blocks: [

今度はウンともスンとも言わない結果に。 なぜ???

また、俺の async が間違っているのかも、と思って、これまた書き換え。

app.js
cron.schedule('00 08 10 * * 1', async () => {
  console.log('It`s triggerd')

  try {
    const result = await client.chat.postMessage({
      channel: channelId,
      text: "Woops, something wrong.",
      blocks: [
      ]
    });
    console.log(result);
  catch(error) {
    console.error(error);
  }
}, {
  timezone: "Asia/Tokyo"
});

これで動かしてみると、イエイ、動いた!

$ node app.js 
[DEBUG]  web-api:WebClient:0 initialized
1
⚡️ Bolt app is running!
It`s triggerd
[DEBUG]  web-api:WebClient:0 apiCall('chat.postMessage') start
[DEBUG]  web-api:WebClient:0 will perform http request
[DEBUG]  web-api:WebClient:0 http response received
{
  ok: true,
  :
  :

cron_message

というわけで、次からは、スプシにユーザリストを POST するところを書く。

sezemi_adminsezemi_admin

ちょこちょこ Slack App からの POST 調べてたんだけど、だいたいが、

Slack API <- GAS

で取得しているのがほとんどで、 Slack -> Slack App (Bolt) -> spread sheet は、ほぼなかった。

一応調べている中であったのがこの記事。

My First Custom Slack App - Part 2

ここでやっていたのが、 google-spreadsheet という Google Sheets API のラップするライブラリで POST とかの処理ではなく、シートをオブジェクトにして、そこで API を操作するというものだった。

★ も 1.8k ある。
若干気になるのが、 2022 年 1 月ごろから開発がストップしていて、 issue も放置状態。まぁ、 MVP の検証まで使えれば OK でしょう。

このライブラリであれば GAS で別にファイルを作ることなく、いまのリポジトリ内に閉じて開発できるので、これを使おう。

というわけで、次は Google Sheet API を設定して、このライブラリを動かすサンプルを試すところから。

sezemi_adminsezemi_admin

Google Sheets API の有効化

google-spreadsheet を使っている日本語の記事 をもとに、 Google Sheets API を設定する。

  1. GCP にログイン
  2. 新しいプロジェクト slack-user-store を作成
  3. Google Sheets API を enable
  4. サービスアカウントの作成

で、このサービスアカウント is 何? と思って調べたら、 VM をあたかも user のように扱い、 Key で Authenticate するものだった。

サービス アカウント  |  IAM のドキュメント  |  Google Cloud

  1. 認証キー (JSON) を発行して保存

これで Google Sheets API の設定は完了。

続きは、実際に google-spreadsheet を動かしてみるぞい

sezemi_adminsezemi_admin

まずは google-spreadsheet を npm install

$ sudo npm install --save google-spreadsheet

added 33 packages, and audited 195 packages in 3s

39 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

で、次の手順は ... と思ってみてみると、もうスプレッドシートの操作に入ってた。

以下は, 既存のスプレッドシートへの編集権限がサービスアカウントに与えられていることを前提とします. まず手始めに, スプレッドシートのキーからタイトルを取得してみましょう.

スプレッドシートでテーブルをつくる

というわけで、スプレッドシートを先に作成する。

users.list() のレスポンスを眺めて、テーブルのカラムは id, team_id, name, icon, count_feedbacks に決定。

spreadsheet_table

次回は今度こそこのスプレッドシートを google-spreadsheet で操作するぞい

sezemi_adminsezemi_admin

google-spreadsheet を動かすぞい。

  1. その前に .bash_profile にこの前作ったスプレッドシートの id (SPREAD_SHEET_ID) を追加
  2. google-spreadsheet の import とインスンタンス化
app.js
const { GoogleSpreadsheet } = require('google-spreadsheet');

// この前保存した Google Spread Sheet API の認証キー
const credential = require('./credential.json');
const spreadsheet = new GoogleSpreadsheet(process.env.SPREAD_SHEET_ID);

なんと、ここで時間切れ ... 。 次こそ、 google-spreadsheet を動かす ... 。

ちなみに、これはこの前の英語で google-spreadsheet を使ってたシリーズ記事を見ながら書いた。

My first Custom Slack App - Part 1 - DEV Community 👩‍💻👨‍💻

sezemi_adminsezemi_admin

久々時間が取れたので、 google-spreadsheet を動かしてみる。

参考にしたのは ↓ の記事。 ちなみに記事の内容は、いろいろググっていると google-spreadsheet の公式ドキュメント and README の情報だった。

【Node.js】 Googleスプレッドシートを簡易データベースとして使う - 一日一膳(当社比)

app.js
const CREDIT = require('./service-credential.json');
const SPREADSHEET_KEY = process.env.SPREAD_SHEET_ID;

const getSpreadSheetTitleByKey = async (spreadsheetKey) => {
  const doc = new GoogleSpreadsheet(spreadsheetKey);

  await doc.useServiceAccountAuth({
    client_email: CREDIT.client_email,
    private_key: CREDIT.private_key,
  });

  await doc.loadInfo();
  console.log(doc.title);
};

getSpreadSheetTitleByKey(SPREADSHEET_KEY);

動かしてみる!

$ node app.js 
# 中略
⚡️ Bolt app is running!
slack_users

スプレッドシートのタイトルが無事に出てきた。 うし!

次は、いよいよスプレッドシートへのデータストアを書いてみるぞい。

sezemi_adminsezemi_admin

行を追加するメソッドを調べる

空いた時間にちょいちょい調べながら、ようやく手を動かせる時間が取れたので、データストアをやってみる。

参考にしていた記事でもよかったんだけど、クラスにまとめて CRUD のメソッドを作ってたので、 "試す" には、ちょっと大掛かりな印象。

というわけで、公式ドキュメントでやってみることにした。

Overview

見ていると、データストアには addRow() を使うみたいなんだけど、よくある最終行 lastRow + 1 と投入データを引数にすると思って、無駄にいろいろ調べてしまった。

結局、 addRow() は add new row なので、そんなことをせずとも一行追加してくれる優秀なやつだった。

Node.js + TypeScriptでスプレッドシートの行を所得・追加する

サンプルデータを addRow() で追加してみる

というわけで、クロージャで書いてみた。

app.js
(async () => {
  const doc = new GoogleSpreadsheet(SPREADSHEET_KEY);

  await doc.useServiceAccountAuth({
    client_email: CREDIT.client_email,
    private_key: CREDIT.private_key,
  });

  const sheet = doc.sheetsByIndex[0];
  const value = {
    slack_id: 'sample1234',
    team_id: 'sample5678',
    name: 'sample',
    icon: '/sample.jpg',
    is_feedback: false,
  };

  try {
    const result = await sheet.addRow(value);
    console.log(result);
  }
  catch (error) {
    console.log(error);
  }
})();

実行!

が、エラー ... 。 でも、やさしいエラーメッセージが帰ってきて感激。

$ node app.js
[DEBUG]  web-api:WebClient:0 initialized
[DEBUG]  web-api:WebClient:0 apiCall('users.list') start
[DEBUG]  web-api:WebClient:0 will perform http request
/home/hoppers/first-bolt-app/node_modules/google-spreadsheet/lib/GoogleSpreadsheet.js:202
    if (!this._rawProperties) throw new Error('You must call `doc.loadInfo()` before accessing this property');
                                    ^

Error: You must call `doc.loadInfo()` before accessing this property

なる。 loadInfo() する必要があるのね。 あざっす!

app.js
(async () => {
  const doc = new GoogleSpreadsheet(SPREADSHEET_KEY);

  await doc.useServiceAccountAuth({
    client_email: CREDIT.client_email,
    private_key: CREDIT.private_key,
  });

  await doc.loadInfo();

  const sheet = doc.sheetsByIndex[0];
  const value = {
    slack_id: 'sample1234',
    team_id: 'sample5678',
    name: 'sample',
    icon: '/sample.jpg',
    is_feedback: false,
  };

  try {
    const result = await sheet.addRow(value);
    console.log(result);
  }
  catch (error) {
    console.log(error);
  }
})();

これで実行!

insert data

いえーい、データ入ったで!

log も無事出力された。

GoogleSpreadsheetRow {
  _sheet: GoogleSpreadsheetWorksheet {
    _spreadsheet: GoogleSpreadsheet {
      spreadsheetId: '******************************',
      authMode: 'JWT',
      _rawSheets: [Object],
      _rawProperties: [Object],
      _spreadsheetUrl: 'https://docs.google.com/spreadsheets/d/'******************************/edit',
      axios: [Function],
      jwtClient: [JWT]
    },
    _headerRowIndex: 1,
    _rawProperties: {
      sheetId: 0,
      title: 'シート1',
      index: 0,
      sheetType: 'GRID',
      gridProperties: [Object]
    },
    _cells: [],
    _rowMetadata: [],
    _columnMetadata: [],
    headerValues: [ 'slack_id', 'team_id', 'name', 'icon', 'is_feedback' ]
  },
  _rowNumber: 4,
  _rawData: [ 'sample1234', 'sample5678', 'sample', '/sample.jpg', 'FALSE' ],
  slack_id: [Getter/Setter],
  team_id: [Getter/Setter],
  name: [Getter/Setter],
  icon: [Getter/Setter],
  is_feedback: [Getter/Setter]
}

続きは、これを関数に切り出して、 Slack の users.list() を叩いてる処理に追加する。

sezemi_adminsezemi_admin

この前作ったクロージャを関数に切り出し。

async function saveMember(member) {

  const doc = new GoogleSpreadsheet(SPREADSHEET_KEY);

  await doc.useServiceAccountAuth({
    client_email: CREDIT.client_email,
    private_key: CREDIT.private_key,
  });

  await doc.loadInfo();

  const sheet = doc.sheetsByIndex[0];

  const value = {
    slack_id: member.id,
    team_id: member.team_id,
    name: member.name,
    icon: member.profile.image_48,
    is_feedback: member.is_feedback,
  }

  try {
    const result = await sheet.addRow(value);
    console.log(result);
  }
  catch (error) {
    console.log(error);
  }
};

これで Slack の API users.list() を叩いて保存する処理を書く。

app.js
(async () => {
  const results = await client.users.list();

  results.members.forEach(member => {
    member.is_feedback = false;
    saveMember(member);
  });
})();

users

入った!! ..... と思ったんだけど、レコードが 2 つしかない。

気になって、 result で出力している Google Sheet API のログを見てみると、

_rowNumber: 2,
# 中略
_rowNumber: 2,
# 中略
_rowNumber: 3,
# 中略
_rowNumber: 3,
# 中略

と同じ行に出力してた。

もしかすると async 内で foreach しているからかもしれない :scream:

(async () => {

  results.members.forEach(member => {
    saveMember(member);
  });
})();
sezemi_adminsezemi_admin

forEach を非同期処理でやるには Promise.all() で解決できるよ、ということだったので変更

  await Promise.all(results.members.map(async (member) => {
    member.is_feedback = false;
    await saveMember(member);
  }));

実行してみると、、、変わらず。ぐぬう。

なら、タイミングを合わせるものや、わざと遅延するものをやってみた。

function waitforme(milisec) {
  return new Promise(resolve => {
      setTimeout(() => { resolve('') }, milisec);
  })
}
    console.log(value);
    const result = await sheet.addRow(value);
    await result.save();
    await waitforme(1000);
    console.log(result);

変わらず ... 。

なんか違う原因があるのかも知れん。

現象としては、先に console.log(value) がユーザ人数分出力されて、 Google Sheet API からのレスポンスが遅れてやってくるんだけど、それがおかしい。

  • 人数が合わない
  • 重複がある
  • 同じ行に書き込む

value はちゃんと入ってるんだけど、 addRow() で失敗してるっぽい。

for で書き直してみるか

sezemi_adminsezemi_admin

↓ の記事などを参考にして Promise.all() で書いてみたんだけど、全然上手くいかず。

async-awaitでもforEachしたい! - Qiita

  await Promise.all(usersInfo.members.map(async (member) => {
    await saveMembers(member);
  }));

思い直して、もう for で書き直してみた。

for (let member of usersInfo.members) await saveMembers(member);

これで実行してみると、ちゃんと直列に実行されて、無事にメンバを登録でけた!

$ node app.js
[DEBUG]  web-api:WebClient:0 initialized
[DEBUG]  web-api:WebClient:0 apiCall('users.list') start
[DEBUG]  web-api:WebClient:0 will perform http request
⚡️ Bolt app is running!
[DEBUG]  web-api:WebClient:0 http response received
2
[
  'USLACKBOT',
  '**************',
  'slackbot',
  '******************************',
  'FALSE',
  '2022/11/7'
]
3
[
  '******************************,
  '******************************',
  '********',
  '******************************',
  'FALSE',
  '2022/11/7'
]
4
[
  '******************************',
  '******************************',
  'practicemakeslackapp',
  '******************************',
  'FALSE',
  '2022/11/7'
]
5
[
  '******************************',
  '******************************',
  '******',
  '******************************',
  'FALSE',
  '2022/11/7'
]

datastore_member

あとは saveMembers() するときに bot を弾きたいので、その処理を追加(なぜか slackbot は is_bot のパラメータが false 。 調べてみると id が固定だからそれで弾けと。 そんなんある?)。

app.js
  if (member.is_bot === false && member.id !== 'USLACKBOT') { // bot と slack bot を除外
    const value = {
      slack_id: member.id,
      team_id: member.team_id,
      name: member.name,
      icon: member.profile.image_48,
      is_feedback: false,
      created_at: date.toLocaleDateString(),
    };

    try {
      const result = await sheet.addRow(value);
      console.log(result._rowNumber);
      console.log(result._rawData);
    }
    catch (error) {
      console.log(error);
    }
  }

これで member だけが登録できるようになった。 メンバー登録処理完了。 ここで今日はおしまい。

続きは登録されたメンバーからランダムに feedback_reciever を選ぶ処理をやる

sezemi_adminsezemi_admin

久々再開。

メンバー登録はできたので、 feedback_reciever をランダムに選ぶ処理を追加するんだけど、詳しくは以下のような流れ。

  1. team_id でメンバーを select
  2. is_feedback === false のメンバーを select
    1. のメンバーからランダムで 1 人を選ぶ

team.info() を使う

まずは team_id を知りたいので、調べてみると、以下のメソッドがあった。

team.info method | Slack

というわけで、ふんふん書いてみる。

(async () => {
  const team_info = await app.client.team.info();
  const team_id = team_info.team.id;
  console.log(team_id);
})();

が、実行してみるとエラー。

Error: An API error occurred: missing_scope
    at platformErrorFromResult (/home/hoppers/first-bolt-app/node_modules/@slack/web-api/dist/errors.js:51:33)
    at WebClient.apiCall (/home/hoppers/first-bolt-app/node_modules/@slack/web-api/dist/WebClient.js:167:56)
    at processTicksAndRejections (node:internal/process/task_queues:96:5)
    at async /home/hoppers/first-bolt-app/app.js:67:21 {
  code: 'slack_webapi_platform_error',
  data: {
    ok: false,
    error: 'missing_scope',
    needed: 'team:read',
    provided: 'chat:write,channels:history,groups:history,im:history,mpim:history,users:read',
    response_metadata: {
      scopes: [
        'chat:write',
        'channels:history',
        'groups:history',
        'im:history',
        'mpim:history',
        'users:read'
      ],
      acceptedScopes: [ 'team:read' ]
    }
  }
}

missing_scope ということで "権限ないぜ、あんた" ってこと。

そんな強いやつなのか ...

とここまでで時間切れ。 続きは権限を入れるか、代替を考えるか、検討するところから

sezemi_adminsezemi_admin

OAuth & Permissions で team:read の権限追加

久々に再開。

前回、 missing_scope で team:read の権限ないよ、ということだったので、調べるのに手間取ったけど、 Slack App の設定から以下の手順で追加できるので、権限を追加。

  1. OAuth & Permissions > Scopes
  2. team:read を追加
  3. reload

OAuth & Permissions
Scopes

で、やってみると無事に team_id が出力できた。

$ node app.js
T***************

team_id でメンバー情報を select

続いては、取得できた team_id からメンバー情報を select してリストにする処理。

公式ドキュメントを見てみたけど、どうやら filter や fetch, select, find のようなメソッドは無かった。 ぐぬう。

getRows() で全件取得したあと、 JSON をパースするほか無さそう。

続きはこのパースの処理から

sezemi_adminsezemi_admin

パースの前に、 getRows() までの処理で、一旦データを出力してみた。

  const doc = new GoogleSpreadsheet(SPREADSHEET_KEY);

  await doc.useServiceAccountAuth({
    client_email: CREDIT.client_email,
    private_key: CREDIT.private_key,
  });

  await doc.loadInfo();
  const sheet = doc.sheetsByIndex[0];

  const users = await sheet.getRows();
  console.log(users);
$ node app.js
[
  GoogleSpreadsheetRow {
    _sheet: GoogleSpreadsheetWorksheet {
      _spreadsheet: [GoogleSpreadsheet],
      _headerRowIndex: 1,
      _rawProperties: [Object],
      _cells: [],
      _rowMetadata: [],
      _columnMetadata: [],
      headerValues: [Array]
    },
    _rowNumber: 2,
    _rawData: [
      'USLACKBOT',
      '************',
      'slackbot',
      'https://a.slack-edge.com/******/img/slackbot_48.png',
      'FALSE',
      '2022/11/7'
    ],
    slack_id: [Getter/Setter],
    team_id: [Getter/Setter],
    name: [Getter/Setter],
    icon: [Getter/Setter],
    is_feedback: [Getter/Setter],
    created_at: [Getter/Setter]
  },
]

今日はこれだけ。 select の処理には filter()find() を使うかなぁ。

sezemi_adminsezemi_admin

team_id で filter

getRaws() で取得したユーザ一覧から team_id が一致するものを filter() で出す。

先に spreadsheet のユーザ一覧に team_id が異なるダミーデータを追加しておく。

slack_id team_id name icon is_feedback created_at
USLACKBOT T02******** slackbot https://hogehogehoge.com FALSE 2022/11/7
MMMMM4EF T03456789PPK test_user_2 https://hogehogehoge.com FALSE 2022/12/5
U0********* T02******** ****** https://hogehogehoge.com FALSE 2022/11/7
U0********* T02******** practicemakeslackapp https://hogehogehoge.com FALSE 2022/11/7
U0********* T02******** ******_1 https://hogehogehoge.com FALSE 2022/11/7
TTTTT42V4EF T03456789PPK test_user https://hogehogehoge.com FALSE 2022/12/5

見様見真似で書いてみる。

app.js
  const users = await sheet.getRows();
  const team_members = users.filter(member => {
    return member._rawData[2] = team_id;
  });
  console.log(team_members);

なぜか、.rawData の name が入る要素が team_id になってしまった ... とおもったら、 team_id は .rawData の配列の 1 番目だったのだ。

    _rawData: [
      'USLACKBOT',
      '************',
      'slackbot',
      'https://a.slack-edge.com/******/img/slackbot_48.png',
      'FALSE',
      '2022/11/7'
    ],
    return member._rawData[1] = team_id;

これで出力してみると、 filter されることなく全件出てきてしまった。

うーむ、と思っていると、 team_id は文字列だったことを思い出す。 うっかりうっかり ...

    return member._rawData[1] === team_id;

これで出力してみると、無事に team_id を filter して出してくれた。

$ node app.js 
[
  GoogleSpreadsheetRow {
    _rowNumber: 2,
    _rawData: [
      // 省略
    ],
    slack_id: [Getter/Setter],
    team_id: [Getter/Setter],
    name: [Getter/Setter],
    icon: [Getter/Setter],
    is_feedback: [Getter/Setter],
    created_at: [Getter/Setter]
  },
  GoogleSpreadsheetRow {
    _rowNumber: 4,
    _rawData: [
      // 省略
    ],
    slack_id: [Getter/Setter],
    team_id: [Getter/Setter],
    name: [Getter/Setter],
    icon: [Getter/Setter],
    is_feedback: [Getter/Setter],
    created_at: [Getter/Setter]
  },
  GoogleSpreadsheetRow {
    _rowNumber: 5,
      // 省略
    ],
    slack_id: [Getter/Setter],
    team_id: [Getter/Setter],
    name: [Getter/Setter],
    icon: [Getter/Setter],
    is_feedback: [Getter/Setter],
    created_at: [Getter/Setter]
  },
  GoogleSpreadsheetRow {
      // 省略
    _rowNumber: 6,
    _rawData: [
      // 省略
    ],
    slack_id: [Getter/Setter],
    team_id: [Getter/Setter],
    name: [Getter/Setter],
    icon: [Getter/Setter],
    is_feedback: [Getter/Setter],
    created_at: [Getter/Setter]
  }
]

で、出力されている GoogleSpreadsheetRow にある、

team_id: [Getter/Setter],

とあるところがなんだろうと思って試しに出力してみると、 team_id がちゃんと出てくるではないか! はやく試せばよかった。

というわけで、最終形はこちら。

app.js
  const users = await sheet.getRows();
  const team_members = users.filter(member => {
    return member.team_id === team_id;
  });

うんスッキリ。 というわけで続きは map で整形するところから。

sezemi_adminsezemi_admin

map で必要な要素の配列にする

引き続き、↓ のドキュメントを参考にして、 filter した結果を map して必要な

  • slack_id,
  • team_id,
  • name,
  • is_feedback

のみを持つ配列にする

JavaScript で forEach を使うのは最終手段 - Qiita

見様見真似で書いてみる。

  const team_members = users.filter(member => member.team_id === team_id)
    .map(member => ({
      slack_id: member.slack_id,
      team_id: member.team_id,
      name: member.name,
      is_feedback: member.is_feedback,
    })
  );

動かしてみると、一発でキタコレ!

[
  {
    slack_id: 'USLACKBOT',
    team_id: '**********',
    name: 'slackbot',
    is_feedback: 'FALSE'
  },
  {
    slack_id: 'U0*********',
    team_id: '**********',
    name: '*****',
    is_feedback: 'FALSE'
  },
  {
    slack_id: 'U0*********',
    team_id: '**********',
    name: 'practicemakeslackapp',
    is_feedback: 'FALSE'
  },
  {
    slack_id: 'U0*********',
    team_id: '**********',
    name: '*****_1',
    is_feedback: 'FALSE'
  }
]

フィードバックがまだの人を追加

で、 filter するものに is_feedback で false になってることがあったので、これを追加。
あと、 spreadsheet のデータにも true を追加。

const team_members = users
  .filter(
    (member) => member.team_id === team_id && member.is_feedback == false
  )
  .map((member) => ({
    slack_id: member.slack_id,
    team_id: member.team_id,
    name: member.name,
    is_feedback: member.is_feedback,
  }));

が、これは配列が空で出てきてしまった。 うーん、と思っていると、 false は文字列だったことに気づき変更。

    (member) => member.team_id === team_id && member.is_feedback === 'false'

が、これも結果は空の配列。 で、小文字 false じゃねえわ 'FALSE' だわと思って変える。

    (member) => member.team_id === team_id && member.is_feedback === 'FALSE'

無事に TRUE のやつを除いて出力でけた。

app.js
const team_members = users
  .filter(
    (member) => member.team_id === team_id && member.is_feedback === "FALSE"
  )
  .map((member) => ({
    slack_id: member.slack_id,
    team_id: member.team_id,
    name: member.name,
    is_feedback: member.is_feedback,
  }));

続きはこの配列からフィードバックレシーバをランダムでピックアップする処理。

いよいよ大詰め!

sezemi_adminsezemi_admin

ランダムでチームメンバからフィードバックを受ける人を選ぶ

team_id と is_feedback で filter したチームメンバからランダムに 1 人候補者を選ぶ処理を書く。
ちなみに、 feedback_reciever としていたんだけど、翻訳にかけると feedback recipient のほうがわかりやすいので、変数はこれにしよ。

で、調べてみると、なるほど、 random() で要素の番号を決めて、その番号から配列の要素を取り出すのね。

JavaScript | 配列・多次元配列からランダムに値を取得する方法 | ONE NOTES

というわけで書いてみた。

app.js
const feedback_recipient = team_members[Math.floor(Math.random() * team_members.length)];

出力してみると、いい感じに規則性がなく出てきたので、一旦これで完成!

追加の処理

これでフィードバックを受ける人が出てくるのだけど、全員 TRUE だったらどうするかなど、色々すっ飛ばしているので、追加するものを書く。

  1. ランダムに選ばれたフィードバックを受ける人の is_feedback の値を TRUE に更新
  2. is_feedback が全員 TRUE だったときの処理
  3. is_feedback が 1 人だけ FALSE だったときの処理

ちょっと 1. は後回しにして、先に 2. 3. を考える。

まず 3. の場合は要素が 1 つだけなら random() しても要素は 1 つしか出ないと思うので、データを 1 人だけ TRUE にしてやってみる。 やっぱり 1 人しか出てこないので、これは考慮しなくて OK 。

で、 2. の場合は、一旦、全員 TRUE のやつを出してランダムで選んだあと、チーム全員の is_feedback を FALSE にしてデータを更新しよう。

というわけで 1. とも関連するのだけど、 spreadsheet のデータの update が必要になるので、これは次回調べてみよう。

調べてみてからになると思うけど、 _rowNumber が必要になるだろうな。

sezemi_adminsezemi_admin

フィードバックを受けるメンバの is_feedback を更新する

spreadsheet の値の update 方法を調べると、 rows[] の添字で save() するみたいだった ... うーむ。
予想通りなんだけど、全員のステータス更新するのとか、メンドそうだ ...

save(options) (async)

// make updates
rows[1].email = 'sergey@abc.xyz';
await rows[1].save(); // save changes

とりあえず、 練習兼ねて以下をやってみる。

  1. ランダムに選ばれたフィードバックを受ける人の is_feedback の値を TRUE に更新

まずは _rowNumber の要素を team_members に入れる。

    .map(member => ({
      _rowNumber:member._rowNumber,
      slack_id: member.slack_id,
      team_id: member.team_id,
      name: member.name,
      is_feedback: member.is_feedback,
    })

一応、出力して入っていることを確認して、 save() の処理を書く。

  users[feedback_recipient._rowNumber] = 'TRUE',
  await users[feedback_recipient._rowNumber].save();

実行してみると、エラー。

TypeError: users[feedback_recipient._rowNumber].save is not a function

なんでじゃ、と思っていると、 key が指定されてなかった。 ごめんさないごめんなさい。

  users[feedback_recipient._rowNumber].is_feedback = 'TRUE',
  await users[feedback_recipient._rowNumber].save();

実行してみるとエラーは解消されたのだけど、、、 異なる row のデータが更新されてしまった。
getRows() で出力される添字と _rowNumber が一致しないっぽい。

何度か試してみると、 _rowNumber -2 すると該当の要素が更新された。整理してみると、当たり前なんだけども、

  • 配列が 0 始まりなので + 1
  • シートのヘッダーが入るので +1

というわけで rows[] の添字と _rowNumber[] は 2 の差ができる。

修正してみて実行すると、ちゃんと feedback_recipient の行の is_feedback が更新されたぞい。

  users[feedback_recipient._rowNumber -2].is_feedback = 'TRUE',
  await users[feedback_recipient._rowNumber -2].save();

続きは、全員の is_feedback が TRUE だったなら、 FALSE に更新する処理。 filter していい感じにデキるといいんだけどなぁ。

sezemi_adminsezemi_admin

is_feedbacked の判定を入れる

まずは is_feedbacked が全員 TRUE かどうかを判定する処理を入れる。 filter して map していた処理を分割。

  const feedback_candidates = users.filter(
    (member) => member.team_id === team_id && member.is_feedbacked === 'FALSE'
  );

  if (feedback_candidates.length !==0) {
    feedback_candidates.map(member => ({
      _rowNumber:member._rowNumber,
      slack_id: member.slack_id,
      team_id: member.team_id,
      name: member.name,
      is_feedbacked: member.is_feedbacked,
    }));

    const feedback_recipient = feedback_candidates[Math.floor(Math.random() * feedback_candidates.length)];
    console.log(feedback_recipient);

    users[feedback_recipient._rowNumber - 2].is_feedbacked = 'TRUE',
    await users[feedback_recipient._rowNumber -2].save();
  } else {
    // 全員の is_feedbacked が TRUE の処理
    console.log('feedback_rescipent がいないよ')
  };

これで全員の is_feedbacked を TRUE にしてみると、無事に else に入った。

feedback_recipient を選ぶ処理を関数化

全員の is_feedbacked が TRUE だったとのきの処理を考えてみる。

  1. is_feedbacked を全員 TRUE のままフィードバックを受ける人を選ぶ
  2. フィードバックを受ける人の is_feedbacked を更新
  3. その他の人の is_feedbacked を更新

ちょっとこれは is_feedbacked の更新処理が入るのでメンドイところ。

理想は is_feedbacked を全員更新して、分岐に入る前に戻ること。

なので、フィードバックを選ぶ処理を関数化して、

  1. is_feedbacked を全員更新してフィードバックをされる候補者リストにする
  2. フィードバックされる人を選ぶ関数にフィードバックをされる候補者リストを入れる

こうすると、重複なしでいけそう。

続きはこの関数に切り出すところから

sezemi_adminsezemi_admin

filter の結果をもとに分岐に入ってから map するのが気持ち悪いので、 map -> fileter に変更

  const candidates_feedbacked = users
    .map(
      (member) => ({
        _rowNumber:member._rowNumber,
        slack_id: member.slack_id,
        team_id: member.team_id,
        name: member.name,
        is_feedbacked: member.is_feedbacked,
      })
    )
    .filter(
      (member) => member.team_id === team_id && member.is_feedbacked === 'FALSE'
    );

  if (candidates_feedbacked.length !==0) {
    // 処理
  };
sezemi_adminsezemi_admin

ちょこちょこと進めていたのをまとめる

selectFeedbackResipient 関数の作成

フィードバックされる人を選ぶ処理を切り出して関数にした。

async function selectFeedbackResipient(candidates, googleSpreadsheetRows) {
  // ランダムにフィードバックを受ける人を選択
  const recipientFeedback = candidates[Math.floor(Math.random() * candidates.length)];

  // 選んだフィードバックを受ける人の is_feedbacked を TRUE にして更新
  googleSpreadsheetRows[recipientFeedback._rowNumber - 2].is_feedbacked = 'TRUE',
  await googleSpreadsheetRows[recipientFeedback._rowNumber -2].save();

  return recipientFeedback;
};

本体の app.js の処理はこんな感じ。

  if (candidatesFeedbacked.length !==0) {
    const recipientFeedback = await selectFeedbackResipient(candidatesFeedbacked, allUsers);
    console.log(recipientFeedback);
  } else {
    // 全員の is_feedbacked の TRUE を FALSE に更新
    // そのリストを selectFeedbackResipient にぶっこむ
  };

全員の is_feedbacked の TRUE を FALSE に更新

フィードバックを受ける候補者のリストを使おうと思ったけど、そもそも空なので使えない。

仕方なくスプシの全ユーザを引っ張ったときのデータを使って、さらにチームのメンバーだけに filter する。

const users = await sheet.getRows();
const teamMembers = users.filter((member) => member.team_id === teamId);

で、一括でスプシの値を更新するものはないので、この teamMembers を一つ一つ取り出して、

  1. is_feedbacked を FALSE
  2. スプシの該当ユーザの値も更新

この 2 つをやる。 メンドイなぁ。

ただし、スプシから引っ張った全ユーザデータを更新時に使わないといけない(いや、もうこれメンドすぎだし、結構ハマった)。 なので teamMembers の _rowNumber から全ユーザデータの is_feedbacked を更新する。

    for (let member of teamMembers) {
      users[member._rowNumber - 2].is_feedbacked = 'FALSE';
      await users[member._rowNumber - 2].save();
    };

動かしてみると、全員の is_feedbacked が更新された。

その更新したチームメンバーを selectFeedbackResipient にぶっこむ

続いて、全員 is_feedbacked が FALSE になったリストを作った関数に入れる。 が、これまたハマったんだけど、さっきの teamMembers を使ってもスプシのデータが更新されない ... 。

というわけで、もっかいスプシのデータを load して、また全ユーザデータを引っ張って、 ... という処理が必要だった。

    await sheet.loadinfo()
    const resetUsers = await sheet.getRows()

    const resetCandidatesFeedbacked = resetUsers
      .filter(
        (member) => member.team_id === teamId && member.is_feedbacked === 'FALSE'
      );
    const recipientFeedback = await selectFeedbackResipient(resetCandidatesFeedbacked, resetUsers);
    console.log(recipientFeedback);

これでようやく全員 TRUE -> FALSE 、選び直しの処理が完了。 いやメンドかった。

google-spreadsheet の操作を別に切り出し

フィードバック候補を選ぶやつが終わったので、これを関数にして、いよいよ開発終了が見えてきたんだけど、スプシの操作が多くなってしまったので、これを別に切り出す。

以前に見ていた、

【Node.js】 Googleスプレッドシートを簡易データベースとして使う - 一日一膳(当社比)
Node.js + TypeScriptでスプレッドシートの行を所得・追加する

を参考に google-spreadsheet-service.js に切り出した。

google-spreadsheet-service.js
const { GoogleSpreadsheet } = require('google-spreadsheet');

const CREDIT = require('./service-credential.json');
const SPREADSHEET_KEY = process.env.SPREAD_SHEET_ID;

class GoogleSpreadsheetService {
  constructor() {
    this.doc = new GoogleSpreadsheet(SPREADSHEET_KEY);
  }

  async init() {
    await this.doc.useServiceAccountAuth({
      client_email: CREDIT.client_email,
      private_key: CREDIT.private_key,
    });

    await this.doc.loadInfo();
    this.sheet = this.doc.sheetsByIndex[0];
  }

  async getRows() {
    return await this.sheet.getRows();
  }

  async addRow(value) {
    return await this.sheet.addRow(value);
  }

}

module.exports = GoogleSpreadsheetService;

最初は require なのか import なのかで、これまた Node.js のお作法を知らず、本体の app.js で使えなかったんだけど、 module.exports を知って無事に完了。

本体の app.js はこんな感じ。

app.js
const GoogleSpreadsheetService = require('./google-spreadsheet-service');

  const sheet = new GoogleSpreadsheetService();
  await sheet.init();
  const allUsers = await sheet.getRows();

filter の処理も切り出そうか悩んでいるのが、イマココ

sezemi_adminsezemi_admin

もう開発の終わりが見えているので、 google-spreadsheet-service に filter の処理を切り出すのはナシにして、とりあえず開発の終わりを目指す。

フィードバック候補を選ぶ処理を関数にする

今までクロージャで書いていたフィードバック候補を選ぶ処理を関数に変更。

(async () => {
  // 処理
})();

// これをこう
async function getFeedbackResipentId(teamId) {
  // teamId を取り出す処理を書いてたんだけど、 
  // post.Message のところで teamId があったので、引数にした
};

で cron で動かすフィードバック候補者へメッセージするところで、この関数を使用。

cron.schedule('00 00 10 * * 1', async () => {
  console.log('It`s triggerd');

  const teamInfo = await client.team.info();
  const teamId = teamInfo.team.id;
  console.log(teamId);
  const channelId = await getFeedbackResipentId(teamId);

  try {
    const result = await client.chat.postMessage({
      channel: channelId,
      text: "Woops, something wrong.",
      blocks: [
      ],
    });
    console.log(result);
  }
  catch(error) {
    console.error(error);
  }
}, {
  timezone: "Asia/Tokyo"
});

getFeedbackResipentId() を呼び出すときに、またまた await キーワードを抜かしてしまい、同期がうまくいかず channel_not_found となったので、泣きそうになった ... 。

最後に cron で最終まで動かしてみる

というわけで、最後に cron の日時を調整して、

  1. アプリがチームメンバーからランダムでフィードバック対象者を選ぶ
  2. アプリがフィードバック対象者にメッセージ(フィードバックする人を選んでね)
  3. フィードバック対象者がフィードバックして欲しい相手を選ぶ
  4. 選ばれたフィードバックする人にアプリからメッセージ(フィードバックの依頼が届いたよ)
  5. フィードバックする人がフィードバック対象者へのフィードバックを入力・送信
  6. アプリからフィードバック対象者にフィードバック内容をメッセージ

これを一通り実行。なお 1. のときに全員がフィードバックを受けていれば、また新しくランダムで選ぶ処理もテスト。

フィードバックの依頼~受け取り
フィードバックの入力・送信

無事に全部のシナリオを実行できた! ちょうど一年ぐらいで開発できた感じだ。ふい~

このあとは、実際にこのアプリを公開するために何が必要なのか調査する。

sezemi_adminsezemi_admin

公開のための調査

公開するために何が必要か調べていたんだけども、ちょっと出だしからつまづく。

  1. Authorization はわかった。 でも、どうすんねん
  2. デプロイ先 ...

Authorization

Slack のドキュメントを探していると、公開に必要そうなものがあったので、それを眺めて、試してみるか、とおもったら、どうしたらその Authorization 情報は取れるのかがさっぱりわからない。

認可(Authorization)

複数のワークスペースにインストールされる、複数のユーザートークンを使用するといったケースのように、アプリが複数のトークンを処理しなければならない場合があります。このようなケースでは token の代わりに authorize オプションを使用する必要があります。

いや、まさしくそうなんだけど、 authorize オプションというのがサンプルコードを見ると、自作するってのはわかるものの、そもそもその botToken とかどうやってチームから取得するのかがわからん。

延々と詰まってる ... 。

デプロイ

ローカルマシンで動かし続けるわけにはいかないので、何らかのクラウドを選ぶ必要がある。

Slack Bolt のドキュメントはそこまでカバーしていて(ありがたや)、

この 2 つが紹介されている。

これに乗っかるのが一番学習コスト少ないのだけど、お金がなぁ。 これも詰まり気味。

というわけで、続きは引き続き Authorization 問題の解決から。

sezemi_adminsezemi_admin

調べていると、 OAuth フローがそれに該当するものだった。

Using OAuth 2.0 | Slack

ただ、これは Bolt を使わずに API 経由で実装する方法が書かれていて、他になにかドキュメントがないか調べたところ、神記事発見!

Slack Bolt で簡単に複数ワークスペースにインストールできるアプリを開発しよう!(TypeScript, Lambda, Serverless Framework によるサンプルコード付き) - Qiita

ちょうど知りたいことと、しかも前提(一つのワークスペースにインストールしているアプリが開発済み)が合っているので、まさしく俺得なやつ。

で、この記事からリンクがあり、 Bolt の公式ドキュメントにもあることを発見。

OAuth フローの実装

Qiita の記事と公式ドキュメントを次に読む。

sezemi_adminsezemi_admin

Bolt 公式の Oauth フローの実装をまず確認。 公開するには、

  1. 公開用 App の作成と設定
  2. 公開用 App とインストールするチームとで Oauth フローを行う
  3. 公開用 App の URL が無いので、 ngrok のようなライブラリを使って localhost のパスを公開用の URL にフォワードする
  4. ローカル開発(単一チームへのインストール) -> テスト公開 -> App 公開 の 3 段階があって、今からやるのは 2 段階目
  5. 3 段階目では socke mode が使えない

ということがわかった。 また途中 Oauth フロー実装例のリポジトリを Slack が公開していて、これが参考になりそう。

bolt-js/examples/oauth at main · slackapi/bolt-js

というわけで、次回は公開 App の作成と設定から

sezemi_adminsezemi_admin

公開用の設定 App 名を OJT360 にする

App を公開するにあたって、今まで作ってきた App が練習用丸出しの名前や description だったので、以下に変更。 アイコンは ... 作る前に考えよう。

Display Information

App の名前は OJT360 に決定。 心理的安全性にフォーカスしようと思ったが、トレーニングツールだもんな、と思ったのでちょっと変更。

Google Trends

心理的安全性はキャッチコピーにするのもいいし。

OAuth フローの実装

Bolt の公式ドキュメントを眺めても、どのように進めるのか、ちょっとわかりにくかったので、 Qiita の記事をガイドにする。

Slack Bolt で簡単に複数ワークスペースにインストールできるアプリを開発しよう!(TypeScript, Lambda, Serverless Framework によるサンプルコード付き) - Qiita

Redirect URL の設定

Redirect URL に必要になるのが App のホスト URL 。 ちょっとサーバをどこにするかは最後まで粘りたいので、一旦、ローカル環境を host にできる(厳密にはそうではない) ngrok を使用する。

これは Qiita の記事からリンク貼られていた Slack の実装例でやられていたもの。

で、これによるとまず App の clientId などが OAuth に必要になるので、 Slack の App 画面( App Credentials )からこれらを .bash_profile に入れる。

SLACK_CLIENT_ID=**********************
export SLACK_CLIENT_ID
SLACK_CLIENT_SECRET=****************
export SLACK_CLIENT_SECRET
SLACK_SIGNING_SECRET=*******************
export SLACK_SIGNING_SECRET

bolt-js/examples/oauth at main · slackapi/bolt-js

SLACK_SIGNING_SECRET は前にも発行されてたんだけど、値が変わっているので、前のものを一旦コメントアウトして更新。

というわけで続きは、 ngrok の設定をやるぞい。

sezemi_adminsezemi_admin

久々再開。 ngrok をインストールする。

  1. ngrok - download から Linux 版をダウンロード
  2. ngrok-v3-stable-linux-amd64.tgz が落ちてくるので、 tar で解凍して /usr/bin/ に mv
  3. ngrok --version を実行してレスポンスが ngrok version 3.1.1 と出たので完了

続いて、 ngrok に登録した際、動かすまでのステップがメールで説明されていたので、続いてやってみる。

  1. ngrok config add-authtoken myAuthToken を実行
  2. レスポンスとして Authtoken saved to configuration file: /home/user/.config/ngrok/ngrok.yml が返ってきたので設定ファイルの作成が完了
  3. トンネルをスタート ngrok http 80
  4. ↑が表示され動いたのを確認(サーバを動かしてないので URL には何も表示されない)

続きは Slack の OAuth 実装例の続きをやる。

sezemi_adminsezemi_admin

ngrok で OAuth を開始! ... するも失敗

ngrok の設定が終わったので、いよいよ OAuth やるぞ。

Slack の OAuth フローの実装例に従ってやってみる。

  1. node app.js でアプリを起動
  2. ngrok http 3000 で URL をローカルのポートをフォワーディング
  3. Slack app の設定画面の OAuth & Permissions を開き、 ngrok のレスポンスにある Fowarding で表示された URL を入力して save

redirect url

これで準備が整ったので、 Fowarding の URL https://**********.ngrok.io に /slack/oauth_redirect を入れて OAuth 開始!

... が、何も起こらずページタイトルには ERR の文字。 うーむ、失敗。 まぁ初手はこんなもんだよな。

で、 Qiita の記事やドキュメントを見ていると、、、

OAuth installation is only needed for public distribution.
Bolt for JavaScript OAuth Test App

左側のメニューから Manage Distribution を開き、Share Your App with Other Workspaces で Activate Public Distribution をクリックします。
3. アプリ配布の有効化

とあって、アプリの有効化をしていなかったのである。 よく読もう ... ( n 回目)。

続きはアプリの有効化から

sezemi_adminsezemi_admin

アプリの有効化

この前の Qiita の記事を参考にして、App の管理画面から他のワークスペースから使える設定を開く。

share your app

ハードコーディングしてないことを確認して、 activate publick distribution で有効化。

特にレスポンスがない ... 。 enable したのでしょう。

続いて、 Qiita の記事では環境変数を編集しているんだけど、これは完了済。

app の初期化を変更

App の環境変数を使っているのは、 const app = new App()const client = new WebClient() の 2 つ。 このうち WebClient は token が必要なので App の初期化後と考えて、一旦以降をコメントアウト。

で、 App の初期化。

app.js
const app = new App({
  // token: process.env.SLACK_BOT_TOKEN,
  signingSecret: process.env.SLACK_SIGNING_SECRET,
  clientId: process.env.SLACK_CLIENT_ID,
  clientSecret: process.env.SLACK_CLIENT_SECRET,
  stateSecret: 'my-state-secret', // ToDo ランダマイズ
  // appToken: process.env.SLACK_APP_TOKEN,
  scopes: [
    'channels:history',
    'chat:write',
    'groups:history',
    'im:history',
    'mpim:history',
    'team:read',
    'users:read',
  ],
  socketMode: true,
});

stateSecret のプロパティはランダム生成したほうがいいとのことで、これはまた今度。

scopes を調べていたときに見たこの記事だと、 user_scopes もあるみたい。とりあえずコケたら、調べてみよう。

Slack API のトークンローテーション完全ガイド

で、この記事を見ると installation の情報をファイルで保存しているので、次回これを真似てやってみる。

const { App, FileInstallationStore } = require("@slack/bolt");

const app = new App({
  // 中略

  // Slack ワークスペースへのインストール情報を管理する実装、ここではローカルファイルに保存します
  installationStore: new FileInstallationStore({
    baseDir: './data/installations',
    clientId: process.env.SLACK_CLIENT_ID,
  }),
}
sezemi_adminsezemi_admin

こちらを参考にしながら、 installation 情報をファイルに保存できるようにする。

Slack API のトークンローテーション完全ガイド

  1. /data/installations ディレクトリを作成
  2. FileInstallationStore を slack/bolt から import
  3. installationStore プロパティを追加
const { App, FileInstallationStore } = require('@slack/bolt');

const app = new App({
  installationStore: new FileInstallationStore({
    baseDir: './data/instrallations',
    clientId: process.env.SLACK_CLIENT_ID,
  }),

で、ログも取れるようなのでこのプロパティも追加。

  1. LogLevel を import
  2. logLevel プロパティを追加
const { LogLevel } = require("@slack/logger");

const app = new App({
  logLevel: process.env.SLACK_LOG_LEVEL || LogLevel.DEBUG,

ただ、この SLACK_LOG_LEVEL は設定していないので、どこで取れるのか確認しようとしたところで時間切れ。

続きはこの環境変数の調査から

sezemi_adminsezemi_admin

久々に再開。 一家インフルはやばかった。

logLevel プロパティに見慣れない環境変数があったけど、これは無しでも OK だった。 あとは socketMode を動かそうとすると、 "token 無いで" と怒られたので、これもコメントアウト。

というわけで、コンストラクタ引数はこんな感じになった。

const app = new App({
  signingSecret: process.env.SLACK_SIGNING_SECRET,
  clientId: process.env.SLACK_CLIENT_ID,
  clientSecret: process.env.SLACK_CLIENT_SECRET,
  stateSecret: 'my-state-secret', // ToDo ランダマイズ
  installationStore: new FileInstallationStore({
    baseDir: './data/instrallations',
    clientId: process.env.SLACK_CLIENT_ID,
  }),
  scopes: [
    'channels:history',
    'chat:write',
    'groups:history',
    'im:history',
    'mpim:history',
    'team:read',
    'users:read',
  ],
  logLevel: LogLevel.DEBUG,
});

installation を動かしてみる

↓ の README を参考にして手順を進める。

node-slack-sdk/examples/openid-connect at main · slackapi/node-slack-sdk · GitHub

  1. app.js を動かし ngrok http 3000 でローカルホストを繋ぐ
  2. フォワードされた URL を Slack App > OAuth & Permissions の Redirect URL に貼る
  • https の URL の末尾に /slack/oauth_redirect を入れる
  • save する
  1. http://localhost:3000/slack/install を開く

いえい、 Add to Slack ボタンが出た!

ボタンを押すと、見慣れた install 画面が表示された!

install

もちろんこのあと Allow ボタンを押しても、特に何も処理を書いてないのでエラー終了。

というわけで、続きは installation の情報をファイルストアする処理を書く。

sezemi_adminsezemi_admin

Allow ボタンを押してエラーが出たと思っていたけど、 ngrok が出している画面はエラーじゃなくて警告で、 "信頼できるサイトなら visit してもいいよ" というものだった(よく読もう n 回目)。

そこで visit site ボタンを押すと、無事に成功!

success

/data/installations/ に専用のディレクトリも自動で作成され、

$ tree data
data
└── instrallations
    └── **************************
        └── ***********
            ├── app-*********************
            ├── app-latest
            ├── user-*************************
            └── user-**********************

3 directories, 4 files

というファイルができて、チーム情報や token が取得できてた。 いえーい

sezemi_adminsezemi_admin

開いてしまったけど、ちょこちょこ以下の検証を進めていた。

  • socket mode が動くかどうか
  • WebClient が動くかどうか

まず socket mode は App のインスタンス引数に、

const app = new App ({
  socketMode: true,
});

これで無事に動かせたので OK 。

WebClient は App をインスタンス化するときのログに、

[DEBUG]  web-api:WebClient:1 apiCall('apps.connections.open') start
[DEBUG]  web-api:WebClient:1 will perform http request
[DEBUG]  web-api:WebClient:0 http response received

とあったので、これもインスタンス化できているのではと思って、試しに users.list() を叩いてみる。

(async () => {
  try {
    const result = await app.client.users.list();
    console.log(result);
  } catch (error) {
    console.log(error);
  }
})();

実行すると、エラーで、

Error: An API error occurred: not_authed
  code: 'slack_webapi_platform_error',
  data: {
    ok: false,
    error: 'not_authed',
    response_metadata: { acceptedScopes: [Array] }
  }
}

scopes がなく not authed 。 うーむ、確かに users.read などを入れているのに、 App のインストール時に allow のときに出てないのよな。

次回 App のインスタンス引数の scopes の指定が間違っていないか、確認する。

sezemi_adminsezemi_admin

ざっと見ると、以下の issue でのやり取りが参考になりそう。

bolt-app Error: An API error occurred: not_authed with the code 'slack_webapi_platform_error' · Issue #1443 · slackapi/bolt-js

どうやら scopes の指定というより、 token が無いことがエラーの原因の様子。

the error code "not_authed" indicates that your app's web API call did not have any token.

やっぱり token を使って、 WebClient をインスタンス化するのか。

sezemi_adminsezemi_admin

ようやく明日から開発を再開できそうなので、今後の方針を考える。

とりあえずこれまでやっていた OAuth フローの検証は installations が取得できているので、 WebClient の init は後回し。

それよりは、実際にサーバにホストして installations を DB に保存することを考えよう。 とりあえず候補を以下の記事からあたってみる。

Herokuの代替サービス30選、使ってみた参考記事リンク付き - Qiita

  • Render.com: DB と Cron を使うと有料なので ×
  • Railway: $5 までの無料枠があり、 MySQL のプラグインを入れると $11 ぐらいの見積もり △
  • Deta.sh: Update して、なんやわからん感じに ×
  • Firebase: Slack App をホストしている記事が多く、いろいろ参考になりそう。 無料枠もある。 ただ従量課金がどこで発生するか見通しが悪い △
  • Koyeb: 完全な無料プランがあったのだが、残念ながら DB は別クラウドで立てて、それを使うことになっていた ×
  • Heroku: 10 周回った感があるが、一応アプリケーションと DB のホストで $10 ぐらい。 Slack Bolt の公式ドキュメントで Heroku サポートをしているので、これはこれでありか ... △
  • AWS Lambda: まだ調査中

うーん、どれも結局、無料にならない。

Slack App のホストからデータストアまで完備している Next-gen Slack platform も見たけど、これはこれで Slack 有料版のみしか利用できないとのこと。 うぇーい。 開発をちょっと止めても 925円 / 月が出るので、これなら Heroku で十分 ... 。

sezemi_adminsezemi_admin

Fly.io を使おう

Heroku 代替で調べていると、 Fly.io が良さそうだった(↑ の記事の一番上で紹介されてるやん ... )。

Herokuの代替として注目のFly.ioでアプリケーションをデプロイする

試しに Fly.io を見ていると、 pricing に

Our pricing is designed to let you run small applications for free, and scale costs affordably as your needs grow.

とええこと書いているので、ちゃんと調べてみる。

結果はこちら。

  • コンテナを使っているので、データ永続化 = ボリュームマウント が必要
  • Fly Volumes というアプリが同居しているサーバの SSD を使う
  • アプリでボリュームを指定するか、別に DB サーバを Fly で立てて接続するやり方がある
  • Fly Postgres という PaaS を使うと監視や毎日のスナップショットなどもついてくる
    • SQLite ベースの LiteFS もあるみたいなんだけど、これはまだ β
  • 他にただホストを立てて、 MySQL とかも使える
  • 無料で最大 VM 3 つ で使用量を超えれば課金
    • アプリと呼ばれる単なるコンピューティングか、マシンと呼ばれる Fly Postgres がついたコンピューティングか、選べる
    • このアプリとマシンで合計 3 つ作成可能

Fly.io に決定!

Node.js アプリを動かす方法もドキュメントにあったので、次回、これを試してみよう。

Run a Node App · Fly Docs

sezemi_adminsezemi_admin

公式にあった Node App を動かし方を試してみる。

  1. git clone https://github.com/fly-apps/hellonode-builtin
  2. npm install express --save
  3. cd hellonode-builtin/ // clone したディレクトリに移動
  4. node server.js

無事にローカルで動いた。 HelloNode app listening on port 3000!
画面キャプチャ忘れた。

続いて、 fylctl の install

  1. $ curl -L https://fly.io/install.sh | sh
  2. 環境変数を追加 export FLYCTL_INSTALL="/home/hoppers/.fly" export PATH="$FLYCTL_INSTALL/bin:$PATH"
  3. アカウントを作成 flyctl auth login
  4. ブラウザで作成
  5. 完了すると flyctl auth login in ****@hoge.email

というわけで、いよいよ launch

  1. flyctl launch
  2. app name を聞かれるので、 blank で適当に作ってもらう( Unique でないとダメ)
  3. region を聞かれるので、 Tokyo, Japan を選択
  4. DB 作る? Redis 作る? と聞かれるけど、今回はパス

結果

Your Node app is prepared for deployment.  Be sure to set your listen port
to 8080 using code similar to the following:

    const port = process.env.PORT || "8080";

If you need custom packages installed, or have problems with your deployment
build, you may need to edit the Dockerfile for app-specific changes. If you
need help, please post on https://community.fly.io.

Now: run 'fly deploy' to deploy your Node app.

速い速い。 というわけで deploy

$ fly deploy
==> Verifying app config
Validating /home/hoppers/fly-io-sample/hellonode-builtin/fly.toml
Platform: machines
✓ Configuration is valid
--> Verified app config
==> Building image
Remote builder fly-builder-wispy-lake-6452 ready
==> Creating build context
--> Creating build context done
==> Building image with Docker
--> docker host: 20.10.12 linux x86_64
Sending build context to Docker daemon  5.704kB
[+] Building 45.0s (15/15) FINISHED                                                                                                        
# 中略
--> Building image done
==> Pushing image to fly
The push refers to repository [registry.fly.io/****************]
# 中略
--> Pushing image done
# 中略
Process groups have changed. This will:
 * create 1 "app" machine
No machines in group 'app', launching one new machine
  Machine *************9 [app] update finished: success
  Finished deploying

これで deploy 完了。 マジで ... 。 こんな簡単に。 スゴイスゴイ

Host に行くと、ちゃんとメッセージが表示されていて、 fly io の dashboard があったので、見てみると、おー動いてる動いてる。

dashboard

一旦、これで作った app を削除して完了。

なるほどな~、 ctl 作る意味はこんなところにあるのね。 次はローカルの slack app をデプロイしてみる

sezemi_adminsezemi_admin

slack app をデプロイしてもデータベースを作っていないので、ローカルで用意する。

あとは chatGPT をどんどん使ってみることにした。 not API なので GPT3.5 だけども。

ORM を調査 -> Prisma を使う

Node.js で使える ORM を chatGPT に聞いてみると、

  • Sequelize
  • Prisma

の 2 つが挙げられた。 どっちがいいか調べてみた。

↓ は Prisma のドキュメントなので、公平性は無いかも知れないけど、クエリの書き方は Prisma のほうが書きやすそうなので、 Prisma を使う。

Prisma vs Sequelize

PostgreSQL をインスコ

何にせよ、まずは PostgreSQL をインスコ。 sudo apt install postgresql で開始したら、 WSL 1 のネットワーク激遅い問題 99 b/s にひっかかり、 20 分以上かかる計算に。

今日はここまで

sezemi_adminsezemi_admin

とりあえず PostgreSQL が入ったか確認

$ psql --version
psql (PostgreSQL) 12.14 (Ubuntu 12.14-0ubuntu0.20.04.1)

Prisma を入れる。

$ sudo npm install prisma --save-dev

added 2 packages, and audited 197 packages in 7s

39 packages are looking for funding
  run `npm fund` for details

いくつか脆弱性があるとのことなので npm audit fix で更新。

一応、 package.json の devDependencies にも入っているか確認。

  "devDependencies": {
    "prisma": "^4.12.0",
    "typescript": "^4.6.3"
  }

chatGPT によると、 pg ライブラリも入れてたんだけど、ドキュメントにはないので調査する。

調べてみると、 ↓ のページがマッチしていたので、以降はこれを参考にして進める。

Add Prisma to an existing project that uses a relational database (15 min) | typescript-postgres

sezemi_adminsezemi_admin

↑ のドキュメントに、

Prerequisites
In order to successfully complete this guide, you need:

  • an existing Node.js project with a package.json
  • Node.js installed on your machine
  • a PostgreSQL database server running and a database with at least one table

Make sure you have your database connection URL (that includes your authentication credentials) at hand! If you don't have a database server running and just want to explore Prisma, check out the Quickstart.

とあるので、先に PostgreSQL の初期設定をやる。

PostgreSQL の初期設定

  1. まずは postgre を再起動
$ sudo /etc/init.d/postgresql restart
 * Restarting PostgreSQL 12 database server                                                                                         [ OK ]
  1. 初期ユーザでログイン
$ sudo -u postgres -i
postgres:~$
  1. ユーザを作成 -> エラー。 うーむ
postgres:~$ createuser -d -U ********* -P ****************
createuser: error: could not connect to database template1: FATAL:  Peer authentication failed for user "*************"

調べてみると、

FATAL: Peer authentication failed for user "myusername"
You are connecting to localhost via a unix socket. A user named "myusername" exists, but your current unix user is not the same as that username. PostgreSQL is set to use "peer" authentication on unix sockets for this user/db combo so it requires your unix and postgresql usernames to match.
Connect from the unix user that matches the desired PostgreSQL user - perhaps with sudo -u theusername psql - or change pg_hba.conf to use a different authentication mode like "md5" for this username.
PostgreSQL - Community Help Wiki

WSL のユーザと postgre のユーザを一緒にせいとのこと。 うーむ、マジか。 というわけで続きはここから

sezemi_adminsezemi_admin

ちゃんと PostgreSQL のドキュメントを読めば、分かる話だった ...

createuser -d # -d で database 作成
-U user # user はつくる人。 ここでは postgres の初期ユーザ
-P newuser # newuser は新しくつくる人。 -P でパスワードの設定に進む

createuser

次回、ちゃんとユーザを作ろう

sezemi_adminsezemi_admin

ちゃんとドキュメントを読んで、 createuser でユーザ作成完了

createuser -d -U postgres -P ******

続いて、さっきのユーザをオーナーにした DB を作成。

$ createdb ****** --encoding=UTF-8 --owner=*********
WARNING:  could not flush dirty data: Function not implemented

が、 WARNING 発令。 WSL 固有のものらしいが、 DB ができたんかできてないんか不明。

作ったユーザで、作った DB を使ってみる。

psql -U ************* -h localhost -d ********
Password for user ************* 
psql (12.14 (Ubuntu 12.14-0ubuntu0.20.04.1))
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
Type "help" for help.

*****=> 

うし、 DB できてる!

というわけで、続きは Prisma に渡すコネクション URL を調べるぞい

sezemi_adminsezemi_admin

Prisma に渡す PostgreSQL のコネクション URI だけど、これは何か postgre にある訳ではなく、 postgres への接続を URI にすると、こうなるという話だった。

Understanding connection URI strings in PostgreSQL

user とか pwd を晒す感じになるんだけど、ええんかこれ

まぁ、 localhost なので、一応これで進めよう

sezemi_adminsezemi_admin

DATABASEURL の書き方がわかったので、 prisma のセットアップを再開

Prisma のセットアップ

手順は公式ドキュメントをもとに進める

Add Prisma to an existing project that uses a relational database (15 min) | typescript-postgres

  1. prisma cli を起動 -> 無事起動
$ npx prisma

◭  Prisma is a modern DB toolkit to query, migrate and model your database (https://prisma.io)

Usage

  $ prisma [command]

Commands

            init   Set up Prisma for your app
        generate   Generate artifacts (e.g. Prisma Client)
              db   Manage your database schema and lifecycle
         migrate   Migrate your database
# 中略
  1. prisma プロジェクトのセットアップ
$ npx prisma init

✔ Your Prisma schema was created at prisma/schema.prisma
  You can now open it in your favorite editor.

Next steps:
1. Set the DATABASE_URL in the .env file to point to your existing database. If your database has no tables yet, read https://pris.ly/d/getting-started
2. Set the provider of the datasource block in schema.prisma to match your database: postgresql, mysql, sqlite, sqlserver, mongodb or cockroachdb.
3. Run prisma db pull to turn your database schema into a Prisma schema.
4. Run prisma generate to generate the Prisma Client. You can then start querying your database.

More information in our documentation:
https://pris.ly/d/getting-started

セットアップして、 .env ファイルと /prisma ができた。
tree

Prisma と PostgreSQL を接続

続いて、いよいよ DB 接続情報の記述。

/prisma/schema.prisma ファイルができているので、確認すると、ドキュメント通りのものが自動生成されていて、これはノー編集で次。

.env の DATABASE に接続情報を入れる。

postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public

作った user と pwd 、 database を入れて、最後 schema=public にして編集完了。

つづいて、 Introspection のコーナー

にしても Introspection ってどういう意味なんだろう、と思って、ドキュメントの冒頭の文章の意味を chatGPT さんに聞いてみた。

「Introspect your database with Prisma」という文章は、Prismaに組み込まれている機能である「introspection(イントロスペクション)」に関するものです。
Introspectionは、Prismaが対象とするデータベースから、そのスキーマ定義を自動的に取得して、Prismaのデータモデルとして使用できる形式に変換する機能です。
つまり、データベースに対して手動でスキーマ定義を記述する必要がなく、Prismaが自動的にデータベースからスキーマ情報を取得できるため、開発者は手軽にPrismaを使用することができます。

なるほど、さすがです。

というわけで、ドキュメントではサンプルのテーブルを db で作って、 prisma からテーブル情報を取得するのだけど、その前に db 接続できるか試してみる(テーブルは作ってないから null か何かになると思うんだが)。

$ npx prisma db pull
Prisma schema loaded from prisma/schema.prisma
Environment variables loaded from .env
Datasource "db": PostgreSQL database "******", schema "public" at "localhost:5432"

✖ Introspecting based on datasource defined in prisma/schema.prisma

Error: P1001

Can't reach database server at `localhost`:`5432`

Please make sure your database server is running at `localhost`:`5432`.

あーあー、ごめんなさい。 postgre をまだ動かしてなかったっす。

というところで今日はここで時間切れ。

sezemi_adminsezemi_admin

postgre を起動して、 prisma db pull を行ったところ、結局以下のエラーが発生。

$ prisma db pull
Prisma schema loaded from prisma/schema.prisma
Environment variables loaded from .env
Datasource "db": PostgreSQL database "*****", schema "public" at "localhost:5432"

✖ Introspecting based on datasource defined in prisma/schema.prisma
Error: 
P4001 The introspected database was empty: 

prisma db pull could not create any models in your schema.prisma file and you will not be able to generate Prisma Client with the prisma generate command.

To fix this, you have two options:

- manually create a table in your database.
- make sure the database connection URL inside the datasource block in schema.prisma points to a database that is not empty (it must contain at least one table).

Then you can run prisma db pull again.

null などを返すのではなく、テーブルがないとエラーが出るので、接続確認としてはわからない ... とう状態。

とりあえず、 migrate してテーブルを作ってみよう。 となると、テーブルを定義せねば。 User はこの前やったからいいんだが、問題は Team の定義。

ま、続きは Team テーブルを考えところから

sezemi_adminsezemi_admin

team テーブルに必要なカラムを調べているのだが、予想外にドキュメントが無い ...

一応、以下の通り、順番に調べてみた。

  1. OAuth フローの実装
    • clientId, clientSecret, stateSecret, scopes (必須)
    • installationStore オプションは、インストール情報の保存と取得を行うハンドラーを提供します
  2. Installation オブジェクト
    • installation のサンプル = 手元のファイルと同じ構造
  3. さらに詳しいやつは Installing with OAuth

で、 1. で紹介されていたサンプルコードで db に保存しているのは以下だった

  • enterprise_id // データがあれば
  • team_id

でも 3. のドキュメントでは token を使うとあるんだが、でもその token は bolt を使っていると、自動でやってくれるとある。 うーむ。

とりあえず Installing with OAuth をちゃんと読むところからがつづき。

sezemi_adminsezemi_admin

Installing with OAuth をちゃんと読んだけど、すべて知ってたことだった。 ただ、そのドキュメントに token はちゃんと db にセキュアに保存しようね、とあったので、やるんだろうな、という感。

この token はどのテーブルに持つべきなのかなぁ、と検索していると、いい感じの動画があった

Build your first Slack Bolt App Pt 3: OAuth & Storing User Credentials (w/ MongoDB) - YouTube

サンプルコードもあったので、次回、これを見よう

GitHub - horeaporutiu/oauth-sample at part3-auth

sezemi_adminsezemi_admin

昨日見つけた動画を見る。 これがかなり今の OAuth の datastore で悩んでいるところにクリーンヒットしてた。 ただ、英語なので字幕(自動)がないと死ぬところだった。

Build your first Slack Bolt App Pt 3: OAuth & Storing User Credentials (w/ MongoDB) - YouTube

ここではそのメモを残しておく。


OAuth のフロー

フローはこんな感じだった

  1. add to slack ボタンを押す
  2. リクエストしている scope がチェックされ、 admin 権限が必要かチェック
  3. User が org に入れようとしているか、 workspace 単体に入れようとしているかチェック
  4. App が User の token を持っているかどうかチェック
  5. 持ってなければ db に store / 持っていれば db から fetch

で、この流れをどう実装しているか、コードで説明。

まず、この動画では mongo を使っている。 postgre が一番良かったが、贅沢は言わない。 ただ、これがスゲーいい選択だということに、あとで気付く。

  • app.js と database 周りをやるものに分かれている。これはそう。 ただディレクトリでもう /database と切ってしまって、さらにどのときに使うものなのか、さらにディレクトリを分けてる(わかりいい!)
    • ex. /database/outh/
  • で肝心の store しているコードへ。 これは /database/oauth の store のファイルを見る。 なんと mongo で store するの、めっちゃ簡単やった ... Installation オブジェクトをそのまま key にして保存してた
    • そりゃ JSON 用の DB と言っても過言ではないかんね
    • オブジェクトの key を指定して、 db のフィールドを指定して ... とかやるよりラクすぎる
  • さらに find query も Installation のスキーマ通りにかけるから、これは直感的

いや、 mongo が使いやすいところはこういうところなのね、と再発見。 prisma は mongo に対応していないんだけど、そもそも OR マッパーを使わないでも十分直感的だからいらんのよね。

ただ、 Fly.io は mongo 用の volume を持っていないので NG ... orz

というわけで、続きは 8:57 秒 から

sezemi_adminsezemi_admin

動画の続きをみると、 org インストール / workplace インストール でファイルを分けて store するとのこと。

で、締めに、実際に add to slack ボタンで installation オブジェクトを取得して、 mongo に入るか確認して無事に入ったよ、で動画はおしまい

OAuth のデータストアのサンプルコードを確認

というわけで、動画で言っていたところを GitHub に上がってるサンプルコードで確認する。

  • スキーマの定義

/database/db.js

const usersSchema = mongoose.Schema(
    {
        _id: String,
        team: { id: String, name: String },
        enterprise: { id: String, name: String },
        user: { token: String, scopes: [String], id: String },
        tokenType: String,
        isEnterpriseInstall: Boolean,
        appId: String,
        authVersion: String,
        bot: {
            scopes: [
                String,
            ],
            token: String,
            userId: String,
            id: String,
        },
    },
    { _id: false },
);
  • org インストールのときのデータストア

database/auth/store_user_org_install.js

    const resp = await model.User.updateOne(
      { _id: installation.enterprise.id },
      {
        team: 'null',
        enterprise: {
          id: installation.enterprise.id,
          name: installation.enterprise.name,
        },
        user: {
          token: installation.user.token,
          scopes: installation.user.scopes,
          id: installation.user.id,
        },
        tokenType: installation.tokenType,
        isEnterpriseInstall: installation.isEnterpriseInstall,
        appId: installation.appId,
        authVersion: installation.authVersion,
        bot: 'null',
      },
      { upsert: true },
    );
  • workspace インストールのときのデータストア

/database/auth/store_user_workspace_install.js

    const resp = await model.User.updateOne(
      { _id: installation.team.id },
      {
        team: { id: installation.team.id, name: installation.team.name },
        // entperise id is null on workspace install
        enterprise: { id: 'null', name: 'null' },
        // user scopes + token is null on workspace install
        user: { token: 'null', scopes: 'null', id: installation.user.id },
        tokenType: installation.tokenType,
        isEnterpriseInstall: installation.isEnterpriseInstall,
        appId: installation.appId,
        authVersion: installation.authVersion,
        bot: {
          scopes: installation.bot.scopes,
          token: installation.bot.token,
          userId: installation.bot.userId,
          id: installation.bot.id,
        },
      },
      { upsert: true },
    );

org の場合、 bot の key をすべて null にしてて、 workspace の場合、 bot の key はすべてストアしてるんだよな。 これでなんでなんやろ ... ?

ここまで見たところでおしまい。 続きは org と workspace インストールで何が違うのか、わかってないかもなので、改めて確認する。

sezemi_adminsezemi_admin

公式ドキュメントを見ていると org と workspace インストールで違うところがしっかり書いてあった。

複数のワークスペースにインストールされる、複数のユーザートークンを使用するといったケースのように、アプリが複数のトークンを処理しなければならない場合があります。このようなケースでは token の代わりに authorize オプションを使用する必要があります。
認可(Authorization)

なるほど、 org の場合は token を使わないのね。

が、この authorize オプションというのがなにか、このページにあるサンプルコードからはわからん。

const authorizeFn = async ({ teamId, enterpriseId }) => {
  // データベースから team(ワークスペース)を取得
  for (const team of installations) {
    // installations 配列から teamId と enterpriseId(Enterprise Grid の OrG の ID)が一致するかチェック
    if ((team.teamId === teamId) && (team.enterpriseId === enterpriseId)) {
      // 一致したワークスペースのクレデンシャルを使用
      return {
        // 代わりに userToken をセットしても OK
        botToken: team.botToken,
        botId: team.botId,
        botUserId: team.botUserId
      };
    }
  }

とおもったら、初期化の処理の中にあった。

const app = new App({ authorize: authorizeFn, signingSecret: process.env.SLACK_SIGNING_SECRET });

コンストラクタ引数に authorizeFn の戻り値を入れているので、

      {
        // 代わりに userToken をセットしても OK
        botToken: team.botToken,
        botId: team.botId,
        botUserId: team.botUserId
      };

ここが必要になるということね。 それでこの前の動画では userToken を使っていたのか。 にゃるほど。

なので動画の通り、 key をフィールドにしてデータストアすればよいのか。うし。

sezemi_adminsezemi_admin

公式ドキュメントをまとめると、以下のような処理をする。

  1. org インストールのときは authorize などをコンストラクタ引数にして App をインスタンス化する
  2. authorize には authoizeFn() のような関数を用意して戻り値をコンストラクタ引数にする
  3. authorizeFn() は teamId, EnterprizeId を引数にして、 userToken や botToken などクルデンシャルを返す

ということなので、この前のサンプルコードで Mongo の schema で設定されていたものをストアすればいいってこっちゃね。

const usersSchema = mongoose.Schema(
    {
        _id: String,
        team: { id: String, name: String },
        enterprise: { id: String, name: String },
        user: { token: String, scopes: [String], id: String },
        tokenType: String,
        isEnterpriseInstall: Boolean,
        appId: String,
        authVersion: String,
        bot: {
            scopes: [
                String,
            ],
            token: String,
            userId: String,
            id: String,
        },
    },
    { _id: false },
);

これを、 RDB に入れようとすると、 Installations テーブルを作って、 key をカラムにする。 この Installations テーブルに関連して、 Enterprises, Teams, Users とそれぞれテーブルを作る感じ(一杯 null があるので嫌だなぁ)。

で、 Installations には bot カラムを作って JSON を突っ込むと。 念のため postgres 調べると JSON 型を持っていたので OK でしょう。

これで db を migrate するぞい

sezemi_adminsezemi_admin

prisma.schema にスキーマを書く

サンプルコードで mongo の schema 定義をそのまま突っ込めばよいとわかったので、 prisma.shema にスキーマを定義する。 参考にしたものは公式ドキュメント。

Prisma schema (Reference)

  • 用意するテーブル
    • User
    • Team
    • Enterprise
    • Installation
  • relation
    • User 1 : 1 Installation
    • Team 1 : 1 Installation
    • Enterprise 1: 1 Installation

User は Team や Enterprise とも 1:1 の relation を持つけど、それは必要になったときにやる。

で、あとは prisma 用の VScode のプラグインがあるということなので、インストール。 予測変換ができるようになったので、便利だわ。

というわけで、見よう見まねで書いてみた。

model User {
  id                  Int       @id @default(autoincrement())
  slackId             String    @unique
  createdAt           DateTime  @default(now())
  token               String
  scopes              Json
  installation        Installation[]
}

model Team {
  id                  Int       @id @default(autoincrement())
  slackId             String    @unique
  createdAt           DateTime  @default(now())
  name                String
  installation        Installation[]
}

model Enterprise {
  id                  Int       @id @default(autoincrement())
  slackId             String    @unique
  createdAt           DateTime  @default(now())
  name                String
  installation        Installation[]
}

model Installation {
  id                  Int         @id @default(autoincrement())
  createdAt           DateTime    @default(now())
  updatedAt           DateTime    @default(now())
  user                User?       @relation(fields: [slackId], references: [slackId])
  team                Team?       @relation(fields: [slackId], references: [slackId])
  enterprise          Enterprise? @relation(fields: [slackId], references: [slackId])
  tokenType           String
  isEnterpriseInstall Boolean
  appId               String
  authVersion         String
  bot                 Json
}

id と slackId どっちもいるのかなぁ? いらんように思うんだが。

で、 Installation テーブルの定義で、入れたプラグインから速くも error が出現。

relation の書き方がエラってるみたいで、 slackId で以下のエラー内容。

Error validating: The argument fields must refer only to existing fields. The following fields do not exist in this model: slackId

バリデーションのエラーです: 引数のフィールドは、既存のフィールドのみを参照する必要があります。次のフィールドはこのモデルには存在しません: slackId

公式ドキュメントのサンプルを見ると、 Post (投稿テーブル) は

model Post {
  id        Int      @id @default(autoincrement())
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  published Boolean  @default(false)
  title     String   @db.VarChar(255)
  author    User?    @relation(fields: [authorId], references: [id])
  authorId  Int?
}

author と authorId の key ? を書いているので、この authorId が無いからっぽい。

というわけで、 Installation テーブルを書き直し。

model Installation {
  id                  Int         @id @default(autoincrement())
  createdAt           DateTime    @default(now())
  updatedAt           DateTime    @default(now())
  user                User?       @relation(fields: [userId], references: [slackId])
  userId              String?
  team                Team?       @relation(fields: [teamId], references: [slackId])
  teamId              String?
  enterprise          Enterprise? @relation(fields: [enterpriseId], references: [slackId])
  enterpriseId        String?
  tokenType           String
  isEnterpriseInstall Boolean
  appId               String
  authVersion         String
  bot                 Json
}

こうするとエラーが消えたので、次回これで migrate をやってみる

sezemi_adminsezemi_admin

postgres を起動し、一応テーブル生成確認のため、ログインした上で、 migrate を実行する

$ npx prisma db pull

が、この前と同じエラー。 shema ファイルに書いてるとか書いてないとかの話ではなかったのか ... 。

$ prisma db pull
Prisma schema loaded from prisma/schema.prisma
Environment variables loaded from .env
Datasource "db": PostgreSQL database "*****", schema "public" at "localhost:5432"

✖ Introspecting based on datasource defined in prisma/schema.prisma
Error: 
P4001 The introspected database was empty: 

prisma db pull could not create any models in your schema.prisma file and you will not be able to generate Prisma Client with the prisma generate command.

To fix this, you have two options:

- manually create a table in your database.
- make sure the database connection URL inside the datasource block in schema.prisma points to a database that is not empty (it must contain at least one table).

Then you can run prisma db pull again.

一応 .env を確認したけど、タイポなどは無かったし、 port も 5432 のまま。 うーむ。

気になるのはエラーメッセージにあるここ。

(it must contain at least one table)

あ、もしかして prisma db pull とあるから DB からテーブル情報を持ってくるとかか。

ちょっとあやふやすぎるなこの辺。 次回ちゃんと調べる

sezemi_adminsezemi_admin

Prisma の CLI リファレンスがあったので、 db pull を確認した。

Prisma CLI Command Reference

やはり db からスキーマを持ってくるコマンドだった。

The db pull command connects to your database and adds Prisma models to your Prisma schema that reflect the current database schema.

うえーい。 そりゃエラーになるわ。

じゃぁ、 migrate どうすんねやろと、 ↑ のリファレンスを見てみると、

  • db push
  • migrate dev

の 2 つがある模様、とわかったところで時間切れ。

次はこの 2 つの違いを調べて、実行するゾイ

sezemi_adminsezemi_admin

db pushmigrate dev の違いをリファレンスで確認

db push

ここに migrate との併用ができるかという topic があったので、そのリンクをたどる。

Prototype your schema

db push でスキーマを試してみて、 migrate dev で fix するという使い方ができる模様。

Can I use Prisma Migrate and db push together?
Yes, you can use db push and Prisma Migrate together in your development workflow . For example, you can:

  • Use db push to prototype a schema at the start of a project and initialize a migration history when you are happy with the first draft
  • Use db push to prototype a change to an existing schema, then run prisma migrate dev to generate a migration from your changes (you will be asked to reset)

じゃあ、 db push しようと思ったところで、いつものように ctrl shift p で WSL を起動しようとしたところ、メニューにない。 なんでなんで、と探していて起動できたところで、今日は時間終了。

もうちょいやりたかったなぁ

sezemi_adminsezemi_admin

db push を実行してみるところから。

postgres を起動してログインして、いよいよ db push

$ npx prisma db push
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": PostgreSQL database "*****", schema "public" at "localhost:5432"

🚀  Your database is now in sync with your Prisma schema. Done in 326ms

EACCES: permission denied, unlink '/home/hoppers/first-bolt-app/node_modules/.prisma/client/index.js'

┌─────────────────────────────────────────────────────────┐
│  Update available 4.13.0 -> 4.14.1                      │
│  Run the following to update                            │
│    npm i --save-dev prisma@latest                       │
│    npm i @prisma/client@latest                          │
└─────────────────────────────────────────────────────────┘

index.js に書き込めなかったよ、というエラーはあるものの、どうやらできたみたい。

postgres 側でも確認する。

****=> \dt
                List of relations
 Schema |     Name     | Type  |      Owner      
--------+--------------+-------+-----------------
 public | Enterprise   | table | *********
 public | Installation | table | *********
 public | Team         | table | *********
 public | User         | table | *********
(4 rows)

カラムも見てみるかと思って、 MySQL でいう describe って何かなぁ、とか調べながら実行。

****=> \d Enterprise
Did not find any relation named "Enterprise".
****=> \d+ Team
Did not find any relation named "Team".

え? Team がないだと ... 。 え、なんで ... 。

他で describe できるようなので、そっちも試してみる。

=> SELECT table_name, column_name, data_type
-> FROM information_schema.columns
-> WHERE table_name = 'User';
 table_name | column_name |          data_type          
------------+-------------+-----------------------------
 User       | id          | integer
 User       | slackId     | text
 User       | createdAt   | timestamp without time zone
 User       | token       | text
 User       | scopes      | jsonb
(5 rows)

出てきた出てきた。 ちゃんと指定したとおりにできてる。 でも time zone を指定されてないな。

続きは、この辺、やり直してみるか。

sezemi_adminsezemi_admin

time zone を prisma.shema ファイルで指定するやり方を調べてみた。

結果、以下の記事によると prisma に time zone を指定するオプションがないとのこと。 うーむ。 公式のリファレンス見ても無かったので、そういうことなんでしょう。

ちょっと忘れそうだけど、特に UTC を使う分に問題はないのでタイムゾーンは指定しないでこのまま使う。

Prisma+MySQLで日時をJSTで保存したくなったときに読む記事

で、一応他のテーブルも describe したんだけど、問題なかった。 ただ relation が取れているかどうかがわからんので、これを次回調査。

sezemi_adminsezemi_admin

relation が取れているか調べていると、たまたま見かけた記事に postgres は大文字小文字を厳格に扱っているとあって、 /d でテーブル無いよと言われた原因かもと気づいた。

【PostgreSQL】テーブルが存在するのにリレーション存在しません(relation does not exist)と表示される

試してみる。

******=> \d "Enterprise"
                                          Table "public.Enterprise"
  Column   |              Type              | Collation | Nullable |                 Default                  
-----------+--------------------------------+-----------+----------+------------------------------------------
 id        | integer                        |           | not null | nextval('"Enterprise_id_seq"'::regclass)
 slackId   | text                           |           | not null | 
 createdAt | timestamp(3) without time zone |           | not null | CURRENT_TIMESTAMP
 name      | text                           |           | not null | 
Indexes:
    "Enterprise_pkey" PRIMARY KEY, btree (id)
    "Enterprise_slackId_key" UNIQUE, btree ("slackId")
Referenced by:
    TABLE ""Installation"" CONSTRAINT "Installation_enterpriseId_fkey" FOREIGN KEY ("enterpriseId") REFERENCES "Enterprise"("slackId") ON UPDATE CASCADE ON DELETE SET NULL

はい、出ました。 またちゃんと relation もあることが確認できたぞい。 いえい。

一応 Team と User も確認。

で、 Installation も確認したんだけど、さすがにちょっと情報が多かった。

                                               Table "public.Installation"
       Column        |              Type              | Collation | Nullable |                  Default                   
---------------------+--------------------------------+-----------+----------+--------------------------------------------
 id                  | integer                        |           | not null | nextval('"Installation_id_seq"'::regclass)
 createdAt           | timestamp(3) without time zone |           | not null | CURRENT_TIMESTAMP
 updatedAt           | timestamp(3) without time zone |           | not null | CURRENT_TIMESTAMP
 userId              | text                           |           |          | 
 teamId              | text                           |           |          | 
 enterpriseId        | text                           |           |          | 
 tokenType           | text                           |           | not null | 
 isEnterpriseInstall | boolean                        |           | not null | 
 appId               | text                           |           | not null | 
 authVersion         | text                           |           | not null | 
 bot                 | jsonb                          |           | not null | 
Indexes:
    "Installation_pkey" PRIMARY KEY, btree (id)
Foreign-key constraints:
    "Installation_enterpriseId_fkey" FOREIGN KEY ("enterpriseId") REFERENCES "Enterprise"("slackId") ON UPDATE CASCADE ON DELETE SET NULL
    "Installation_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("slackId") ON UPDATE CASCADE ON DELETE SET NULL
    "Installation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("slackId") ON UPDATE CASCADE ON DELETE SET NULL

というわけで、テーブルもできたので、次回からいよいよチーム情報や Installation を保存する処理を書くぞい

sezemi_adminsezemi_admin

Installation をストアする処理を書く

Installation のサンプルコードを見る

保存する処理を書くにあたって、とりあえず動画とセットになっていたサンプルコードを見る。

oauth-sample/app.js at part3-auth · horeaporutiu/oauth-sample

この前もざっと見たけど、ちゃんと読んでみると、気になったのは 3 点。

  • 初期化の処理の中に store と fetch がある
    • fetch が初期化に必要?
  • const customRoutes = require('./utils/custom_routes'); は何?
  • registerListeners(app); は何?

ここで、ちょっと見ない間に GitHub が優秀になっていて、関数やパスから参照できるようになってる! めちゃ助かる。

まず const customRoutes = require('./utils/custom_routes'); は ↓

custom_routes.js

中身を見てみると、通常 Add to Slack ボタンで OAuth を進めるところ、カスタムページを用意して、そこでリダイレクト URL を発行するものだった。

続いて、 registerListeners(app); はこちら。

/listeners/index.js

これも中身を見ると、

module.exports.registerListeners = (app) => {
  actions.register(app);
  commands.register(app);
  events.register(app);
  messages.register(app);
  shortcuts.register(app);
  views.register(app);
};

とあって、 Listeners を register するとあるので、イベントやショートカットをリッスンする処理をまとめているのだった。 要はこのアプリの機能をまとめているものだった。

なるほどな~

今こちらで作っているものは、かろうじて database 処理は別にしているものの、機能も初期化も同じ app.js になっているので、見通しがわるい。

このサンプルコードは、 app.js をあくまでブートストラップ目的にしている、ということだった。 MVP が終わったら、そういうこともやりましょ。

というわけで、今日はここまで。 残っているは fetch がなぜあるか問題は次回しらべる。

sezemi_adminsezemi_admin

fetch があるのは当然で、 installation 取得によって得られてた token などを使って、 app をインスタンス化するからだね。 次は app 側の installation の store と fetch を書こう

sezemi_adminsezemi_admin

サンプルコード通りの処理以外はしないので、ここは素直に app.js にコピペする。

    storeInstallation: async (installation) => {
      console.log('installation: ' + installation)
      console.log(installation)
      if (
        installation.isEnterpriseInstall
        && installation.enterprise !== undefined
      ) {
        return orgAuth.saveUserOrgInstall(installation);
      }
      if (installation.team !== undefined) {
        return workspaceAuth.saveUserWorkspaceInstall(installation);
      }
      throw new Error('Failed saving installation data to installationStore');
    },
    fetchInstallation: async (installQuery) => {
      console.log('installQuery: ' + installQuery)
      console.log(installQuery)
      if (
        installQuery.isEnterpriseInstall
        && installQuery.enterpriseId !== undefined
      ) {
        return dbQuery.findUser(installQuery.enterpriseId);
      }
      if (installQuery.teamId !== undefined) {
        return dbQuery.findUser(installQuery.teamId);
      }
      throw new Error('Failed fetching installation');
    },
sezemi_adminsezemi_admin

これまたサンプルで挙がっているリポジトリの通り、 DB へのストアなどの処理は /database にまとめる。 こんな感じ。

/database
   /auth
     store-user-org-install.js // Org インストール
     store-user-workspace-install.js // Workspace インストール
   db.js // DB 接続
   find-user.js // ユーザ検索

というわけで、 db.js に DB 接続のコードを書くゾイ。 と思って、 prisma のドキュメントを見ると、え、え、 ts しか使えないの ...

sezemi_adminsezemi_admin

TS 書いてみるのもよいかと思ったけど、そもそもサンプルコードをベースにするなら Mongo でいいんじゃね、という気持ちに。 PaaS として MongoDB Atlas を使えば Free プランもある。

Pricing | MongoDB

で、軽くググると Fly.io に MongoDB Atlas を接続してデプロイするという記事もあったので、これでもいいのでは説。

Backend Deployment - Fly.io and Mongo Atlas - HackMD

sezemi_adminsezemi_admin

DB を Mongo Atlas に変更 ?

Prisma が ts しか対応していない(ちゃんと調べろ、オレ)
and PostgreSQL 微妙にわからん
and Mongo Atlas Fly.io でも使える
and Mongo Atlas ならサンプルコード通りに実装すればおk
という理由から DB は Postgre から変えて Mongo Atlas を使ってみよう。

というわけで、 Mongo Atlas で Fly.io に接続できて and 無料なのか、ちゃんと調べてみる

と思ったら、やっぱり Fly.io のサポートは Postges と SQLite だけだった ... 。 うーむ、悩みどころ。

sezemi_adminsezemi_admin

Mongo Atlas と Fly.io で接続できるのか、この前ググった記事ぐらいしかなかった。 もちろん Fly.io の公式ドキュメントにはない。

Backend Deployment - Fly.io and Mongo Atlas - HackMD

ということは、この記事の手順をなぞるしか検証方法がないんだけども ... 。

で、並行して Prisma の CRUD も確認してみた。

CRUD (Reference)

const user = await prisma.user.create({
  data: {
    email: 'elsa@prisma.io',
    name: 'Elsa Prisma',
  },
})

これなら TS 関係なくね、となったので、 Prisma を引き続き、使おう。

というわけで、今度こそ Prisma で Installation のストア処理を書くぞい.

sezemi_adminsezemi_admin

Prisma で Installation をストアする

まずは公式に載っているので、 prisma client をインストールするところから。

Install Prisma Client | typescript-postgresql

$ sudo npm install @prisma/client

changed 2 packages, and audited 185 packages in 13s

40 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

で、 db との接続を行う db.ts を /prisma に書く。

db.ts
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

この import 時点でエラー発生。

error

軽くエラーメッセージで調べてみると、 npx prisma migratenpx prisma db push を行ってないからとのこと。 一応やり直してみたけど解消されず ... 。

あ、そもそも ts って、まだこのリポジトリに入ってたんだっけ、という疑問。 消してしまってたかも

sezemi_adminsezemi_admin

といらえず import のエラーメッセージ Did you mean to set the 'moduleResolution' option to 'node', or to add aliases to the 'paths' option? で調べると、 ↓ の記事がヒット。

...Did you mean to set the 'moduleResolution' option to 'node', or to add aliases to the 'paths' option?と出た時の対処法 - Qiita

この通り、 tsconfig.json にある "moduleResolution": "node", を enable してみる。

tsconfig.json
    "module": "ES2015",                                /* Specify what module code is generated. */
    // "rootDir": "./",                                  /* Specify the root folder within your source files. */
    "moduleResolution": "node",                       /* Specify how TypeScript looks up a file from a given module specifier. */
    // "baseUrl": "./",                                  /* Specify the base directory to resolve non-relative module names. */

なんということでしょう~ ♪ きれいに import エラーが消えました ♪
fixed import error

というわけで、続きは db.ts で接続処理を引き続きやる

sezemi_adminsezemi_admin

prisma の公式 に則って、引き続き、接続処理を書く。

db.ts
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

async function main() {
  const allUsers = await prisma.user.findMany()
  console.log(allUsers)
}

main()
  .then(async () => {
    await prisma.$disconnect()
  })
  .catch(async (e) => {
    console.error(e)
    await prisma.$disconnect()
    process.exit
  })

main はとりあえず動作検証するために書いた。

というわけで、実行してみる。

$ npx ts-node .prisma/db.ts 
(node:885) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.
(Use `node --trace-warnings ...` to show where the warning was created)

import { PrismaClient } from '@prisma/client';
^^^^^^

SyntaxError: Cannot use import statement outside a module

import でコケる ... 。 ぐぬう。 エラーメッセージでググった記事で、 package.json に

  "type": "module",

というプロパティを有効化せよ、ということなので、これを追加。

node.js エラー「SyntaxError: Cannot use import statement outside a module」が発生した場合の対処法 | mebee

再度実行。

$ npx ts-node ./prisma/db.ts 
TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts"

うえーい。 またエラーなのでエラーメッセージで調べてみると、 tsconfig からさっき追加した "type": "module" を消せと。 いやいや、それ消すと import できませんねん。

ts-node の Unknown file extension ".ts" エラーを解決する - Qiita

この記事の通り、とりあえず --esm オプションをつけて実行してみる。

$ npx ts-node --esm ./prisma/db.ts 
[]

おけ、成功! 無事に接続できた。

というわけで、オプション無しでできるように記事の通り、 tsconfig に以下を追加。

tsconfig.json
    "ts-node": {
      "esm": true,
      "experimentalSpecifierResolution": "node",
    },

実行してみると、ぐぬう、エラー。

$ npx ts-node ./prisma/db.ts 
/usr/local/lib/node_modules/ts-node/src/index.ts:820
    return new TSError(diagnosticText, diagnosticCodes);
           ^
TSError: ⨯ Unable to compile TypeScript:
error TS5023: Unknown compiler option 'ts-node'.

えー、 "ts-node" プロパティをしらんだと ... 。 tsconfig でもエラーがでてて、 "ts-node" は不明ですって ... 。

ちょっと訳わからんまま進めてきたけど、これは NG なので、次回ちゃんと調べよう。

sezemi_adminsezemi_admin

そもそも import でコケるのがおかしいので、そこから再調査。 調べてみると、以下の Issue にあたる。

import { PrismaClient } from "@prisma/client" · prisma/prisma · Discussion #2222

import statement is not yet supported in NodeJS by default.

なんですと ... 。 確かに app.js とか見てもそうだった。 orz

というわけで、その Issue でもコメントアウトされている、

const { PrismaClient } = require('@prisma/client');

に書き換えて実行。

$ npx ts-node ./prisma/db.ts 
[]

いけたで!

というわけで、 db.ts を DB 接続用に変える。
調べていると、以下の記事が参考になりそうなんだけど、ここでタイムアップ

Nest.js の ORM に Prisma を導入してみる - Qiita