メッセージ・イベントのリスニング

アプリが受信権限を持っているメッセージをリスニングするには、message型でないイベントを除外するmessage() メソッドを使用します。

message() は、パターンと一致しないメッセージを除外する、 string 型の pattern パラメーター (オプション) または RegExp オブジェクトを受け入れます。

// 特定の文字列、この場合 👋絵文字を含むメッセージと一致
app.message(':wave:', async ({ message, say}) => {
  say(`Hello, <@${message.user}>`);
});

正規表現(RegExp) パターンの使用

文字列の代わりに 正規表現(RegExp) パターンを使用すると、照合の精度を高めることができます。

RegExp の一致結果はすべて context.matches に格納されます。

app.message(/^(hi|hello|hey).*/, async ({ context, say }) => {
  // context.matches の内容が特定の正規表現と一致
  const greeting = context.matches[0];
  
  say(`${greeting}, how are you?`);
});

メッセージの送信

リスナー関数内では、関連付けられている会話 (たとえば、リスナーをトリガーしたイベントやアクションが発生した会話) が存在するときにはいつでも say() を使用できます。 say() はパラメーターに文字列を入れて単純なテキストベースのメッセージを送信するか、もしくは、JSON を使ってより複雑なメッセージを送信します。渡されたメッセージペイロードは、関連付けられている会話に送信されます。

リスナーの外部にメッセージを送信する場合、またはより高度な操作 (特定のエラーの処理など) を実行する場合は、Bolt インスタンスにアタッチされたクライアントを使用して chat.postMessage を呼び出します。

// "knock knock" を含むメッセージをリスニングし、 "who's there?" というメッセージをイタリック体で送信
app.message('knock knock', ({ message, say }) => {
  say(`_Who's there?_`);
});

ブロックを使用したメッセージの送信

say() はより複雑なメッセージペイロードを受け入れて、メッセージに機能と構造を容易に追加できるようにします。

リッチなメッセージレイアウトをアプリに追加する方法については、API サイトのガイドを参照し、Block Kit ビルダーの一般的なアプリフローのテンプレートを確認してください。

// 誰かが 📅 絵文字でリアクションした時に、日付ピッカー block を送信
app.event('reaction_added', ({ event, say }) => {
  if (event.reaction === 'calendar') {
    say({
      blocks: [{
          "type": "section",
          "text": {
            "type": "mrkdwn",
            "text": "Pick a date for me to remind you"
          },
          "accessory": {
            "type": "datepicker",
            "action_id": "datepicker_remind",
            "initial_date": "2019-04-28",
            "placeholder": {
              "type": "plain_text",
              "text": "Select a date"
             }
          }
        }]});
  }
});

イベントのリスニング

Events API イベントをリスニングするには、 event() メソッドをアプリ設定でサブスクライブしてから使用します。これにより、Slack で何かが発生した (ユーザーがメッセージにリアクションした、チャンネルに参加したなど) ときに、アプリでアクションを実行できます。

event() メソッドは、文字列型の eventType を必要とします。

const welcomeChannelId = 'C12345';

// ユーザーが新規でチームに加入した際に、指定のチャンネルにメッセージを送信して自己紹介を促す
app.event('team_join', async ({ event, context }) => {
  try {
    const result = await app.client.chat.postMessage({
      token: context.botToken,
      channel: welcomeChannelId,
      text: `Welcome to the team, <@${event.user.id}>! 🎉 You can introduce yourself in this channel.`
    });
    console.log(result);
  }
  catch (error) {
    console.error(error);
  }
});

メッセージのサブタイプのフィルタリング

message() リスナーは event('message') に相当します。

イベントのサブタイプをフィルタリングするには、組み込みの matchEventSubtype() ミドルウェアを使用します。 bot_messagemessage_replied のような一般的なメッセージサブタイプは、メッセージイベントページにあります。

// bot からのメッセージ全てと一致
app.message(subtype('bot_message'), ({ message }) => {
  console.log(`The bot user ${message.user} said ${message.text}`);
});

Web API の使用

Web API メソッドを呼び出すには、Bolt アプリに提供されている WebClientapp.client として使用します (この場合、適切なアプリのスコープを設定が必要です)。クライアントのメソッドの 1 つを呼び出すと、Slack からの応答を含む Promise が返されます。

Bolt の初期化に使用されるトークンは context オブジェクト内にあり、ほとんどの Web API メソッドで必須です。

// September 30, 2019 11:59:59 PM を Unix エポックタイムで表示
const whenSeptemberEnds = 1569887999;

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

アクションのリスニング

アプリでは、ボタンのクリック、メニューの選択、メッセージアクションなどのユーザーアクションを、 action メソッドを使用してリスニングすることができます。

アクションは文字列型の action_id または RegExp オブジェクトでフィルタリングできます。 action_id は、Slack プラットフォーム上のインタラクティブコンポーネントの一意の識別子として機能します。

すべての action() の例で ack() が使用されていることに注目してください。Slack からイベントを受信したことを確認するために、アクションリスナー内で ack() 関数を呼び出す必要があります。これについては、「イベントの確認」 セクションで説明しています。

// action_id が "approve_button" のインタラクティブコンポーネントがトリガーされる毎にミドルウェアが呼び出される
app.action('approve_button', async ({ ack, say }) => {
  ack();
  // アクションを反映してメッセージをアップデート
});

制約付きオブジェクトを使用したアクションのリスニング

制約付きのオブジェクトを使って、 callback_idblock_id 、および action_id (またはそれらの組み合わせ) をリスニングすることができます。オブジェクト内の制約には、文字列型または RegExp オブジェクトを使用できます。

// action_id が 'select_user' と一致し、block_id が 'assign_ticket' と一致する場合のみミドルウェアが呼び出される
app.action({ action_id: 'select_user', block_id: 'assign_ticket' },
  async ({ action, ack, context }) => {
    ack();
    try {
      const result = await app.client.reactions.add({
        token: context.botToken,
        name: 'white_check_mark',
        timestamp: action.ts,
        channel: action.channel.id
      });
    }
    catch (error) {
      console.error(error);
    }
});

アクションへの応答

アクションに応答するには、主に 2 つの方法があります。1 つ目の (最も一般的な) 方法では、 say 関数を使用します。 say 関数は、着信イベントが発生した会話にメッセージを返します。

アクションに応答する 2 つ目の方法では、 respond() を使用します。これは、アクションに関連付けられている response_url を使用するためのシンプルななユーティリティです。

// action_id が "approve_button" のインタラクティブコンポーネントがトリガーされる毎にミドルウェアが呼び出される
app.action('approve_button', ({ ack, say }) => {
  // アクションリクエストの確認
  ack();
  say('Request approved 👍');
});

respond() の使用

respond()response_url を呼び出すためのユーティリティであるため、同じ方法で動作します。新しいメッセージペイロードを使用して JSON オブジェクトを渡すことができます。このオブジェクトは、 response_type (値は in_channel または ephemeral )、 replace_originaldelete_original のようなオプションのプロパティを使用して元の会話のソースにパブリッシュされます。

// “user_select” の action_id がトリガーされたアクションをリスニング
app.action('user_choice', ({ action, ack, respond }) => {
	ack();
	respond(`You selected <@${action.selected_user}>`);
});

イベントの確認

アクション、コマンド、およびオプションイベントは、常に ack() 関数を使用して確認する必要があります。これにより、Slack がイベントの受信を認識することができ、それに応じて Slack ユーザーインターフェイスが更新されます。イベントのタイプによっては、確認通知が異なることがあります。たとえば、ダイアログの送信を確認するときに、送信にエラーが含まれている場合は検証エラーを出して ack() を呼び出し、送信が有効な場合はパラメータなしで ack() を呼び出します。

この ack() による応答は 3 秒以内にしなければならないので、新しいメッセージを送信したり、データベースから情報を取得したりする直前に ack() を呼び出すことをお勧めします。

// Regex でメールアドレスが有効かチェック
let isEmail = /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/
// 制約付きのオブジェクト を使用して ticket_submit という callback_id を持つダイアログ送信をリスニング
app.action({ callback_id: 'ticket_submit' }, ({ action, ack }) => {
	// メールアドレスが有効。ダイアログを受信
  if (isEmail.test(action.submission.email)) {
	  ack();
  } else {
		// メールアドレスが無効。エラーを確認
	  ack({
		  errors: [{
			  "name": "email_address",
			  "error": "Sorry, this isn’t a valid email"
      }]
    });
  }
});

コマンドのリスニングと応答

着信するスラッシュコマンドイベントをリスニングするには、アプリで command() メソッドを使用します。メソッドは文字列型の commandName を必要とします。

アプリがイベントを受け取ったことを Slack に通知するには、コマンドを ack() で確認する必要があります。

スラッシュコマンドに応答する方法は 2 つあります。1 つ目の方法では、文字列または JSON ペイロードを受け入れる say() を使用します。2 つ目の方法では、 response_url のユーティリティである response() を使用します。これらについては、「アクションへの応答」セクションで詳しく説明しています。

// この echo コマンドは 単純にコマンドをエコー(こだま)
app.command('/echo', async ({ command, ack, say }) => {
  // コマンドリクエストを確認
  ack();
  
  say(`${command.text}`);
});

モーダルビューのオープン

モーダルビューは、ユーザー情報を収集したり、情報の動的な表示を実現するためのインタフェースです。モーダルビューは、適切な trigger_idビュー部分のペイロード を組み込みの API クライアントによる views.open メソッドの呼び出しに渡すことでオープンすることができます。

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

モーダルビューの作成についてのより詳細な情報は API ドキュメントを参照してください。

// コマンド起動をリッスン
app.command('/ticket', ({ ack, payload, context }) => {
  // コマンドのリクエストを確認
  ack();

  try {
    const result = app.client.views.open({
      token: context.botToken,
      // 適切な trigger_id を受け取ってから 3 秒以内に渡す
      trigger_id: payload.trigger_id,
      // view の値をペイロードに含む
      view: {
        type: 'modal',
        // callback_id が view を特定するための識別子
        callback_id: 'view_1',
        title: {
          type: 'plain_text',
          text: 'Modal title'
        },
        blocks: [
          {
            type: 'section',
            text: {
              type: 'mrkdwn',
              text: 'Welcome to a modal with _blocks_'
            },
            accessory: {
              type: 'button',
              text: {
                type: 'plain_text',
                text: 'Click me!'
              },
              action_id: 'button_abc'
            }
          },
          {
            type: 'input',
            block_id: 'input_c',
            label: {
              type: 'plain_text',
              text: 'What are your hopes and dreams?'
            },
            element: {
              type: 'plain_text_input',
              action_id: 'dreamy_input',
              multiline: true
            }
          }
        ],
        submit: {
          type: 'plain_text',
          text: 'Submit'
        }
      }
    });
    console.log(result);
  }
  catch (error) {
    console.error(error);
  }
});

モーダルビューの更新と多重表示

モーダルビューの表示は、複数のビューをスタックとして保持する場合があります。views.open という API を呼び出すと、まずルートのモーダルビューが生成されます。その最初の呼び出しの後で、views.update によって動的にそのビューを書き換えたり、views.push で新しいビューをその上に積み重ねて表示したりといったことができます。

views.update
モーダルビューを更新するには組み込みの API クライアントを使って views.update を呼び出します。この API 呼び出しにはそのビューをオープンしたときに生成された view_id と、書き換えられた blocks の配列を渡します。ユーザーが既存のビューの中にある要素とインタラクションを行なったことをきっかけにビューを更新する場合は、そのリクエストの bodyview_id が含まれています。

views.push
ビューのスタックに新しいビューを積み重ねるには、組み込みの API クライアントを使って views.push を呼び出します。この API 呼び出しには、適切な trigger_id と新しく生成する ビュー部分のペイロードを渡します。views.push の引数は ビューをオープンするときと同様です。最初のモーダルビューをオープンした後、その上にさらに二つまで追加のビューをスタックに積み重ねることができます。

より詳細な情報は API ドキュメントを参照してください。

// action_id: button_abc のボタンを押すイベントをリッスン
// (そのボタンはモーダルビューの中にあるという想定)
app.action('button_abc', ({ ack, body, context }) => {
  // ボタンを押したイベントを確認
  ack();

  try {
    const result = app.client.views.update({
      type: 'modal',
      token: context.botToken,
      // リクエストに含まれる view_id を渡す
      view_id: body.view.id,
      // 更新された view の値をペイロードに含む
      view: {
        // callback_id が view を特定するための識別子
        callback_id: 'view_1',
        title: {
          type: 'plain_text',
          text: 'Updated modal'
        },
        blocks: [
          {
            type: 'section',
            text: {
              type: 'plain_text',
              text: 'You updated the modal!'
            }
          },
          {
            type: 'image',
            image_url: 'https://media.giphy.com/media/SVZGEcYt7brkFUyU90/giphy.gif',
            alt_text: 'Yay! The modal was updated'
          }
        ]
      }
    });
    console.log(result);
  }
  catch (error) {
    console.error(error);
  }
});

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

モーダルビューのペイロードが入力のブロックを含む場合、その入力値を受け取るために view_submission のイベントをリスニングする必要があります。view_submission イベントをリスニングするためには、組み込みの view() メソッドを使用します。

view() メソッドは、文字列型または RegeExp型 の callback_id を必要とします。

input ブロックの値は state オブジェクトを参照することで取得できます。state 内には values というオブジェクトがあり、これは block_id と一意な action_id に紐づける形で入力値を保持しています。

より詳細な情報は API ドキュメントを参照してください。

// モーダルビューでのデータ送信イベントを処理します
app.view('view_b', async ({ ack, body, view, context }) => {
  // モーダルビューでのデータ送信イベントを確認
  ack();

  // 入力値を使ってやりたいことをここで実装 - ここでは DB に保存して送信内容の確認を送っている

  // block_id: block_1 という input ブロック内で action_id: input_a の場合の入力
  const val = view['state']['values']['block_1']['input_a'];
  const user = body['user']['id'];

  // ユーザーに対して送信するメッセージ
  let msg = '';
  // DB に保存
  const results = await db.set(user.input, val);

  if (results) {
    // DB への保存が成功
    msg = 'Your submission was successful';
  } else {
    msg = 'There was an error with your submission';
  }

  // ユーザーにメッセージを送信
  try {
    app.client.chat.postMessage({
      token: context.botToken,
      channel: user,
      text: msg
    });
  }
  catch (error) {
    console.error(error);
  }

});

オプションのリスニングと応答

option() メソッドは、Slack からの着信オプションリクエストペイロードをリッスンします。 actions() と同様に、 action_id オブジェクトまたは制約付きオブジェクトが必要です。

external_select メニューには action_id を使用することをお勧めしますが、ダイアログはまだ Block Kit をサポートしていないため、制約オブジェクトを用いて callback_id でフィルタリングする必要があります。

オプションリクエストに応答するためには、適切なオプションを指定して ack() を実行する必要があります。external_select の応答の例ダイアログ応答の例も API サイトで参照できます。

// external_select オプションリクエストに応答する例
app.options('external_action', async ({ options, ack }) => {
  // チームまたはチャンネル情報を取得
  const results = await db.get(options.team.id);
  
  if (results) {
    let options = [];
    // ack 応答 するために options 配列に情報をプッシュ
    for (const result in results) {
      options.push({
        "text": {
          "type": "plain_text",
          "text": result.label
        },
        "value": result.value
      });
    }

    ack({
      "options": options
    });
  } else {
    ack();
  }
});

エラーの処理

リスナーの中でエラーが発生した場合は、リスナー内で直接ハンドリングすることが望ましいでしょう。しかし、リスナーがすでに return した後でエラーが発生する場合もあります。 (say() または respond() を呼び出した場合や、必要なときに ack() を呼び出さなかった場合など)。このようなエラーはデフォルトではコンソールにログ出力されます。ユーザー自身がこうしたエラーを処理するには、error(fn) メソッドを使用してグローバルエラーハンドラーをアプリにアタッチします。

エラーをよりスマートに管理するには、client キーの配下で (say()respond() の代わりに) アプリにアタッチされている chat.postMessage メソッドを使用することをお勧めします。これにより Promise が返されるため、そこでエラーをキャッチして処理することができます。

app.error((error) => {
	// メッセージ再送信もしくはアプリを停止するかの決定をくだすために、エラーの詳細をチェック 
	console.error(error);
});

承認

承認は、特定の着信イベントの処理中にどの Slack 認証情報 (ボットトークンなど) を使用可能にするかを決定するプロセスです。

1 つのワークスペースにインストールされたカスタムアプリでは、App 初期化時に token オプションを使用できます。ただし、複数のワークスペースにインストールされる場合や、複数のユーザートークンにアクセスする必要がある場合など、アプリが複数のトークンを処理しなければならない場合には、代わりに authorize オプションを使用する必要があります。

authorize オプションは、イベントソースを入力値として受け取る関数に設定でき、許可された認証情報を含むオブジェクトに Promise を返す必要があります。ソースには、 teamId (常に利用可能)、 userIdconversationIdenterpriseId のようなプロパティを使用して、イベントの送信者や送信元に関する情報が含まれています。

許可された認証情報には、botTokenuserTokenbotId (アプリがボット自体からのメッセージを無視するために必要)、 botUserId などの固有のプロパティもいくつか含まれています。その他、 context オブジェクトを使用すれば他のプロパティの指定もできるようになります。

botToken プロパティと userToken プロパティは、一方または両方を必ず指定する必要があります。say() のようなヘルパーを動作させるには、どちらか一方は指定しなければなりません。両方指定した場合は、botToken が優先されます。

const app = new App({ authorize: authorizeFn, signingSecret: process.env.SLACK_SIGNING_SECRET });

// 注: これはデモの目的のみの例です
// 実際は重要なデータはセキュリティの高いデータベースに保存してください
// このアプリは bot トークンのみのしようと仮定、ここで使われるオブジェクトは、複数ワークスペースにアプリをインストールする際の認証情報を保管するモデルとします

const installations = [
  {
    enterpriseId: 'E1234A12AB',
    teamId: 'T12345',
    botToken: 'xoxb-123abc',
    botId: 'B1251',
    botUserId: 'U12385',
  },
  {
    teamId: 'T77712',
    botToken: 'xoxb-102anc',
    botId: 'B5910',
    botUserId: 'U1239',
  },
];

const authorizeFn = async ({ teamId, enterpriseId }) => {
  // データベースから team 情報を取得
  for (const team in installations) {
    // installations 配列から teamId と enterpriseId が一致するかチェック
    if ((team.teamId === teamId) && (team.enterpriseId === enterpriseId)) {
      // 一致したワークスペースの認証情報を使用
      return {
        // かわりに userToken を使用
        botToken: team.botToken,
        botId: team.botId,
        botUserId: team.botUserId
      };
    }
  }
  
  throw new Error('No matching authorizations'); // 認証エラー
}

Conversation stores

Bolt は、会話 (conversation) に関連する state を設定および取得する store をサポートしています。conversation store には以下の 2 つのメソッドがあります。

  • set() は会話の state を変更します。set() は、文字列型の conversationId、任意の型の value、およびオプションの数値型の expiresAt を必要とします。set()Promise を返します。
  • get() は store から会話の state を取得します。get() は文字列型の conversationId を必要とし、その会話の state とともに Promise を返します。

conversationContext() は、他のミドルウェアによる会話の更新を可能にする組み込みのグローバルミドルウェアです。イベントを受け取ると、ミドルウェア関数は context.updateConversation() を使用して状態を設定でき、context.conversation を使用してその state を取得できます。

組み込みの conversation store は、シンプルに会話の state をメモリーに格納します。状況によってはこれで十分ですが、アプリのインスタンスが複数実行されている場合、状態はプロセス間で共有されないため、データベースを使用して会話の state を取得する conversation store を実装することをお勧めします。

const app = new App({
  token,
  signingSecret,
  // クラスを作成する感じで
  convoStore: new simpleConvoStore()
});

// Firebaseのようなデータベースを使い conversation store を実装
class simpleConvoStore {
  set(conversationId, value, expiresAt) {
    // Promise を返す
    return db().ref('conversations/' + conversationId).set({ value, expiresAt });
  }

  get(conversationId) {
    // Promise を返す
    return new Promise((resolve, reject) => {
      db().ref('conversations/' + conversationId).once('value').then((result) => {
        if (result !== undefined) {
          if (result.expiresAt !== undefined && Date.now() > result.expiresAt) {
            db().ref('conversations/' + conversationId).delete();

            reject(new Error('Conversation expired'));
          }
          resolve(result.value)
        } else {
          // Conversation が存在しないエラー
          reject(new Error('Conversation not found'));
        }
      });
    });
  }
}

グローバルミドルウェア

グローバルミドルウェアは、すべての着信イベントに対して、リスナーミドルウェアより前に実行されます。app.use(fn(payload,...,next)) を使用すると、グローバルミドルウェアをいくつでもアプリに追加できます。

グローバルミドルウェアとリスナーミドルウェアは、いずれも、next() を呼び出して実行チェーンの制御を次のミドルウェアに渡すか、next(error) を呼び出して以前に実行したミドルウェアチェーンにエラーを渡す必要があります。

たとえば、アプリが、対応する内部認証サービス (SSO プロバイダ、LDAP など) で識別されたユーザーにのみ応答する必要があるとします。この場合、グローバルミドルウェアを使用して認証サービス内のユーザーレコードを検索し、ユーザーが見つからない場合はエラーとなるように定義するのがよいでしょう。

//  Acme ID情報管理プロバイダ上のユーザからの着信イベントと紐つけた認証ミドルウェア
function authWithAcme({ payload, context, say, next }) {
  const slackUserId = payload.user;
  const helpChannelId = 'C12345';

  // Slack ユーザ ID を使って Acmeシステム上にあるユーザ情報を検索できる関数があるとと仮定
  acme.lookupBySlackId(slackUserId)
    .then((user) => {
      // 検索できたらそのユーザ情報でコンテクストを生成
      context.user = user;

      // 制御とリスナー関数を次のミドルウェアに引き渡し
      next();
    })
    .catch((error) => {
      // Acme システム上にユーザが存在しないのでエラーをわたし、イベントプロセスを終了
      if (error.message === 'Not Found') {
        app.client.chat.postEphemeral({
          token: context.botToken,
          channel: payload.channel,
          user: slackUserId,
          text: `Sorry <@${slackUserId}, you aren't registered in Acme. Please post in <#${helpChannelId} for assistance.`
        });
        return;
      }

      // 制御とリスナー関数を(もしあれば)前のミドルウェア渡す、もしくはグローバルエラーハンドラに引き渡し
      next(error);
    });
}

リスナーミドルウェア

リスナーミドルウェアは、全てではありませんが多くのリスナー関数を対象としたロジックに使用され、組み込みメソッド内のリスナー関数より先に引数として追加されます。ここでは任意の数のリスナーミドルウェアを追加することができます。

組み込みリスナーミドルウェアはいくつか用意されており、例えば、メッセージのサブタイプをフィルタリングする subtype() や、ボットに直接 @ メンションしないメッセージを除外する directMention() のように使用することができます。

ただしもちろん、よりカスタマイズされた機能を追加するために、独自のミドルウェアを作成することもできます。独自のミドルウェアを記述する際には、関数で next() を呼び出して制御を次のミドルウェアに渡すか、next(error) を呼び出して以前に実行されたミドルウェアチェーンにエラーを渡す必要があります。

たとえば、リスナーが人間からのメッセージのみを扱うのであれば、ボットメッセージを除外するリスナーミドルウェアを作成できます。

// 'bot_message' サブタイプを持つメッセージをフィルタリングするリスナーミドルウェア
function noBotMessages({ message, next }) {
  if (!message.subtype || message.subtype !== 'bot_message') {
     next();
  }
}

// ボットではなく人間からのメッセージのみを受信するリスナー
app.message(noBotMessages, ({ message }) => console.log(
  `(MSG) User: ${message.user}
   Message: ${message.text}`
));

context の追加

すべてのリスナーから、情報を追加してイベントを充実させるために使用できる context オブジェクトにアクセスすることができます。これはたとえば、サードパーティのシステムからユーザー情報を追加したり、チェーン内の次のミドルウェアの一時的な状態を追加したりする場合に使用します。

context は単なるオブジェクトであるため、必要な情報をいくらでも追加、編集できます。

async function addTimezoneContext ({ payload, context, next }) {
  const user = await app.client.users.info({
    token: context.botToken,
    user: payload.user_id,
    include_locale: true
  });

  // ユーザのタイムゾーン情報を追加
  context.tz_offset = user.tz_offset;
}

app.command('request', addTimezoneContext, async ({ command, ack, context }) => {
  // コマンドリクエストの確認
  ack();
  // リクエスト時のローカル時間を取得
  const local_hour = (Date.UTC() + context.tz_offset).getHours();

  // チャンネル ID のリクエスト
  const requestChannel = 'C12345';

  const requestText = `:large_blue_circle: *New request from <@${command.user_id}>*: ${command.text}`;

  // 午前9時〜午後5時以外のリクエストの場合は明日
  if (local_hour > 17 || local_hour < 9) {
    // ローカル時間の明日午前9時までの差分を取得する関数があると仮定
    const local_tomorrow = getLocalTomorrow(context.tz_offset);

    try {
      // メッセージ送信スケジュールを調整
      const result = await app.client.chat.scheduleMessage({
        token: context.botToken,
        channel: requestChannel,
        text: requestText,
        post_at: local_tomorrow
      });
    }
    catch (error) {
      console.error(error);
    }
  } else {
    try {
      // 送信
      const result = app.client.chat.postMessage({
        token: context.botToken,
        channel: requestChannel,
        text: requestText
      });
    }
    catch (error) {
      console.error(error);
    }
  }
});

ログの表示

デフォルトでは、Bolt はアプリからコンソールに情報をログします。ログ取集が行われる回数をカスタマイズするには、コンストラクタで logLevel を渡します。使用可能なログレベルは、レベルの高い方から順に、DEBUGINFOWARN、および ERROR です。

// パッケージから LogLevel をインポート
const { App, LogLevel } = require('@slack/bolt');

// オプションとして、コンストラクタで Log level を設定可能
const app = new App({
  token,
  signingSecret,
  logLevel: LogLevel.DEBUG,
});

コンソール以外へのログ出力の送信

ログの送信先をコンソール以外に設定するなど、logger をよりスマートに管理するには、カスタム logger を実装します。カスタム logger には、以下の特定のメソッド (Logger インターフェイスと呼ばれる) を実装する必要があります。

メソッド パラメーター 戻り値の型
setLevel() level: LogLevel void
getLevel() なし string (値は error, warn, info, debug のいずれか)
setName() name: string void
debug() ...msgs: any[] void
info() ...msgs: any[] void
warn() ...msgs: any[] void
error() ...msgs: any[] void

非常に単純なカスタム logger では、名前やレベルが無視され、すべてのメッセージがファイルに書き込まれることがあります。

const { App } = require('@slack/bolt');
const { createWriteStream } = require('fs');
const logWritable = createWriteStream('/var/my_log_file');

const app = new App({
  token,
  signingSecret,
  // リテラルオブジェクトとしてクラスを指定する感じで logger を生成。
  logger: {
    debug(...msgs): { logWritable.write('debug: ' + JSON.stringify(msgs)); },
    info(...msgs): { logWritable.write('info: ' + JSON.stringify(msgs)); },
    warn(...msgs): { logWritable.write('warn: ' + JSON.stringify(msgs)); },
    error(...msgs): { logWritable.write('error: ' + JSON.stringify(msgs)); },
    setLevel(): { },
    setName(): { },
  },
});

レシーバーのカスタマイズ

レシーバーは、Slack から送信されたイベントを処理およびパースして発行するので、Bolt アプリがそのイベントにコンテキストを追加し、アプリのリスナーに渡すことができます。レシーバーは、レシーバーのインターフェイスに準拠している必要があります。

メソッド パラメーター 戻り値の型
on() type: string, listener: fn() unknown
start() None Promise
stop() None Promise

Bolt アプリでは on() が 2 回呼び出されます。

  • Receiver.on('message', listener) は、解析されたすべての着信リクエストを onIncomingEvent() にルーティングする必要があります。これは、Bolt アプリでは this.receiver.on('message', message => this.onIncomingEvent(message)) として呼び出されます。
  • Receiver.on('error', listener) は、エラーをグローバルエラーハンドラーにルーティングする必要があります。これは、Bolt アプリでは this.receiver.on('error', error => this.onGlobalError(error)) として呼び出されます。

Bolt アプリを初期化するときにカスタムレシーバーをコンストラクタに渡すことで、そのカスタムレシーバーを使用できます。ここで紹介するのは、基本的なカスタムレシーバーです。

レシーバーについて詳しくは、組み込み Express レシーバーのソースコードをお読みください。

import { EventEmitter } from 'events';
import { createServer } from 'http';
import express from 'express';

// EventEmitter は on() 関数を操作
// https://nodejs.org/api/events.html#events_emitter_on_eventname_listener
class simpleReceiver extends EventEmitter {
  constructor(signingSecret, endpoints) {
    super();
    this.app = express();
    this.server = createServer(this.app);

    for (const endpoint of endpoints) {
      this.app.post(endpoint, this.requestHandler.bind(this));
    }
  }

  start(port) {
    return new Promise((resolve, reject) => {
      try {
        this.server.listen(port, () => {
          resolve(this.server);
        });
      } catch(error) {
        reject(error);
      }
    });
  }

  stop() {
    return new Promise((resolve, reject) => {
      this.server.close((error) => {
        if (error) {
          reject(error);
          return;
        }
        resolve();
      })
    })
  }

  requestHandler(req, res) {
    // 着信リクエストをパースするparseBody 関数があると仮定
    const parsedReq = parseBody(req);
    const event = {
      body: parsedReq.body,
      // レシーバーが確認作業に重要
      ack: (response) => {
        if (!response) {
          res.send('')
        } else {
          res.send(response);
        }
      }
    };
    this.emit('message', event);
  }
}