firstSlackApp
Readme
チームの心理的安全性に効く Slack App を作りたいと思い、その作り方を勉強することにした。これはその学習メモを残していく用のスクラップ。
ちなみに times チャンネルにログを残しているのだが、それだとコンテキストが一発で辿れないため、中断すると、そのコンテキストを理解するだけで勉強時間が終わってしまう。
このため、スクラップにログを残しておいて、スグにコンテキストがわかるようにする
What to learn
Slack App は色々作り方があるのだけど、今回は Bolt という Slack 謹製の SDK ? Framework ?? を使う。
で、基本的にはこの Bolt のチュートリアルを使って進めている
以下はすでに Done
で、次の イベントの設定 (HTTP) に進んでいると、 ↓ と同じエラーが出て、「あ、そうか。普通サーバから Request 飛ばすもんな。それな」となったところで中断
で、今日ようやく時間が取れて、再開したところ、一応 Slack App 動くか試してみるかと思って 3. ローカルプロジェクトの設定 をやると、見事にコケてしまい、エラーを見ると、以下の環境変数が無いよ、という話
SLACK_SIGNING_SECRET
SLACK_BOT_TOKEN
環境変数を .bash_profile
で設定したところで、今日は終了。
またまた時間が取れてやってみたところ、$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
が動いたところで完了。また一個も進んでない ...
この 4. で Request URL を発行するのにあたって、 ↓ の記述に従って、
ローカル開発では、ngrokのようなプロキシサービスを使って公開URLを作成し、リクエストを開発環境にトンネリングすることができます。ローカル開発のためのSlackでのngrokの使用については別のチュートリアルがありますので、そちらを参照してください。
このチュートリアルを進めることに。
読んでいると、ソケットモードというのを enable にすると、 HTTP Request URL を使わなくても、 WebSocket URL とやりとりが出来るとのこと。なる~
というわけで、このチュートリアルの手順を進める
- 自身の Slack App の設定画面で Socket Mode というメニューがあるので enable にする
- Token が発行されるので、任意の名前 "socket_mode_token" をつけてメモる
- その Token を
SLACK_APP_TOKEN
という環境変数に設定するexport SLACK_APP_TOKEN='xapp-***'
- ↑ を .bash_profile に追加して、
source ~/.bash_profile
をした
今日はここで終了。
続きは、 Next make a simple change to your basic Bolt initialisation code: の下にあるように、 app.js にソケットモードを有効にする設定を書くところから
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 にリクエストするやつをやるんだけど、これは飛ばして、本線に戻った。
- Event Subscriptionsの下にある、Enable Eventsというラベルの付いたスイッチを切り替え -> 完了
- Enable Events スイッチの下に Request URL の入力ボックスに URL を貼り付け -> ?
画面に Request URL ボックスが表示されてないやんって思ったんだが、
Socket Mode is enabled. You won’t need to specify a Request URL.
と Enable Events の設定画面にあったのを見落としていた。というわけで Request URL は無しで次に進む
- Subscribe to Bot Events までスクロールします。メッセージに関するイベントが4つから選択:
-
message.channels
あなたのアプリが追加されているパブリックチャンネルのメッセージをリッスン -
message.groups
あなたのアプリが追加されている🔒プライベートチャンネルのメッセージをリッスン -
message.im
あなたのアプリとユーザーのダイレクトメッセージをリッスン -
message.mpim
あなたのアプリが追加されているグループ DM をリッスン
ここまで読んだところで今日はおしまい。続きはこのイベントを選択するところから
あと、今後ハマったときに参考にするかもと思った記事があったので、メモっておく
続きで、 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 できた。
今日はここでおしまい
今日から次のステップに進む
今度は作った bot = Slack App がいるチャンネルや DM で "hello" が含まれるメッセージがあると、 bot が反応して "Hey there @user" と返すやつをつくる。
サンプルは ↓
// "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.
これを追加して ↓ で動作を確認。
- アプリを再起動
- bot をチャンネル or DM に追加
- hello を送信すると bot が反応
そして、見事にウンともスンとも動かん結果に ...
まぁ、最初はこんなもんだよね。というわけで続きはここから
"hello" に Bot が反応しないやつ、
- アプリの再起動
というのを、 Slack そのものを再起動するのかと勘違いしたけど、そうではなく Slack App を再起動なのではと思いやってみた。
$ node app.js
⚡️ Bolt app is running!
で、 Bot がいるチャンネルでやってみると ...
動いたやん!いえい :+1:
というわけで次ぃ
次にやるのは、ボタン、選択メニュー、日付ピッカー、モーダルなどの機能を使用する とのこと
手順は↓
- Slack App の Menu で Interactivity を有効にする
- Request URL を設定する
すでに有効になっていたのと、 Socket Mode が有効なのでパス。
では、イベントを 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 が動くのかなぁ、と考えていると時間切れ。
これを深堀りすると、ちょっと時間がかかりそうなので、そんなもんかと割り切って、次に進も
チュートリアルで、加えた blocks[]
の配列(オブジェクト)の中身を解説してくれていたのでメモ
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 は ↓ で作れるとのことで、サンプルを見てみると、なるほど感がある
ただ、ボタンを押しても何も起こらないので、これにハンドラーを追加して、ボタンを押すと "@user clicked the button" と返すようにする
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()
がちょっとわからんな、と思いながら、とりあえずこれで動かしてみる
無事にボタンを押すと、メッセージが表示された。
一旦、今日はここまで。次は先に進むか、一応、この追加したハンドラーが何しているのか調べるか、どっちかから
とりあえず先に進めてみた。
なんとこれで終わりだった ...
で、これからは興味のあるものを進めてね、ということで挙がっていたリストが ↓ だった。
こっから先は作りたいやつのユーザーストーリーを眺めて、勉強するものを決める。なので、この前のハンドラーが何をしているのかは本筋から外れるかも知れないので、また出てきたら調べてみよう。
というわけで、ユーザーストーリーをまとめる。
ユーザーストーリー
- Slack のメンバーを取得する
- App が特定の曜日になるとランダムでメンバーを選びメッセージする
- メッセージ内容は固定でフィードバックの趣旨を説明するもの
- リストを出すボタンを用意する
- フィードバックを受ける人がボタンを押して App がフィードバックする候補リストをモーダルで出す
- フィードバックを受ける人がフィードバックしてもらいたい人を 5 人選ぶ
- App が Google Form API にフィードバックフォームの作成リクエストを出す
- App が Google Form API から完了のステータスとフォームの URL のメッセージをリッスンする
- これは Google Form の Bot を用意してメッセージを吐いてもらったほうがよいかもな
- App がフィードバックする人にフォーム URL をメッセージする
- App が Google Form API から 5 人のレスポンスの完了のメッセージをリッスンする
- App がフィードバックを受ける人にフィードバック終了と Google Form の URL をメッセージする
続きは整理したユーザーストーリーをもとに、次のステップで挙がっていたリストをザッとみて、勉強することを決める。
昨日挙がっていた次のステップに挙がってたリストをしげしげと眺める
ザッと見てユーザーストーリーに必要そうなものは ↓ だったけど、全然足りない ...
- WebAPI で指定日付になにかするということもできそう
- 応答とかをイベントをリッスン出来る Event API
- ボタンのクリックとかは アクションのリスニング
- モーダル周り
- OAuth もいずれ必要になるんだけど、必要になるまでは触れない
- 3rd Party からの request を受け取る context
まずはなにはともあれ、チームのメンバー情報を得たいので、 Web API を眺めてみるかなぁ。
ただ、ちょっとチュートリアルのメニューの下に "リファレンス" とあったので、こっちを先に見てみたほうが良い気もする。
うーむ ... 次はリファレンスをザッと見てから、いらなかったら Web API を眺めよう
リファレンス(Appインターフェイスと設定) を読んでみたんだけど、文字通りリファレンスで一覧でまとめたものだったので、ザッと見て、 Web API を読むことに。
とりあえず慣れる上で、そこにあった日付と時刻をもとにしたサンプルをアレンジして書いてみることにした。
// 勉強の開始時間 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
を使ってないので、これ動かないのでは、と感じてる。。
次にちょっと調べてみよう
とりあえず、この前書いたコードで動くかどうか、 Unix エポックタイムを変えてやってみた
const whenLearningStart = '1643330100';
で、 "wake me up" というメッセージをリッスンするので、それを post して待っていると、ナント動いた ... 。
この result
はなんだろうと思ってググってみたりしたんだけど、その途中で、同じようにスクラップで 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 がなぜ動くのか、というところから
あとは Zenn を開いたときに、見かけた記事が今後必要になりそうと思ったのでメモっておく
さらにそもそも Google Form API いらないかもと思ったやつがあったので、これもメモっておく。こういう使い方があるとというパターンを挙げてるリポジトリに、企業文化の定着調査的なやつがあった。
$result
が特別なグローバルな変数なんかな、って思って、違う名前にして試しみたら、動いたので、変数名は関係なかった。
今日はそれだけ~
$result
という変数そのものがいらんのでは、と思って、削ってみた。
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 を読み進める。
が、目的は "メンバーリストを取得する" なので、読み進めるよりググったほうが速いんじゃね、と思って検索してみるとあった。
しかもサンプルコードがあるので、このコードを使おうと思って読み進めてるところで、時間終了。
// 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;
});
}
token は context で持ってこれるんだっけかな
コメント部分、
// 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 のところから
これまた今後、他の API との連携を考えるときに使えそうなやつがあったのでメモ
引き続き 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.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);
}
}
ちなみに、最後の起動のところもエラーが出てる。
もうちょっと、単純な例から始めたほうがよいと思って、いろいろググると、簡単なやつがあったので、それを試してみる
How To List Slack Users With Node | by Phil Andrews | Level Up Coding
これによると users を見るには permittion が無いとダメということだったので、 Slack App の設定画面から、 OAuth & Permissions を開き、 Scope という項目から users:read
を OK にした。
で、サンプルコードを元に書いたのが ↓ 。
(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.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 を取り出すところから
変数名を members にしていると、 users.list() で members の要素とゴッチャになるので、 users に変えて書くことにした。
で、 name だけを出そうとすると users.list()
のサンプルコードのように forEach で取り出すしか無いので、ちょっと関数を分けて、 say に渡すように変更。
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 オブジェクトにするところを書いてないので、続きはそこから
ようやく、 1 ヶ月ぶりぐらいに時間が取れたので再開。スクラップに書いておいてよかった。たどりやすい
というわけで、 say()
にわたす userNames を取り出す retrieveUsersName()
を書くところから。
で userNames は配列で渡したほうがよいかと思い、 ↓ のように書いてみた
function retrieveUsersName(usersArray) {
usersArray.forEach(function(user) {
userNames.unshift(user['name']);
});
}
と書いてから、文字列で連結したものが良いかと思い直してる ... 。 そっちのほうがいいな
retrieveUsersName()
の戻り値は文字列にしようと思って ↓ のように変更
function retrieveUserNames(usersArray) {
usersArray.forEach(function(user) {
userNames += user['name'] + ', ';
});
}
これで動かしてみた!
まぁ、そんなもんだよな、と思って、 retrieveUserNames() で、
usersArray.forEach(function(user) {
userNames += user['name'] + ', ';
console.log(userNames);
});
デバッグすると、
slackbot,
slackbot, hirose,
slackbot, hirose, practicemakeslackapp,
と出てくるので、ちゃんと出来てるんだけどなぁ、と思っていたら、「あー、返却してないやん」という初歩に気付いた。 ぐぬう
というわけで、 return を入れて完了。
function retrieveUserNames(usersArray) {
usersArray.forEach(function(user) {
userNames += user['name'] + ', ';
});
return userNames;
}
というわけで動かしてみると、
無事にチャンネルに入っているやつの名前が出てきたぞい。いえーい :tada:
続きは、メンバーリストが出せるようになったので、次の
App が特定の曜日になるとランダムでメンバーを選びメッセージする
これを書くぞ
次のユーザストーリーを進める
- App が特定の曜日になるとランダムでメンバーを選びメッセージする
- メッセージ内容は固定でフィードバックの趣旨を説明するもの
- リストを出すボタンを用意する
特定の曜日にメッセージできるメソッドなりが無いかなと思って、いろいろググっていると、ドンピシャのやつがあった
とりあえず今日はこれを調べたところでオシマイ
続きは載っているサンプルコードを動かすぞ
// 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 便利よね
続きは、この補完を使っていい感じに特定の "曜日" がセットできるメソッドを見つけるところから
他にもあるかも知れないので、クエリを残しておく
Date()
のメソッドを確認するため、試しに new Date().getDate;
を書いて、 getDate から参照を辿ってみると、 interface だけどメソッドの定義があった。 いえい
/** 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() の処理をするという感じで良さそう。
というわけで、何が出力されるのか確かめてみると、
console.log(new Date().getUTCDate()); // 15
15 って ... day が出てきてるやん。 ぐぬうってなったところでオシマイ
書きながら思っていたんだけど、 getUTCDate() の引数に today の UNIX TIME を入れないといかんのかも
もしかしてと思ってみると、ナント、定義がメソッドの上のコメントにあったのに、メソッドの下に定義があると勘違いしてしまった模様。 アホアホ
/** 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 ...
定義が書かれているコメントの場所を間違えて getUTCdate()
を使ってたけど、正しくは getuUTCDay()
だったことに気付いたので変更
console.log(new Date().getUTCDay());
これで動かしてみたら ...
$ node app.js
3
⚡️ Bolt app is running!
今日は水曜日なので、無事に出力された模様。
月曜スタートとして、
- Mon ・・・ 1
- Tue ・・・ 2
- Wed ・・・ 3
- Thu ・・・ 4
- Fri ・・・ 5
- Sat ・・・ 6
- Sun ・・・ 7
かな? Sun が 0 な可能性もあるな。
この出力だと具体的な日時がないので、 setDate()
と setHours()
で日時を指定して、 scheduleMessage()
の post_at のパラメータに入れればよいな。
というわけで、続きは setDate()
と setHours()
を使って具体的な日時を作るところから。 進んできたぞ~
setDate()
と setHours()
で日付をセットするところから開始
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 だったよな。ごめんなさい ... 。
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 なのでわからんと思って変換ツールで調べてみると、あれ、エラー ... 。
なんでやろ。 そもそも、なぜ 1000 で割ってるのかと調べてみたら、なるほどね。 桁数が違うときがあるのね
なら変換ツールを変えたらいけるかもと思って違うツールを試してみたら、無事、意図通りの時間になってた。
ここまで調べたところで今日は時間切れ。
続きは scheduleMessage() を使って、いよいよメッセージを送信してみるぞ
scheduleMessage()
の処理を書くところから。
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()
を入れて書き直し
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()
が効いていない模様。 なんでじゃ
というわけで、次回は、またまたこの調査から
getTime()
などの関数の参照ができなくなっていて、 lib.es5.d.ts というファイルも無い。 どういうこった
そもそも
$ 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
これは出るので、なんだろうなぁという気持ち
date = new Date();
これは /home/user_name/.vscode-server/bin/c722ca6c7eed3d7987c0d5c3df5c45f6b15e77d1/extensions/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts
で参照ができるのに、
dateOfWeek = date.getUTCDay();
の getUTCDay()
は参照ができない。
ただ ↑のパスから辿るとちゃんと、 /home/user_name/.vscode-server/bin/c722ca6c7eed3d7987c0d5c3df5c45f6b15e77d1/extensions/node_modules/typescript/lib/lib.es5.d.ts
があって、そこにメソッドもあったんだよなぁ
久々に再開。
ts のライブラリが参照できなかったので、一旦、グローバルインストールしてた typescript などは一旦アンインストール。
ちなみに WSL でやると、どうやらパーミッションが上手いこと設定されず、何度も chown をやることになった。 なんでやねん
で、再度 ts をローカルにインストール
$ npm install --save-dev typescript
-
./node_modules/.bin/tsc --version
->Version 4.6.3
-
$ ./node_modules/.bin/tsc --init
-> tsconfig.json - tsconfig.json を編集
ここまでやったところで終了。
それでも Date のメソッドを参照できなければ、最終的には import で解決する
やっぱり Date のメソッドを参照できないので、 import で lib を直接入れてみる
import { Date } from "./node_modules/typescript/lib/lib.es5.d.ts";
としてみても、 Date オブジェクトが認識されてない ...
うーん、どういうことだろう
ちょっとラチがあかないので、一旦、別ディレクトリで一から構築する。
といっても、 npm install @slack/bolt
するだけの簡単なお仕事なので、スグに完了
で、動かしてみると、やっぱり Date.setHours()
で指定した時刻にならない ...
VScode 上でも正しく lib を参照できず、 any になってしまっている。 なんでじゃ~
結局進捗せず、今日はここまで
js から ts のライブラリを呼び出すときに、何かお作法があるのかと思って、調べていると、そもそも es2015 の lib (今回欲しい es5 のやつではない)を読んでいるから、それを変える方法がないか、ということに調査の方法を切り替え。
で、調べていると、 tsconfig で lib の設定を追加すると、使っているライブラリを変えられるとのこと。 ts のファイルだけじゃないかなぁと思っているんだが、物は試しでやってみることにした。
参考にしたのは ↓
- TSConfig Reference - Docs on every TSConfig option
- tsconfig.jsonを設定する | TypeScript入門『サバイバルTypeScript』
で、一応、↓のように変えてみた。
{
"compilerOptions": {
"target": "ES5",
"lib": ["ES2020", "DOM"],
"module": "ES2015",
}
これで次回、参照できるか確認するゾイ
やっぱり tsconfig を設定してみても参照できず ...
一旦、ちょっと離れて JS の Date オブジェクトを使えるか試してみることにした。
date = new Date();
console.log(date.getDate());
これで 25 が返ってきたので、動いてるっぽい。
次回、ちょっとこっちのメソッドに切り替えてやってみよう
ようやく時間が取れたので、 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()
して、ちゃんと時刻設定が出来たゾイ。 続きはこれをもとに指定曜日にメッセージを出すやつで試す。
久々やって前進するといいなぁ
T02 と出力されるのは JS の format の問題みたい。 Slack App って UNIX 時間だったので、いけんのかな
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 !
というわけで、次回は指定曜日にメッセージを送信するやつに再チャレ
指定日時が設定できるようになったので、いよいよメッセージを送信するやつをやる。
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 も吐いてないので、動いてない状態。 うぇーい。
ということで、今日は時間切れ。 続きはこの調査
というわけで
scheduleMessage()
の仕様を見ても引数の漏れはなく、単純な typo も無かった
UTC で 9 時間の時差を入れていたけど、これ入れないほうが良いのではと思ったので、やってみても動かんかった ... 。
うーむ、あとは仕様を確認したときに気になったんだけど、 post_at
が int なので、今のやつだと float になってしまってるので、それがいかんのかも。
次は UNIXTIME で float にしないやり方があるか、調査してみよう
setUTChours()
の戻り値がミリ秒単位で戻るので、 / 1000 してるんだけど、これで float になって動いてないのかもと推測( scheduleMessage()
の仕様では int )。
というわけで、
-
setUTChours()
の戻り値をミリ秒単位にしないやり方
これ調査してみたけど、引数の option は特に無いし、他のメソッドも無さそうだった。
うーむ、他に無いかなぁと考えたところ、小数点切り捨てで良いのでは ... と閃いた(そんな大層なものではないが w)。
そう思って、試しに小数点を切り捨てた数値で UNIX 時間から時間に変換すると、
- 1653991200.188 -> 1653991200 -> 2022-05-31 19:00:00
となったので、行けそう!
というわけで、小数点切り捨ては色々やり方があるんだけど、 メジャーっぽい Math
の trunc()
が良さそうだった。
[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
次はこれで動かして見るぞい
動かしてみたところ、、、ぐぬう、動かんかった。 error 吐いてくれれば、まだ何か掴めんだけど。
と仕様を見ていると、 api が test 出来る様子
chat.scheduleMessage method | Slack
これで token, channelId, post_at, text を入れてテストしてみると、ナント
{
"ok": false,
"error": "invalid_auth"
}
ええー、そもそも認証でコケてたんかい ...
で、ここで時間切れ。 次回はこの test を通すところから
久々に再開。
API のテストをするところから。
パラメータを↑で設定テストしたところ、
{
"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."
}
]
}
]
}
]
}
}
で無事に通った!
で、投稿する時間を待っていると、
無事に投稿された! ヤター! 時差を考慮した UTC にしないといけないこともわかったので、これでアプリを動かしてみる。
ドキドキしながら待っていると、、、 orz 今日もアカンかった。
もしかすると、ちゃんとインスタンス化できてないのかもと思って、 init の処理を追加
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 と出るようになったけど、それでもアカンかった ... 。
なぜじゃ ...
とりあえず 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
ちゃんとレスがきて、
動いた!
Slack の API じゃなくて、俺の async が間違っていたのか ... orz
というわけで、指定曜日にメッセージを出すプログラムを変更。
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' ]
}
}
メッセージがポストされたゾイ!
いえーい !!
このログを振り返ってみると、 3 ヶ月かかってた ... 。 トホホ。 まぁ前に進めて良かった。
次は text じゃなくて blocks を送るかな。 とりあえず次に考えよう~
- App が特定の曜日になるとランダムでメンバーを選びメッセージする
- メッセージ内容は固定でフィードバックの趣旨を説明するもの
- リストを出すボタンを用意する
- フィードバックを受ける人がボタンを押して App がフィードバックする候補リストをモーダルで出す
- フィードバックを受ける人がフィードバックしてもらいたい人を 5 人選ぶ
次のユーザーストーリーはこちら
- App が特定の曜日になるとランダムでメンバーを選びメッセージする
- メッセージ内容は固定でフィードバックの趣旨を説明するもの
- リストを出すボタンを用意する
- フィードバックを受ける人がボタンを押して App がフィードバックする候補リストをモーダルで出す
- フィードバックを受ける人がフィードバックしてもらいたい人を 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 のサンプルをとりあえず送ってみよう
まず使いたいコンポーネントを整理する。
- メンバーリストのチェックボックス(レビュアーを複数選択)
- ボタン(依頼するボタン)
これで Refernce を辿ってみると、ドンピシャで users_list を出せて複数選択できるやつがあった。
で、ボタンは特に 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"
}
]
}
]
}
これだとエラーが出ていて、候補で出てきているものを入力してもエラーになる。
うーむ、やっぱり一足飛びにはできないなぁ。 続きはもう少しちゃんと Block Kit の初歩から始めてみよう。
久々に時間が取れた。 Block Kit Builder で試すところからスタート。
と思ったけど、これまた API と同じで上から順番に読んでも仕方がなく、自分のやりたいことから調べる。
メッセージで出したいものをまとめる
- 説明文
- チェックボックス
- ユーザーリスト
- 送信ボタン(選択したユーザーをフィードバックフォーム送信処理に渡す)
これを Block Kit Builder で作ろう。
まず Block type で使えるものを整理
Actions にチェックボックスやボタンっぽいのがありそうなので見てみると、 elements という key にそれが指定できそう。
なんだ actions だけでなく、 section, context, input の Block type で使えるやん。
というところで、ここまで調べたところで終了。 とりあえず次はボタン出して、説明文を載せる Block を作るゾイ
フィードバックをリクエストする説明文を書くところから。
section
はシンプルなテキストを送る Block なので、これを使う。 で、 text
に plain text
と mkdwn
を選べる。 まぁ、普通に Slack のマークダウンを使えば良いので、これは mkdwn で良い
というわけで説明文を書いてみた。
{
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "毎週月曜は *フィードバックリクエスト* を送る日だよ\n今回は誰にフィードバックをもらおうか?\nメンバーから *3 人* 以上選んでね"
}
},
{
"type": "divider"
}
]
}
で、 Block Kit から送信。
無事に受信できた。
今日はここまで。 次は Button ではなく、 user_list をチェックボックスで出すのをやる
Checkbox を出せる Block type はなにかなと見ていると、 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.
あとはチェックボックス
これを見て見よう見まねで書いてみたんだけども、、、案の定エラーでまくり
{
"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"
}
}
]
}
うーむ ... 。
というところで時間切れ。 ちょっと input のサンプルの JSON から始めてみよう
引き続き、 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": "メンバーさん"
}
}
]
}
}
]
}
というわけで、前回までに作ったメッセージを入れてみた。
ええやんええやん。
というわけで、 User List を出すやつを取り組む。
ちょっと時間が無かったので、軽くドキュメントを読んだんだけど、 element
で指定せよ、とのこと。 マジか。 check box やらんでよかった ...
The type of element. In this case
type
is alwaysmulti_users_select
.
次回は実際にこの 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 があった
で、このサンプル通り書いてみたら、これまたスンナリできてしまった ...
{
"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
}
}
]
}
いや、スンナリすぎる。。
というわけで、次は下に送信ボタンを作って、押すと、この選んだメンバーメッセージを送信するやつをやる
選んだメンバーにメッセージを送信する処理について調べてみると、 app.action()
が適任っぽい。
で、これどうやって使うのかと調べていると、
- multi_users_select などから action_id や、 button から block_id をリッスン
- action の引数にリッスンしたものと、やりたい処理を入れて実行
という流れかな。
ほどよいサンプルコードを探したんだけど、探しきれず、今日はタイムオーバー。
メッセージでインタラクティブなやり取りをする流れをまとめたリファレンスを見つけたので読む
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 を使っているので、調べたらこれもリファレンスもあった
続きはこれを読んで payload を確認するところから
前回見つけた Socket Mode implementation のリファレンスを読んでいると、序盤に intro があったので、まずそっちを先に読む。
ざっと関連することをメモ
- 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()
で、これは初めてみた。リンクされたドキュメントはこちら。
このメソッドはまた今度試してみるとして、これでイントロはオシマイ。
で、続きは implementation のリファレンスに戻って読むところから
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 コマンドを叩いてみよう
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 がいい感じっぽい。
ここまで調べたところで時間終了。
つづきはこの Fetch API を使って request を出すところから
Fetch API で request を出すコードを書くところから再開。
見様見真似で書いてみた。
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
そうなのか ... うーむ。
引き続き、ちょっと調べていると、参考になるものがあったので、次回やってみよう。
ちょこちょこ調べてたんだけど、ようやくまとまった時間が取れた。
で、そのちょこちょこ調べているときに、そもそも今みてるドキュメント、 Socket Mode implementation は Bolt など SDK 使わずに実装する人向けだった。 あたしゃ、そもそもせーへんやつ ... なんというムダなことを。 ちゃんと読もう。
さらに、 apps.connections.open
メソッドも Websocket URL が必要な場合であって、 Bolt にはリクエストの payload をリッスンするメソッドがそもそも用意されているのであった ... 。
メソッドはその名の通り action()
で action_id
と行う処理を引数にするもの。
というわけで、この action()
を試せるコードが無いかと調べてみる。
調べてみると、そもそも参考にしていた記事にいい感じに試しているコードがあった。 うーむ、ちゃんと読もう 第 2 弾。
という軌道修正をしたところで、タイムアップ。
続きは、この action()
のサンプルをもとに自分で試してみるところから
サンプルコードを試すところから、と言っていたら、前に書いたことがあったのだった ... 。 スクラップに残しても、こういうことは忘れてしまう。
サンプルコードを見るに、message を送る処理で block 内に action_id を入れて、 action は別に切り出すのね。
あとは入力されたメンバーを確認するのにどうするか、調べてみると、同じように options()
を使うみたい。
ん? 違うな
外部データソースを使用するセレクトメニューなどから送られる選択肢読み込みのリクエストをリッスンします。
と思って調べると、そうか、そもそも action()
で取れる引数は action_id
だけじゃないのね。
というわけで、この前の特定曜日にメッセージを送る処理に、 block を追加したところでおしまい。
(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()
を書くところから
action() の処理を書くところから再開。 と言っても、ほぼコピペ
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 は必須だった。
その 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 だった)。
いえーい、キタ!
えへへ。 うれしいねぇ
実際にメンバーを入力してボタンを押すと ... ちゃんとリッスンして 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()
使えるみたいね。
というわけで、続きはリアクションを書くところから
ボタンなどを押したアクションへの応答を書いてみた。
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
えー、マジで。 次回もちょっと他に手がないか調べてみて無かったら、モーダルでユーザを開くようにメッセージを変更する。
ぐぬう
section
タイプで accessory
にするといい感じにいけるっぽい。
{
"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"
}
}
}
]
}
前回チラッと書いた 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": "メンバーを選ぶ"
}
}
}
]
}
これでメッセージも変更
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 を確認してみる。
無事にメッセージを受信。
メンバーを選んでみる。 say()
も動いた。 挙動としてはバッチリ!
payload は ...
{
type: 'multi_users_select',
action_id: 'selected_users',
block_id: '****',
selected_users: [ 'U02Q******, 'U02Q******' ],
action_ts: '*******************'
}
ヤター、取れた!
次はこの users を repond()
で出してみて、その選んだユーザにメッセージを送信する処理を書こう。
いえーい
先日の payload
だとリクエストを出した user
が取れないことに気づいたので、他で試してみる。
で、 action()
に block_id
を設定してみたり、単に block_actions
にしてみたけど、まったくレスポンスが取れない。 ぐぬう。
結局、 action_id
に戻して body
からアクセスして取ることに。
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() を書くところから
ちょこちょこやってたんだけど、まとめて時間が取れたので更新
- multi_users_select で取れたのが、 id だけだったので、これを名前にする必要があった
- API に
users.info()
というのがあるので、それを使ってみる
こんな感じで書いた。
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.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);
});
});
これでやってみたところ ...
practicemakeslackapp
****se
でけました。
次は、 select したユーザにメッセージを送る処理を追加するところから。
選んだユーザにメッセージを送るには、 chat.postMessage()
を使う。
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.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 で書いた。
次回はこれを試して見るところから。
前回書いた、選んだ相手にメッセージを送信する処理を試してみる。
無事に送れた~。
ただ、 respond()
で送る相手を返すところ、どうも 2 人目も同じ名前を返していて、おかしい。
うーん、ただ選んだ相手の名前を伝えるだけだから、 say()
でやってみるかと変えてみた。
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);
}
});
動かしてみると、うまく出た。
なぜかは知らん。。。
というわけで、次は送った相手にフィードバックフォームのモーダルを block kit で作る。 いよいよ佳境だ
フィードバック入力フォームを作る前に、先にフィードバックをリクエストしたユーザを、依頼メッセージにいれることをやる
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);
}
});
});
無事にメッセージに入れられた。
続いて、 modal のリファレンスを調べた。
次はこれを読むところから
modal のリファレンスから、まず modal の open -> submit までをまとめた。
-
trigger_id
で open - initial view を表示
- submit すると状態がかわる
で、 1. で使うのは views.open()
サンプルコードもあるので、これを試してみたいのだけど、見慣れぬコンストラクタ引数があった。
signingSecret: "your-signing-secret",
調べてみると、 User Token のことで new App() するときに、ちゃんと使ってた。 よく見よう n 回目。
というわけで、続きはこの views.open()
のサンプルをもとに試してみるところから。 ちょっと気になってるのは、変数 requested_user
をモーダルに引き継げるのかどうか
views.open()
の前にモーダルの開き方がわからなかったので、調べてた(世の中のサンプルコードはだいたい /
コマンドから開く感じだった)。
開くには trigger_id
が必要だったんだけど、 button 要素で trigger_id
を指定すると、 invalid になってしまう。
悩んでいたところ、 Bolt のリファレンスにあった。
trigger_id
はスラッシュコマンド、ボタンの押下、メニューの選択などによって Request URL に送信されたペイロードの項目として入手することができます。
モーダルの開始
ボタンを押すと自動的に trigger_id
が発行されるので、それを payload で受け取ればいいのねと理解。
というわけで、 views.open()
を書いてみた。
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 でこんな感じでデザイン。
これを view.open()
に入れる。
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);
}
});
これで動かしてみると、
いえーい、いい感じにできたぞい! マジで、いよいよ感がある。
というわけで、次回はこの payload を受け取る action を書くところから。
modal からの payload を受け取るには app.view()
を使うのだけど、この引数が何かわからん。。。
一応、 Bolt のリファレンスを見ると、それらしい記述があるのだが ... 。
app.view('view_b', async ({ ack, body, view, client, logger }) => {
// 処理
});
view_b
とは何かわからず、ものは試しで callback_id
かと思ってやってみると、取れた!
// modal の view.open() に callback_id を post_feedback を追加
app.action("open_modal", async ({ body, ack, client, logger}) => {
console.log(body);
});
というわけで、 payload から目的の、
- フィードバックした人
- フィードバックした相手
- フィードバック内容
- Keep なこと
- Problem なこと
を取得する。
で、この payload がややこしい。 一応ドキュメントにサンプルがあったので、それを参考にする。
ただ、いろいろ書いていない( 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 も必須
- element を
-
users_select
の値を取るのが罠で、 value と書くのでは NG でview.state.values.block_id.acktion_id.selected_user
で指定する
というわけで、書いたコードがこちら。
- modal を表示するやつ
// 入力フォームを開く
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.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)
};
});
これで動かしてみた!
いえーい、これでほぼ完了。
あとは、ランダムにフィードバックを受けるユーザを選ぶところの処理だけ。
ユーザリストを保持するときに、 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 つかな。
とりあえず、次回もうちょっと考えてみる。
前回の 2 つの課題について、一応 ↓ のようにすることに決めた。
- ユーザリストを保持するために GAS のスプシにデータストアする
- ただし定期実行には使わない(特定のプログラムだけ実行したい)
- 定期実行には Cron を使う
Cron の実装
まず Cron から実装。まずはライブラリを見てみる。
Cron のライブラリ
GitHub と npm で調べたところ、以下 3 つが代表的だった。
- node-cron/node-cron: A simple cron-like job scheduler for Node.js
- kelektiv/node-cron: Cron for NodeJS.
- node-schedule/node-schedule: A cron-like and not-cron-like job scheduler for Node.
使い方はだいたい同じで、クロージャ内に実行したい処理を書く感じだった。
機能もほとんど同じだったんだけど、 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 の処理を指定チャンネルにメッセージを送信するものに書き換え。
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
が間違っているのかも、と思って、これまた書き換え。
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,
:
:
というわけで、次からは、スプシにユーザリストを POST するところを書く。
ちょこちょこ Slack App からの POST 調べてたんだけど、だいたいが、
Slack API <- GAS
で取得しているのがほとんどで、 Slack -> Slack App (Bolt) -> spread sheet は、ほぼなかった。
一応調べている中であったのがこの記事。
ここでやっていたのが、 google-spreadsheet という Google Sheets API のラップするライブラリで POST とかの処理ではなく、シートをオブジェクトにして、そこで API を操作するというものだった。
★ も 1.8k ある。
若干気になるのが、 2022 年 1 月ごろから開発がストップしていて、 issue も放置状態。まぁ、 MVP の検証まで使えれば OK でしょう。
このライブラリであれば GAS で別にファイルを作ることなく、いまのリポジトリ内に閉じて開発できるので、これを使おう。
というわけで、次は Google Sheet API を設定して、このライブラリを動かすサンプルを試すところから。
google-spreadsheet ライブラリを使った日本語記事もあった。
Google Sheets API の有効化
google-spreadsheet を使っている日本語の記事 をもとに、 Google Sheets API を設定する。
- GCP にログイン
- 新しいプロジェクト slack-user-store を作成
- Google Sheets API を enable
- サービスアカウントの作成
で、このサービスアカウント is 何? と思って調べたら、 VM をあたかも user のように扱い、 Key で Authenticate するものだった。
- 認証キー (JSON) を発行して保存
これで Google Sheets API の設定は完了。
続きは、実際に google-spreadsheet を動かしてみるぞい
まずは 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
に決定。
次回は今度こそこのスプレッドシートを google-spreadsheet で操作するぞい
google-spreadsheet を動かすぞい。
- その前に .bash_profile にこの前作ったスプレッドシートの id (SPREAD_SHEET_ID) を追加
- google-spreadsheet の import とインスンタンス化
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 を使ってたシリーズ記事を見ながら書いた。
久々時間が取れたので、 google-spreadsheet を動かしてみる。
参考にしたのは ↓ の記事。 ちなみに記事の内容は、いろいろググっていると google-spreadsheet の公式ドキュメント and README の情報だった。
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
スプレッドシートのタイトルが無事に出てきた。 うし!
次は、いよいよスプレッドシートへのデータストアを書いてみるぞい。
行を追加するメソッドを調べる
空いた時間にちょいちょい調べながら、ようやく手を動かせる時間が取れたので、データストアをやってみる。
参考にしていた記事でもよかったんだけど、クラスにまとめて CRUD のメソッドを作ってたので、 "試す" には、ちょっと大掛かりな印象。
というわけで、公式ドキュメントでやってみることにした。
見ていると、データストアには addRow()
を使うみたいなんだけど、よくある最終行 lastRow + 1
と投入データを引数にすると思って、無駄にいろいろ調べてしまった。
結局、 addRow()
は add new row なので、そんなことをせずとも一行追加してくれる優秀なやつだった。
addRow()
で追加してみる
サンプルデータを というわけで、クロージャで書いてみた。
(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()
する必要があるのね。 あざっす!
(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);
}
})();
これで実行!
いえーい、データ入ったで!
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()
を叩いてる処理に追加する。
この前作ったクロージャを関数に切り出し。
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() を叩いて保存する処理を書く。
(async () => {
const results = await client.users.list();
results.members.forEach(member => {
member.is_feedback = false;
saveMember(member);
});
})();
入った!! ..... と思ったんだけど、レコードが 2 つしかない。
気になって、 result
で出力している Google Sheet API のログを見てみると、
_rowNumber: 2,
# 中略
_rowNumber: 2,
# 中略
_rowNumber: 3,
# 中略
_rowNumber: 3,
# 中略
と同じ行に出力してた。
もしかすると async 内で foreach しているからかもしれない :scream:
(async () => {
results.members.forEach(member => {
saveMember(member);
});
})();
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 で書き直してみるか
↓ の記事などを参考にして Promise.all()
で書いてみたんだけど、全然上手くいかず。
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'
]
あとは saveMembers()
するときに bot を弾きたいので、その処理を追加(なぜか slackbot は is_bot
のパラメータが false
。 調べてみると id が固定だからそれで弾けと。 そんなんある?)。
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 を選ぶ処理をやる
久々再開。
メンバー登録はできたので、 feedback_reciever をランダムに選ぶ処理を追加するんだけど、詳しくは以下のような流れ。
- team_id でメンバーを select
- is_feedback === false のメンバーを select
-
- のメンバーからランダムで 1 人を選ぶ
team.info() を使う
まずは team_id を知りたいので、調べてみると、以下のメソッドがあった。
というわけで、ふんふん書いてみる。
(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 ということで "権限ないぜ、あんた" ってこと。
そんな強いやつなのか ...
とここまでで時間切れ。 続きは権限を入れるか、代替を考えるか、検討するところから
team:read
の権限追加
OAuth & Permissions で 久々に再開。
前回、 missing_scope で team:read
の権限ないよ、ということだったので、調べるのに手間取ったけど、 Slack App の設定から以下の手順で追加できるので、権限を追加。
- OAuth & Permissions > Scopes
-
team:read
を追加 - reload
で、やってみると無事に team_id
が出力できた。
$ node app.js
T***************
team_id でメンバー情報を select
続いては、取得できた team_id からメンバー情報を select してリストにする処理。
公式ドキュメントを見てみたけど、どうやら filter や fetch, select, find のようなメソッドは無かった。 ぐぬう。
getRows()
で全件取得したあと、 JSON をパースするほか無さそう。
続きはこのパースの処理から
パースの前に、 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()
を使うかなぁ。
今回は filter して map するやり方が良さそう
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 |
見様見真似で書いてみる。
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 がちゃんと出てくるではないか! はやく試せばよかった。
というわけで、最終形はこちら。
const users = await sheet.getRows();
const team_members = users.filter(member => {
return member.team_id === team_id;
});
うんスッキリ。 というわけで続きは map で整形するところから。
map で必要な要素の配列にする
引き続き、↓ のドキュメントを参考にして、 filter した結果を map して必要な
- slack_id,
- team_id,
- name,
- is_feedback
のみを持つ配列にする
見様見真似で書いてみる。
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 のやつを除いて出力でけた。
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,
}));
続きはこの配列からフィードバックレシーバをランダムでピックアップする処理。
いよいよ大詰め!
ランダムでチームメンバからフィードバックを受ける人を選ぶ
team_id と is_feedback で filter したチームメンバからランダムに 1 人候補者を選ぶ処理を書く。
ちなみに、 feedback_reciever としていたんだけど、翻訳にかけると feedback recipient のほうがわかりやすいので、変数はこれにしよ。
で、調べてみると、なるほど、 random()
で要素の番号を決めて、その番号から配列の要素を取り出すのね。
というわけで書いてみた。
const feedback_recipient = team_members[Math.floor(Math.random() * team_members.length)];
出力してみると、いい感じに規則性がなく出てきたので、一旦これで完成!
追加の処理
これでフィードバックを受ける人が出てくるのだけど、全員 TRUE だったらどうするかなど、色々すっ飛ばしているので、追加するものを書く。
- ランダムに選ばれたフィードバックを受ける人の is_feedback の値を TRUE に更新
- is_feedback が全員 TRUE だったときの処理
- is_feedback が 1 人だけ FALSE だったときの処理
ちょっと 1. は後回しにして、先に 2. 3. を考える。
まず 3. の場合は要素が 1 つだけなら random()
しても要素は 1 つしか出ないと思うので、データを 1 人だけ TRUE にしてやってみる。 やっぱり 1 人しか出てこないので、これは考慮しなくて OK 。
で、 2. の場合は、一旦、全員 TRUE のやつを出してランダムで選んだあと、チーム全員の is_feedback を FALSE にしてデータを更新しよう。
というわけで 1. とも関連するのだけど、 spreadsheet のデータの update が必要になるので、これは次回調べてみよう。
調べてみてからになると思うけど、 _rowNumber
が必要になるだろうな。
フィードバックを受けるメンバの is_feedback を更新する
spreadsheet の値の update 方法を調べると、 rows[] の添字で save()
するみたいだった ... うーむ。
予想通りなんだけど、全員のステータス更新するのとか、メンドそうだ ...
// make updates
rows[1].email = 'sergey@abc.xyz';
await rows[1].save(); // save changes
とりあえず、 練習兼ねて以下をやってみる。
- ランダムに選ばれたフィードバックを受ける人の 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 していい感じにデキるといいんだけどなぁ。
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 だったとのきの処理を考えてみる。
- is_feedbacked を全員 TRUE のままフィードバックを受ける人を選ぶ
- フィードバックを受ける人の is_feedbacked を更新
- その他の人の is_feedbacked を更新
ちょっとこれは is_feedbacked の更新処理が入るのでメンドイところ。
理想は is_feedbacked を全員更新して、分岐に入る前に戻ること。
なので、フィードバックを選ぶ処理を関数化して、
- is_feedbacked を全員更新してフィードバックをされる候補者リストにする
- フィードバックされる人を選ぶ関数にフィードバックをされる候補者リストを入れる
こうすると、重複なしでいけそう。
続きはこの関数に切り出すところから
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) {
// 処理
};
ちょこちょこと進めていたのをまとめる
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 を一つ一つ取り出して、
- is_feedbacked を FALSE
- スプシの該当ユーザの値も更新
この 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 に切り出した。
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 はこんな感じ。
const GoogleSpreadsheetService = require('./google-spreadsheet-service');
const sheet = new GoogleSpreadsheetService();
await sheet.init();
const allUsers = await sheet.getRows();
filter の処理も切り出そうか悩んでいるのが、イマココ
もう開発の終わりが見えているので、 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. のときに全員がフィードバックを受けていれば、また新しくランダムで選ぶ処理もテスト。
無事に全部のシナリオを実行できた! ちょうど一年ぐらいで開発できた感じだ。ふい~
このあとは、実際にこのアプリを公開するために何が必要なのか調査する。
公開のための調査
公開するために何が必要か調べていたんだけども、ちょっと出だしからつまづく。
- Authorization はわかった。 でも、どうすんねん
- デプロイ先 ...
Authorization
Slack のドキュメントを探していると、公開に必要そうなものがあったので、それを眺めて、試してみるか、とおもったら、どうしたらその Authorization 情報は取れるのかがさっぱりわからない。
複数のワークスペースにインストールされる、複数のユーザートークンを使用するといったケースのように、アプリが複数のトークンを処理しなければならない場合があります。このようなケースでは token の代わりに
authorize
オプションを使用する必要があります。
いや、まさしくそうなんだけど、 authorize
オプションというのがサンプルコードを見ると、自作するってのはわかるものの、そもそもその botToken とかどうやってチームから取得するのかがわからん。
延々と詰まってる ... 。
デプロイ
ローカルマシンで動かし続けるわけにはいかないので、何らかのクラウドを選ぶ必要がある。
Slack Bolt のドキュメントはそこまでカバーしていて(ありがたや)、
この 2 つが紹介されている。
これに乗っかるのが一番学習コスト少ないのだけど、お金がなぁ。 これも詰まり気味。
というわけで、続きは引き続き Authorization 問題の解決から。
調べていると、 OAuth フローがそれに該当するものだった。
ただ、これは Bolt を使わずに API 経由で実装する方法が書かれていて、他になにかドキュメントがないか調べたところ、神記事発見!
ちょうど知りたいことと、しかも前提(一つのワークスペースにインストールしているアプリが開発済み)が合っているので、まさしく俺得なやつ。
で、この記事からリンクがあり、 Bolt の公式ドキュメントにもあることを発見。
Qiita の記事と公式ドキュメントを次に読む。
Bolt 公式の Oauth フローの実装をまず確認。 公開するには、
- 公開用 App の作成と設定
- 公開用 App とインストールするチームとで Oauth フローを行う
- 公開用 App の URL が無いので、
ngrok
のようなライブラリを使って localhost のパスを公開用の URL にフォワードする - ローカル開発(単一チームへのインストール) -> テスト公開 -> App 公開 の 3 段階があって、今からやるのは 2 段階目
- 3 段階目では socke mode が使えない
ということがわかった。 また途中 Oauth フロー実装例のリポジトリを Slack が公開していて、これが参考になりそう。
というわけで、次回は公開 App の作成と設定から
公開用の設定 App 名を OJT360 にする
App を公開するにあたって、今まで作ってきた App が練習用丸出しの名前や description だったので、以下に変更。 アイコンは ... 作る前に考えよう。
App の名前は OJT360 に決定。 心理的安全性にフォーカスしようと思ったが、トレーニングツールだもんな、と思ったのでちょっと変更。
心理的安全性はキャッチコピーにするのもいいし。
OAuth フローの実装
Bolt の公式ドキュメントを眺めても、どのように進めるのか、ちょっとわかりにくかったので、 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
SLACK_SIGNING_SECRET は前にも発行されてたんだけど、値が変わっているので、前のものを一旦コメントアウトして更新。
というわけで続きは、 ngrok の設定をやるぞい。
久々再開。 ngrok をインストールする。
- ngrok - download から Linux 版をダウンロード
- ngrok-v3-stable-linux-amd64.tgz が落ちてくるので、 tar で解凍して /usr/bin/ に mv
-
ngrok --version
を実行してレスポンスがngrok version 3.1.1
と出たので完了
続いて、 ngrok に登録した際、動かすまでのステップがメールで説明されていたので、続いてやってみる。
-
ngrok config add-authtoken myAuthToken
を実行 - レスポンスとして
Authtoken saved to configuration file: /home/user/.config/ngrok/ngrok.yml
が返ってきたので設定ファイルの作成が完了 - トンネルをスタート
ngrok http 80
- ↑が表示され動いたのを確認(サーバを動かしてないので URL には何も表示されない)
続きは Slack の OAuth 実装例の続きをやる。
ngrok で OAuth を開始! ... するも失敗
ngrok の設定が終わったので、いよいよ OAuth やるぞ。
Slack の OAuth フローの実装例に従ってやってみる。
-
node app.js
でアプリを起動 -
ngrok http 3000
で URL をローカルのポートをフォワーディング - Slack app の設定画面の OAuth & Permissions を開き、 ngrok のレスポンスにある Fowarding で表示された URL を入力して save
これで準備が整ったので、 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 回目)。
続きはアプリの有効化から
アプリの有効化
この前の Qiita の記事を参考にして、App の管理画面から他のワークスペースから使える設定を開く。
ハードコーディングしてないことを確認して、 activate publick distribution で有効化。
特にレスポンスがない ... 。 enable したのでしょう。
続いて、 Qiita の記事では環境変数を編集しているんだけど、これは完了済。
app の初期化を変更
App の環境変数を使っているのは、 const app = new App()
と const client = new WebClient()
の 2 つ。 このうち WebClient は token が必要なので App の初期化後と考えて、一旦以降をコメントアウト。
で、 App の初期化。
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 もあるみたい。とりあえずコケたら、調べてみよう。
で、この記事を見ると installation の情報をファイルで保存しているので、次回これを真似てやってみる。
const { App, FileInstallationStore } = require("@slack/bolt");
const app = new App({
// 中略
// Slack ワークスペースへのインストール情報を管理する実装、ここではローカルファイルに保存します
installationStore: new FileInstallationStore({
baseDir: './data/installations',
clientId: process.env.SLACK_CLIENT_ID,
}),
}
こちらを参考にしながら、 installation 情報をファイルに保存できるようにする。
- /data/installations ディレクトリを作成
- FileInstallationStore を slack/bolt から import
- installationStore プロパティを追加
const { App, FileInstallationStore } = require('@slack/bolt');
const app = new App({
installationStore: new FileInstallationStore({
baseDir: './data/instrallations',
clientId: process.env.SLACK_CLIENT_ID,
}),
で、ログも取れるようなのでこのプロパティも追加。
- LogLevel を import
- logLevel プロパティを追加
const { LogLevel } = require("@slack/logger");
const app = new App({
logLevel: process.env.SLACK_LOG_LEVEL || LogLevel.DEBUG,
ただ、この SLACK_LOG_LEVEL
は設定していないので、どこで取れるのか確認しようとしたところで時間切れ。
続きはこの環境変数の調査から
久々に再開。 一家インフルはやばかった。
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
- app.js を動かし
ngrok http 3000
でローカルホストを繋ぐ - フォワードされた URL を Slack App > OAuth & Permissions の Redirect URL に貼る
- https の URL の末尾に
/slack/oauth_redirect
を入れる - save する
-
http://localhost:3000/slack/install
を開く
いえい、 Add to Slack ボタンが出た!
ボタンを押すと、見慣れた install 画面が表示された!
もちろんこのあと Allow ボタンを押しても、特に何も処理を書いてないのでエラー終了。
というわけで、続きは installation の情報をファイルストアする処理を書く。
Allow ボタンを押してエラーが出たと思っていたけど、 ngrok が出している画面はエラーじゃなくて警告で、 "信頼できるサイトなら visit してもいいよ" というものだった(よく読もう n 回目)。
そこで visit site ボタンを押すと、無事に成功!
/data/installations/ に専用のディレクトリも自動で作成され、
$ tree data
data
└── instrallations
└── **************************
└── ***********
├── app-*********************
├── app-latest
├── user-*************************
└── user-**********************
3 directories, 4 files
というファイルができて、チーム情報や token が取得できてた。 いえーい
開いてしまったけど、ちょこちょこ以下の検証を進めていた。
- 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 の指定が間違っていないか、確認する。
ざっと見ると、以下の issue でのやり取りが参考になりそう。
どうやら scopes の指定というより、 token が無いことがエラーの原因の様子。
the error code "not_authed" indicates that your app's web API call did not have any token.
やっぱり token を使って、 WebClient をインスタンス化するのか。
ようやく明日から開発を再開できそうなので、今後の方針を考える。
とりあえずこれまでやっていた OAuth フローの検証は installations が取得できているので、 WebClient の init は後回し。
それよりは、実際にサーバにホストして installations を DB に保存することを考えよう。 とりあえず候補を以下の記事からあたってみる。
- 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 で十分 ... 。
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 アプリを動かす方法もドキュメントにあったので、次回、これを試してみよう。
公式にあった Node App を動かし方を試してみる。
git clone https://github.com/fly-apps/hellonode-builtin
npm install express --save
-
cd hellonode-builtin/
// clone したディレクトリに移動 node server.js
無事にローカルで動いた。 HelloNode app listening on port 3000!
画面キャプチャ忘れた。
続いて、 fylctl の install
$ curl -L https://fly.io/install.sh | sh
- 環境変数を追加
export FLYCTL_INSTALL="/home/hoppers/.fly"
export PATH="$FLYCTL_INSTALL/bin:$PATH"
- アカウントを作成
flyctl auth login
- ブラウザで作成
- 完了すると
flyctl auth login in ****@hoge.email
というわけで、いよいよ launch
flyctl launch
- app name を聞かれるので、 blank で適当に作ってもらう( Unique でないとダメ)
- region を聞かれるので、 Tokyo, Japan を選択
- 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 があったので、見てみると、おー動いてる動いてる。
一旦、これで作った app を削除して完了。
なるほどな~、 ctl 作る意味はこんなところにあるのね。 次はローカルの slack app をデプロイしてみる
slack app をデプロイしてもデータベースを作っていないので、ローカルで用意する。
あとは chatGPT をどんどん使ってみることにした。 not API なので GPT3.5 だけども。
ORM を調査 -> Prisma を使う
Node.js で使える ORM を chatGPT に聞いてみると、
- Sequelize
- Prisma
の 2 つが挙げられた。 どっちがいいか調べてみた。
↓ は Prisma のドキュメントなので、公平性は無いかも知れないけど、クエリの書き方は Prisma のほうが書きやすそうなので、 Prisma を使う。
PostgreSQL をインスコ
何にせよ、まずは PostgreSQL をインスコ。 sudo apt install postgresql
で開始したら、 WSL 1 のネットワーク激遅い問題 99 b/s にひっかかり、 20 分以上かかる計算に。
今日はここまで
とりあえず 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
↑ のドキュメントに、
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 の初期設定
- まずは postgre を再起動
$ sudo /etc/init.d/postgresql restart
* Restarting PostgreSQL 12 database server [ OK ]
- 初期ユーザでログイン
$ sudo -u postgres -i
postgres:~$
- ユーザを作成 -> エラー。 うーむ
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 のユーザを一緒にせいとのこと。 うーむ、マジか。 というわけで続きはここから
ちゃんと PostgreSQL のドキュメントを読めば、分かる話だった ...
createuser -d # -d で database 作成
-U user # user はつくる人。 ここでは postgres の初期ユーザ
-P newuser # newuser は新しくつくる人。 -P でパスワードの設定に進む
次回、ちゃんとユーザを作ろう
ちゃんとドキュメントを読んで、 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 を調べるぞい
Prisma に渡す PostgreSQL のコネクション URI だけど、これは何か postgre にある訳ではなく、 postgres への接続を URI にすると、こうなるという話だった。
user とか pwd を晒す感じになるんだけど、ええんかこれ
まぁ、 localhost なので、一応これで進めよう
DATABASEURL の書き方がわかったので、 prisma のセットアップを再開
Prisma のセットアップ
手順は公式ドキュメントをもとに進める
Add Prisma to an existing project that uses a relational database (15 min) | typescript-postgres
- 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
# 中略
- 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
ができた。
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 をまだ動かしてなかったっす。
というところで今日はここで時間切れ。
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 テーブルを考えところから
team テーブルに必要なカラムを調べているのだが、予想外にドキュメントが無い ...
一応、以下の通り、順番に調べてみた。
-
OAuth フローの実装
- clientId, clientSecret, stateSecret, scopes (必須)
- installationStore オプションは、インストール情報の保存と取得を行うハンドラーを提供します
-
Installation オブジェクト
-
installation
のサンプル = 手元のファイルと同じ構造
-
- さらに詳しいやつは Installing with OAuth
で、 1. で紹介されていたサンプルコードで db に保存しているのは以下だった
-
enterprise_id
// データがあれば team_id
でも 3. のドキュメントでは token を使うとあるんだが、でもその token は bolt を使っていると、自動でやってくれるとある。 うーむ。
とりあえず Installing with OAuth をちゃんと読むところからがつづき。
Installing with OAuth をちゃんと読んだけど、すべて知ってたことだった。 ただ、そのドキュメントに token はちゃんと db にセキュアに保存しようね、とあったので、やるんだろうな、という感。
この token はどのテーブルに持つべきなのかなぁ、と検索していると、いい感じの動画があった
Build your first Slack Bolt App Pt 3: OAuth & Storing User Credentials (w/ MongoDB) - YouTube
サンプルコードもあったので、次回、これを見よう
昨日見つけた動画を見る。 これがかなり今の OAuth の datastore で悩んでいるところにクリーンヒットしてた。 ただ、英語なので字幕(自動)がないと死ぬところだった。
Build your first Slack Bolt App Pt 3: OAuth & Storing User Credentials (w/ MongoDB) - YouTube
ここではそのメモを残しておく。
OAuth のフロー
フローはこんな感じだった
- add to slack ボタンを押す
- リクエストしている scope がチェックされ、 admin 権限が必要かチェック
- User が org に入れようとしているか、 workspace 単体に入れようとしているかチェック
- App が User の token を持っているかどうかチェック
- 持ってなければ 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 秒 から
動画の続きをみると、 org インストール / workplace インストール でファイルを分けて store するとのこと。
で、締めに、実際に add to slack ボタンで installation オブジェクトを取得して、 mongo に入るか確認して無事に入ったよ、で動画はおしまい
OAuth のデータストアのサンプルコードを確認
というわけで、動画で言っていたところを GitHub に上がってるサンプルコードで確認する。
- スキーマの定義
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 インストールのときのデータストア
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 インストールのときのデータストア
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 インストールで何が違うのか、わかってないかもなので、改めて確認する。
公式ドキュメントを見ていると 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 をフィールドにしてデータストアすればよいのか。うし。
公式ドキュメントをまとめると、以下のような処理をする。
- org インストールのときは authorize などをコンストラクタ引数にして App をインスタンス化する
- authorize には authoizeFn() のような関数を用意して戻り値をコンストラクタ引数にする
- 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 するぞい
prisma.schema にスキーマを書く
サンプルコードで mongo の schema 定義をそのまま突っ込めばよいとわかったので、 prisma.shema にスキーマを定義する。 参考にしたものは公式ドキュメント。
- 用意するテーブル
- 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 をやってみる
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 からテーブル情報を持ってくるとかか。
ちょっとあやふやすぎるなこの辺。 次回ちゃんと調べる
Prisma の CLI リファレンスがあったので、 db pull
を確認した。
やはり 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 つの違いを調べて、実行するゾイ
db push
と migrate dev
の違いをリファレンスで確認
ここに migrate との併用ができるかという topic があったので、そのリンクをたどる。
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 を起動しようとしたところ、メニューにない。 なんでなんで、と探していて起動できたところで、今日は時間終了。
もうちょいやりたかったなぁ
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 を指定されてないな。
続きは、この辺、やり直してみるか。
time zone を prisma.shema ファイルで指定するやり方を調べてみた。
結果、以下の記事によると prisma に time zone を指定するオプションがないとのこと。 うーむ。 公式のリファレンス見ても無かったので、そういうことなんでしょう。
ちょっと忘れそうだけど、特に UTC を使う分に問題はないのでタイムゾーンは指定しないでこのまま使う。
で、一応他のテーブルも describe したんだけど、問題なかった。 ただ relation が取れているかどうかがわからんので、これを次回調査。
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 を保存する処理を書くぞい
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');
は ↓
中身を見てみると、通常 Add to Slack ボタンで OAuth を進めるところ、カスタムページを用意して、そこでリダイレクト URL を発行するものだった。
続いて、 registerListeners(app);
はこちら。
これも中身を見ると、
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 がなぜあるか問題は次回しらべる。
fetch があるのは当然で、 installation 取得によって得られてた token などを使って、 app をインスタンス化するからだね。 次は app 側の installation の store と fetch を書こう
サンプルコード通りの処理以外はしないので、ここは素直に 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');
},
これまたサンプルで挙がっているリポジトリの通り、 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 しか使えないの ...
TS 書いてみるのもよいかと思ったけど、そもそもサンプルコードをベースにするなら Mongo でいいんじゃね、という気持ちに。 PaaS として MongoDB Atlas を使えば Free プランもある。
で、軽くググると Fly.io に MongoDB Atlas を接続してデプロイするという記事もあったので、これでもいいのでは説。
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 だけだった ... 。 うーむ、悩みどころ。
Mongo Atlas と Fly.io で接続できるのか、この前ググった記事ぐらいしかなかった。 もちろん Fly.io の公式ドキュメントにはない。
ということは、この記事の手順をなぞるしか検証方法がないんだけども ... 。
で、並行して Prisma の CRUD も確認してみた。
const user = await prisma.user.create({
data: {
email: 'elsa@prisma.io',
name: 'Elsa Prisma',
},
})
これなら TS 関係なくね、となったので、 Prisma を引き続き、使おう。
というわけで、今度こそ Prisma で Installation のストア処理を書くぞい.
Prisma で Installation をストアする
まずは公式に載っているので、 prisma client をインストールするところから。
$ 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 に書く。
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
この import 時点でエラー発生。
軽くエラーメッセージで調べてみると、 npx prisma migrate
や npx prisma db push
を行ってないからとのこと。 一応やり直してみたけど解消されず ... 。
あ、そもそも ts って、まだこのリポジトリに入ってたんだっけ、という疑問。 消してしまってたかも
といらえず import のエラーメッセージ Did you mean to set the 'moduleResolution' option to 'node', or to add aliases to the 'paths' option?
で調べると、 ↓ の記事がヒット。
この通り、 tsconfig.json にある "moduleResolution": "node",
を enable してみる。
"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 エラーが消えました ♪
というわけで、続きは db.ts で接続処理を引き続きやる
prisma の公式 に則って、引き続き、接続処理を書く。
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 できませんねん。
この記事の通り、とりあえず --esm
オプションをつけて実行してみる。
$ npx ts-node --esm ./prisma/db.ts
[]
おけ、成功! 無事に接続できた。
というわけで、オプション無しでできるように記事の通り、 tsconfig に以下を追加。
"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 なので、次回ちゃんと調べよう。
そもそも 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 接続用に変える。
調べていると、以下の記事が参考になりそうなんだけど、ここでタイムアップ