Slackbotを適切に設定すれば、Slack上でボタンやドロップダウン、定期タスク、スマート通知などを使って、WordPressサイトの管理を効率化することができます。

今回は、WordPressサイトの管理用にインタラクティブな対話機能、自動化、監視機能をSlackbotに実装する方法を取り上げます。

前提条件

これからご紹介する内容は、以下が必要になります。

  • ボット権限とスラッシュコマンドを持つSlackアプリ
  • APIアクセス権を持つKinstaアカウントとテスト用サイト
  • ローカル環境へのNode.jsとNPMのインストール
  • JavaScriptの基礎知識(少なくともコードのコピーと微調整に慣れていること)
  • SlackとKinsta APIキー

はじめに

このSlackbotは、Node.jsとSlackのJavaScriptフレームワークであるBoltを使い、スラッシュコマンドをKinstaのAPIに連携させてアクションを実行できるようにしています。

今回は、Slackアプリの作成やKinsta APIアクセスの取得に関する手順は割愛します。サイト管理用にNode.jsとKinsta APIを使ってSlackbotを作成する方法はこちらをご覧ください。

先に上記の記事に目を通してから、本記事をご覧いただくのがおすすめです。この記事では、Slackアプリの作成、ボットトークンとSigning Secretの取得、Kinsta APIキーの取得についてご説明します。

Slackbotにインタラクティブ機能を追加

Slackbotはスラッシュコマンドだけに頼る必要はありません。ボタン、メニュー、モーダルなどのインタラクティブな要素を使えば、ボットをより直感的でユーザーフレンドリーなツールに変身させることができます。

例えば、/clear_cache environment_idと入力する代わりに、サイトのステータスを確認した後、ボタンをクリックしてキャッシュをクリアすることができれば便利です。これにはSlackのWeb APIクライアントが必要になります。以下のコマンドでプロジェクトにインストールしてください。

npm install @slack/web-api

app.jsで初期化します。

const { WebClient } = require('@slack/web-api');
const web = new WebClient(process.env.SLACK_BOT_TOKEN);

.envファイルにSLACK_BOT_TOKENが設定されていることを確認します。例として、前回の記事でご紹介した/site_statusコマンドを改良してみます。単にテキストを送信するだけでなく、「キャッシュのクリア」「バックアップの作成」「ステータスの詳細確認」のようなクイックアクション用のボタンを追加します。

このハンドラは以下のようになります。

app.command('/site_status', async ({ command, ack, say }) => {
  await ack();
  
  const environmentId = command.text.trim();
  
  if (!environmentId) {
    await say('環境IDを指定してください。構文: /site_status [environment-id]');
    return;
  }
  
  try {
    // 環境ステータスの取得
    const response = await kinstaRequest(`/sites/environments/${environmentId}`);
    
    if (response && response.site && response.site.environments && response.site.environments.length > 0) {
      const env = response.site.environments[0];
      
      // ステータスメッセージを整える
      let statusMessage = formatSiteStatus(env);
      
      // インタラクティブなボタンでメッセージを送信
      await web.chat.postMessage({
        channel: command.channel_id,
        text: statusMessage,
        blocks: [
          {
            type: 'section',
            text: {
              type: 'mrkdwn',
              text: statusMessage
            }
          },
          {
            type: 'actions',
            elements: [
              {
                type: 'button',
                text: {
                  type: 'plain_text',
                  text: '🧹 キャッシュのクリア',
                  emoji: true
                },
                value: environmentId,
                action_id: 'clear_cache_button'
              },
              {
                type: 'button',
                text: {
                  type: 'plain_text',
                  text: '📊 ステータスの詳細確認',
                  emoji: true
                },
                value: environmentId,
                action_id: 'detailed_status_button'
              },
              {
                type: 'button',
                text: {
                  type: 'plain_text',
                  text: '💾 バックアップの作成',
                  emoji: true
                },
                value: environmentId,
                action_id: 'create_backup_button'
              }
            ]
          }
        ]
      });
    } else {
      await say(`⚠️ ID ${environmentId} の環境が見つかりませんでした`);
    }
  } catch (error) {
    console.error('サイトステータスのチェックでエラーが発生: ', error);
    await say(`❌ サイトの状態の確認中にエラーが発生しました: ${error.message}`);
  }
});

各ボタンをクリックすると、アクションがトリガーされます。以下、「キャッシュのクリア」ボタンをどのように扱うかを見てみます。

// ボタンのアクションハンドラを追加
app.action('clear_cache_button', async ({ body, ack, respond }) => {
  await ack();
  
  const environmentId = body.actions[0].value;
  
  await respond(`環境 ${environmentId} のキャッシュをクリアしています...🔄`);
  
  try {
    // Kinsta APIを呼び出してキャッシュをクリア
    const response = await kinstaRequest(
      `/sites/environments/${environmentId}/clear-cache`,
      'POST'
    );
    
    if (response && response.operation_id) {
      await respond(`✅ キャッシュクリアの操作を開始。オペレーションID: ${response.operation_id}`);
    } else {
      await respond('⚠️ キャッシュのクリアリクエストを送信しましたが、オペレーションIDが返されませんでした');
    }
  } catch (error) {
    console.error('キャッシュのクリアエラー:', error);
    await respond(`❌ キャッシュのクリアエラー: ${error.message}`);
  }
});

バックアップボタンやステータスボタンも同じパターンで、それぞれを適切なAPIエンドポイントやコマンドロジックにリンクさせればよい。

// 他のボタン用ハンドラ
app.action('detailed_status_button', async ({ body, ack, respond }) => {
  await ack();
  const environmentId = body.actions[0].value;
  // detailed_statusコマンドと同様の詳細なステータスチェックを実装
  // ...
});

app.action('create_backup_button', async ({ body, ack, respond }) => {
  await ack();
  const environmentId = body.actions[0].value;
  // create_backupコマンドと同様のバックアップ作成を実装
  // ...
});

ドロップダウンでサイトを選択

環境IDの入力は、チームメンバー全員がどのIDがどの環境に属しているかを把握する必要があり、面倒な操作になり得ます。

これをより直感的に行えるよう、/site_status [environment-id]を入力する代わりに、Slackのドロップダウンでサイトを選択できるようにします。選択すると、該当のサイトのステータスが表示され、先ほど実装したクイックアクションボタンを使用できるようにします。

以下のような流れになります。

  • Kinsta APIから全てのサイトを取得
  • 各サイトの環境を取得
  • これらのオプションでドロップダウンメニューを構築
  • ユーザーの選択を処理してサイトのステータスを表示

以下はドロップダウンメニューを表示するコマンドです。

app.command('/select_site', async ({ command, ack, say }) => {
  await ack();
  
  try {
    // すべてのサイトを取得
    const response = await kinstaRequest('/sites');
    
    if (response && response.company && response.company.sites) {
      const sites = response.company.sites;
      
      // 各サイトにオプションを作成
      const options = [];
      
      for (const site of sites) {
        // このサイトの環境を取得
        const envResponse = await kinstaRequest(`/sites/${site.id}/environments`);
        
        if (envResponse && envResponse.site && envResponse.site.environments) {
          for (const env of envResponse.site.environments) {
            options.push({
              text: {
                type: 'plain_text',
                text: `${site.name} (${env.name})`
              },
              value: env.id
            });
          }
        }
      }
      
      // ドロップダウンでメッセージを送信
      await web.chat.postMessage({
        channel: command.channel_id,
        text: '管理するサイトを選択:',
        blocks: [
          {
            type: 'section',
            text: {
              type: 'mrkdwn',
              text: '*管理するサイトを選択:*'
            },
            accessory: {
              type: 'static_select',
              placeholder: {
                type: 'plain_text',
                text: 'Select a site'
              },
              options: options.slice(0, 100), // Slackのオプションは最大100個まで
              action_id: 'site_selected'
            }
          }
        ]
      });
    } else {
      await say('❌ サイトの取得にエラーが発生しました。API認証情報を確認してください。');
    }
  } catch (error) {
    console.error('エラー:', error);
    await say(`❌ サイトの取得エラー: ${error.message}`);
  }
});

サイトが選択すると、以下のアクションハンドラで処理されます。

// サイト選択を処理
app.action('site_selected', async ({ body, ack, respond }) => {
  await ack();
  
  const environmentId = body.actions[0].selected_option.value;
  const siteName = body.actions[0].selected_option.text.text;
  
  // 環境ステータスを取得
  try {
    const response = await kinstaRequest(`/sites/environments/${environmentId}`);
    
    if (response && response.site && response.site.environments && response.site.environments.length > 0) {
      const env = response.site.environments[0];
      
      //ステータスメッセージを整える
      let statusMessage = `*${siteName}* (ID: `${environmentId}`)nn${formatSiteStatus(env)}`;
      
      // インタラクティブなボタンでメッセージを送信(site_statusコマンドに類似)
      // ...
    } else {
      await respond(`⚠️ ID ${environmentId} の環境が見つかりませんでした`);
    }
  } catch (error) {
    console.error('エラー:', error);
    await respond(`❌ 環境の取得エラー: ${error.message}`);
  }
});

なお、これによりサイトの選択が簡素化されますが、誤って危険な操作を実行しないように注意してください。

確認ダイアログの表示

誤って実行すると危険な操作もあります。例えば、キャッシュのクリアは一見重要な操作ではありませんが、本番サイトで作業している場合、ワンクリックでは実行するのには抵抗があるはず。そこで役立つのがSlackのモーダル(ダイアログ)の表示です。

clear_cache_buttonがクリックされたら即座にキャッシュをクリアするのではなく、確認用のモーダルを表示することができます。

app.action('clear_cache_button', async ({ body, ack, context }) => {
  await ack();
  
  const environmentId = body.actions[0].value;
  
  // 確認ダイアログを開く
  try {
    await web.views.open({
      trigger_id: body.trigger_id,
      view: {
        type: 'modal',
        callback_id: 'clear_cache_confirmation',
        private_metadata: environmentId,
        title: {
          type: 'plain_text',
          text: 'Confirm Cache Clearing'
        },
        blocks: [
          {
            type: 'section',
            text: {
              type: 'mrkdwn',
              text: `環境 ${environmentId} のキャッシュをクリアしますか?`
            }
          }
        ],
        submit: {
          type: 'plain_text',
          text: 'キャッシュをクリア'
        },
        close: {
          type: 'plain_text',
          text: 'キャンセル'
        }
      }
    });
  } catch (error) {
    console.error('確認ダイアログを開く際にエラーが発生しました:', error);
  }
});

上のコードでは、web.views.open()を使用して、明確なタイトル、警告メッセージ、および「キャッシュをクリア」と「キャンセル」の2つのボタンを持つモーダルを起動し、private_metadataenvironmentIdを保存して、「キャッシュのクリア」をクリックした際に表示できるようになっています。

モーダルで「キャッシュをクリア」をクリックすると、Slackがview_submissionイベントを送信します。このイベントを処理し、操作を実行する方法は以下のようになります。

// 確認ダイアログの入力を処理
app.view('clear_cache_confirmation', async ({ ack, body, view }) => {
  await ack();
  
  const environmentId = view.private_metadata;
  const userId = body.user.id;
  
  // 対応するユーザーのDMチャンネルを検索
  const result = await web.conversations.open({
    users: userId
  });
  
  const channel = result.channel.id;
  
  await web.chat.postMessage({
    channel,
    text: `🔄 環境 ${environmentId} のキャッシュをクリアしています...`
  });
  
  try {
    // Kinsta APIを呼び出してキャッシュをクリア
    const response = await kinstaRequest(
      `/sites/environments/${environmentId}/clear-cache`,
      'POST'
    );
    
    if (response && response.operation_id) {
      await web.chat.postMessage({
        channel,
        text: `✅ キャッシュのクリア操作を開始。オペレーションID: ${response.operation_id}`
      });
    } else {
      await web.chat.postMessage({
        channel,
        text: '⚠️ キャッシュのクリアリクエストを送信しましたが、オペレーションIDが返されませんでした。'
      });
    }
  } catch (error) {
    console.error('キャッシュのクリアエラー: ', error);
    await web.chat.postMessage({
      channel,
      text: `❌ キャッシュのクリアエラー: ${error.message}`
    });
  }
});

上のコードでは、確認ダイアログで操作を確定した後、private_metadataからenvironmentIdを取得し、web.conversations.open()を使用してプライベートDMを開いて、キャッシュをクリアするためにAPIリクエストを実行。その結果に応じて完了またはエラーメッセージを表示します。

プログレスバーの追加

Slackのコマンドの中には、キャッシュのクリアやステータスの確認など、即座に実行できるものもありますが、そうではないものもあります。

例えば、バックアップの作成やファイルのデプロイには数秒から数分かかることがあります。その間にボットからの応答がなければ、正常に処理が行われていないと勘違いする可能性があります。

Slackには組み込みのプログレスバーはありませんが、ちょっとした工夫でプログレスバー擬きを作ることができます。以下はBlock Kitを使って視覚的なプログレスバーでメッセージを更新するヘルパー関数です。

async function updateProgress(channel, messageTs, text, percentage) {
  // プログレスバーを作成
  const barLength = 20;
  const filledLength = Math.round(barLength * (percentage / 100));
  const bar = '█'.repeat(filledLength) + '░'.repeat(barLength - filledLength);
  
  await web.chat.update({
    channel,
    ts: messageTs,
    text: `${text} [${percentage}%]`,
    blocks: [
      {
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: `${text} [${percentage}%]n`${bar}``
        }
      }
    ]
  });
}

これを/create_backupコマンドに組み込んでみます。操作全体が完了するのをただ待つのではなく、進捗を表示するようにします。

app.command('/create_backup', async ({ command, ack, say }) => {
  await ack();
  
  const args = command.text.split(' ');
  const environmentId = args[0];
  const tag = args.length > 1 ? args.slice(1).join(' ') : `手動バックアップ ${new Date().toISOString()}`;
  
  if (!environmentId) {
    await say('環境IDを入力してください。構文: /create_backup [environment-id] [optional-tag]');
    return;
  }
  
  // 最初のメッセージを投稿し、更新のためのタイムスタンプを取得
  const initial = await say('🔄 バックアップを開始します...');
  const messageTs = initial.ts;
  
  try {
    // 進捗状況を10%に更新
    await updateProgress(command.channel_id, messageTs, '🔄 バックアップを作成中です...', 10);
    
    // バックアップの作成にKinsta APIを呼び出す
    const response = await kinstaRequest(
      `/sites/environments/${environmentId}/manual-backups`,
      'POST',
      { tag }
    );
    
    if (response && response.operation_id) {
      await updateProgress(command.channel_id, messageTs, '🔄 バックアップ中です...', 30);
      
      // 稼働状況のポーリング
      let completed = false;
      let percentage = 30;
      
      while (!completed && percentage  setTimeout(resolve, 3000));
        
        // 動作状況の確認
        const statusResponse = await kinstaRequest(`/operations/${response.operation_id}`);
        
        if (statusResponse && statusResponse.operation) {
          const operation = statusResponse.operation;
          
          if (operation.status === 'completed') {
            completed = true;
            percentage = 100;
          } else if (operation.status === 'failed') {
            await web.chat.update({
              channel: command.channel_id,
              ts: messageTs,
              text: `❌ バックアップに失敗しました。エラー: ${operation.error || '不明なエラー'}`
            });
            return;
          } else {
            // 進捗を更新
            percentage += 10;
            if (percentage > 95) percentage = 95;
            
            await updateProgress(
              command.channel_id, 
              messageTs, 
              '🔄 バックアップ中です...', 
              percentage
            );
          }
        }
      }
      
      // 最終アップデート
      await web.chat.update({
        channel: command.channel_id,
        ts: messageTs,
        text: `✅ バックアップが完了しました`,
        blocks: [
          {
            type: 'section',
            text: {
              type: 'mrkdwn',
              text: `✅ バックアップが正常に完了しました。タグ: ${tag} オペレーションID: ${response.operation_id}`
            }
          }
        ]
      });
    } else {
      await web.chat.update({
        channel: command.channel_id,
        ts: messageTs,
        text: '⚠️ バックアップのリクエストを送信しましたが、オペレーションIDが返されませんでした。'
      });
    }
  } catch (error) {
    console.error('バックアップ作成エラー:', error);
    
    await web.chat.update({
      channel: command.channel_id,
      ts: messageTs,
      text: `❌ バックアップ作成エラー: ${error.message}`
    });
  }
});

成功または失敗の通知

ボットは「成功✅」や「失敗❌」のようなプレーンテキストを返すのが一般的で、これでも機能はしますが、これだけでは成功した理由や失敗した理由はわかりません。

成功メッセージとエラーメッセージに適切な書式を設定し、有用なコンテキスト、提案、書式を追加することができます。

以下のユーティリティをutils.jsに追加して、すべてのコマンドで再利用できるようにしましょう。

function formatSuccessMessage(title, details = []) {
  let message = `✅ *${title}*nn`;
  
  if (details.length > 0) {
    details.forEach(detail => {
      message += `• ${detail.label}: ${detail.value}n`;
    });
  }
  
  return message;
}

function formatErrorMessage(title, error, suggestions = []) {
  let message = `❌ *${title}*nn`;
  message += `*エラー:* ${error}nn`;
  
  if (suggestions.length > 0) {
    message += '*提案:*n';
    suggestions.forEach(suggestion => {
      message += `• ${suggestion}n`;
    });
  }
  
  return message;
}

module.exports = {
  connectToSite,
  logCommand,
  formatSuccessMessage,
  formatErrorMessage
};

これらの関数は、構造化された入力を受け取って、Slackで見やすい形式に変換します。絵文字やラベル、改行により、やり取りの多いスレッドの中でもパッと内容を把握することができます。コマンドハンドラの内部は以下のようになります。例として/clear_cacheを見てみます。

app.command('/clear_cache', async ({ command, ack, say }) => {
  await ack();
  
  const environmentId = command.text.trim();
  
  if (!environmentId) {
    await say('環境IDを入力してください。構文: /clear_cache [environment-id]');
    return;
  }
  
  try {
    await say('🔄 Processing...');
    
    // Kinsta APIを呼び出してキャッシュをクリア
    const response = await kinstaRequest(
      `/sites/environments/${environmentId}/clear-cache`,
      'POST'
    );
    
    if (response && response.operation_id) {
      const { formatSuccessMessage } = require('./utils');
      
      await say(formatSuccessMessage('Cache Clearing Started', [
        { label: '環境ID', value: ``${environmentId}`` },
        { label: 'オペレーションID', value: ``${response.operation_id}`` },
        { label: 'ステータス', value: 'In Progress' }
      ]));
    } else {
      const { formatErrorMessage } = require('./utils');
      
      await say(formatErrorMessage(
        'キャッシュのクリアエラー',
        'オペレーションIDが返されませんでした',
        [
          '環境IDを確認してください',
          'API認証情報を確認してください',
          '後から再試行してください'
        ]
      ));
    }
  } catch (error) {
    console.error('Cache clearing error:', error);
    
    const { formatErrorMessage } = require('./utils');
    
    await say(formatErrorMessage(
      'Cache Clearing Error',
      error.message,
      [
        '環境IDを確認してください',
        'API認証情報を確認してください',
        '後から再試行してください'
      ]
    ));
  }
});

設定済みジョブでWordPressのタスクを自動化

ここまで紹介した操作は、明示的にコマンドをトリガーすることが前提ですが、ボットが毎晩サイトをバックアップしてくれたり、毎朝始業前にサイトがダウンしていないかを確認したりしてくれると非常に便利です。

node-scheduleライブラリを使って、cron式に基づいてタスクを実行することも可能です。まずはnode-scheduleをインストールします。

npm install node-schedule

app.jsの冒頭で以下のように設定します。

const schedule = require('node-schedule');

また、設定済みのタスクをリストアップしたり、後から取り消したりできるよう、有効な設定済みタスクを追跡する方法も必要になります。

const scheduledJobs = {};

設定済みタスクコマンドの作成

タスクの種類(backupclear_cachestatus_check)、環境ID、cron式を受け取る基本的な/schedule_taskコマンドから始めます。

/schedule_task backup 12345 0 0 * * *

以下のコマンドは毎日0時にバックアップを実行します。

app.command('/schedule_task', async ({ command, ack, say }) => {
  await ack();

  const args = command.text.split(' ');
  if (args.length  {
      console.log(`環境 ${environmentId} に対して設定済みの ${taskType} を実行中`);

      try {
        switch (taskType) {
          case 'backup':
            await kinstaRequest(`/sites/environments/${environmentId}/manual-backups`, 'POST', {
              tag: `設定済みバックアップ ${new Date().toISOString()}`
            });
            break;
          case 'clear_cache':
            await kinstaRequest(`/sites/environments/${environmentId}/clear-cache`, 'POST');
            break;
          case 'status_check':
            const response = await kinstaRequest(`/sites/environments/${environmentId}`);
            const env = response?.site?.environments?.[0];
            if (env) {
              console.log(`Status: ${env.display_name} is ${env.is_blocked ? 'blocked' : 'running'}`);
            }
            break;
        }
      } catch (err) {
        console.error(`Scheduled ${taskType} failed for ${environmentId}:`, err.message);
      }
    });

    scheduledJobs[jobId] = {
      job,
      taskType,
      environmentId,
      cronSchedule,
      userId: command.user_id,
      createdAt: new Date().toISOString()
    };

    await say(`✅ 設定済みタスクが作成されました
*タスク:* ${taskType}
*環境:* `${environmentId}`
*cron:* `${cronSchedule}`
*ジョブID:* `${jobId}`

To cancel this task, run `/cancel_task ${jobId}``);
  } catch (err) {
    console.error('設定済みタスクの作成エラー:', err);
    await say(`❌ 設定済みタスクの作成に失敗しました: ${err.message}`);
  }
});

設定済みタスクの取り消し

何か変更があったり、タスクが不要になったりした場合は、以下のコマンドでタスクを取り消すことができます。

/cancel_task

この実装は以下のとおりです。

app.command('/cancel_task', async ({ command, ack, say }) => {
  await ack();

  const jobId = command.text.trim();

  if (!scheduledJobs[jobId]) {
    await say(`⚠️ ID ${jobId} でタスクが見つかりませんでした`);
    return;
  }

  scheduledJobs[jobId].job.cancel();
  delete scheduledJobs[jobId];

  await say(`✅ タスク ${jobId} を取り消しました`);
});

設定済みタスクの一覧表示

設定済みのタスクを一覧表示することも可能です。

app.command('/list_tasks', async ({ command, ack, say }) => {
  await ack();

  const tasks = Object.entries(scheduledJobs);
  if (tasks.length === 0) {
    await say('設定済みタスクはありません');
    return;
  }

  let message = '*設定済みタスク:*nn';

  for (const [jobId, job] of tasks) {
    message += `• *ジョブID:* `${jobId}`n`;
    message += `  - タスク: ${job.taskType}n`;
    message += `  - 環境: `${job.environmentId}`n`;
    message += `  - cron: `${job.cronSchedule}`n`;
    message += `  - 作成者: nn`;
  }

  message += '_Use `/cancel_task [job_id]` to cancel a task._';
  await say(message);
});

これにより、Slackbotにさらなる自律性を与えることができます。バックアップ、キャッシュのクリア、ステータスの確認が完全に自動化され、スケジュール通りに実行されます。

定期メンテナンス

毎週のバックアップや日曜日の夜に行うキャッシュのクリアなど、定期的なメンテナンスタスクを実行するには、メンテナンスウィンドウが役に立ちます。

メンテナンスウィンドウとは、ボットが事前に定義した以下のようなタスクを実行する時間帯を意味します。

  • バックアップの作成
  • キャッシュのクリア
  • 開始通知と完了通知の送信

書式は以下のようにシンプルです。

/maintenance_window [environment_id] [day_of_week] [hour] [duration_hours]

例えば以下のようになります。

/maintenance_window 12345 Sunday 2 3

上のコードは、毎週日曜日の午前2時から3時間メンテナンスタスクが実行される例です。実装は以下のようになります。

// メンテナンスウィンドウを作成するコマンドを追加
app.command('/maintenance_window', async ({ command, ack, say }) => {
  await ack();
  
  // 指定フォーマット: environment_id day_of_week hour duration
  // 例: /maintenance_window 12345 Sunday 2 3
  const args = command.text.split(' ');
  
  if (args.length < 4) {
    await say('必要なパラメータをすべて入力してください。構文: `/maintenance_window [environment_id] [day_of_week] [hour] [duration_hours]`');
    return;
  }
  
  const [environmentId, dayOfWeek, hour, duration] = args;
  
  // 入力を検証
  const validDays = ['日曜日', '月曜日', '火曜日', '水曜日', '木曜日', '金曜日', '土曜日'];
  if (!validDays.includes(dayOfWeek)) {
    await say(`曜日が無効です。次から選択してください: ${validDays.join(', ')}`);
    return;
  }
  
  const hourInt = parseInt(hour, 10);
  if (isNaN(hourInt) || hourInt  23) {
    await say('時間は0から23までの数字である必要があります。');
    return;
  }
  
  const durationInt = parseInt(duration, 10);
  if (isNaN(durationInt) || durationInt  12) {
    await say('所要時間は1から12までの数字である必要があります。');
    return;
  }
  
  // 曜日をcron形式に変換
  const dayMap = {
    '日曜日': 0,
    '月曜日': 1,
    '火曜日': 2,
    '水曜日': 3,
    '木曜日': 4,
    '金曜日': 5,
    '土曜日': 6
  };
  
  const cronDay = dayMap[dayOfWeek];
  
  // メンテナンスウィンドウ開始のためのcronスケジュールを作成
  const cronSchedule = `0 ${hourInt} * * ${cronDay}`;
  
  // 一意のジョブIDを生成
  const jobId = `maintenance_${environmentId}_${Date.now()}`;
  
  // ジョブを設定
  try {
    const job = schedule.scheduleJob(cronSchedule, async function() {
      // メンテナンスウィンドウを開始
      await web.chat.postMessage({
        channel: command.channel_id,
        text: `🔧 *Maintenance Window Started*n*Environment:* `${environmentId}`n*Duration:* ${durationInt} hoursnn自動メンテナンスタスクを実行中です。`
      });
      
      // メンテナンスタスクの実行
      try {
        // 1. バックアップの作成
        const backupResponse = await kinstaRequest(
          `/sites/environments/${environmentId}/manual-backups`,
          'POST',
          { tag: `メンテナンス用バックアップ ${new Date().toISOString()}` }
        );
        
        if (backupResponse && backupResponse.operation_id) {
          await web.chat.postMessage({
            channel: command.channel_id,
            text: `✅ メンテナンス用バックアップを作成しました。オペレーションID: `${backupResponse.operation_id}``
          });
        }
        
        // 2. キャッシュのクリア
        const cacheResponse = await kinstaRequest(
          `/sites/environments/${environmentId}/clear-cache`,
          'POST'
        );
        
        if (cacheResponse && cacheResponse.operation_id) {
          await web.chat.postMessage({
            channel: command.channel_id,
            text: `✅ キャッシュをクリアしました。オペレーションID: `${cacheResponse.operation_id}``
          });
        }
        
        // 3. メンテナンス終了通知を設定
        setTimeout(async () => {
          await web.chat.postMessage({
            channel: command.channel_id,
            text: `✅ *Maintenance Window Completed*n*Environment:* `${environmentId}`nnすべてのメンテナンスタスクが完了しました。`
          });
        }, durationInt * 60 * 60 * 1000); // 時間をミリ秒に変換
      } catch (error) {
        console.error('メンテナンスタスクエラー:', error);
        await web.chat.postMessage({
          channel: command.channel_id,
          text: `❌ メンテナンス中にエラーが発生しました: ${error.message}`
        });
      }
    });
    
    // 後で取り消せるようにジョブを保存
    scheduledJobs[jobId] = {
      job,
      taskType: 'maintenance',
      environmentId,
      cronSchedule,
      dayOfWeek,
      hour: hourInt,
      duration: durationInt,
      userId: command.user_id,
      createdAt: new Date().toISOString()
    };
    
    await say(`✅ メンテナンスウィンドウを設定しました
*環境:* `${environmentId}`
*スケジュール:* 毎週 ${dayOfWeek} の ${hourInt}:00から ${durationInt} 時間
*ジョブID:* `${jobId}`

To cancel this maintenance window, use `/cancel_task ${jobId}``);
  } catch (error) {
    console.error('メンテナンスウィンドウの設定エラー:', error);
    await say(`❌ メンテナンスウィンドウの設定エラー: ${error.message}`);
  }
});

自動レポート

月曜日の朝、WordPressサイトがバックアップされたかどうか、あるいは何時間も停止していないかどうかなどを心配しながら目を覚ますのは避けたいものです。自動レポートがあれば、Slackbotによるスケジュールに沿ったバフォーマンスの概要を素早く確認することができます。

この手のレポートは、以下のようなことを把握するのに適しています。

  • 現在のサイトステータス
  • 過去7日間のバックアップアクティビティ
  • PHPバージョンとプライマリドメイン
  • ブロックされた環境やバックアップ漏れなどのフラグ

これを自動化する/schedule_reportコマンドは、以下のように作成できます。

// 週次レポートを設定するコマンドを追加
app.command('/schedule_report', async ({ command, ack, say }) => {
  await ack();
  
  // 指定フォーマット: environment_id day_of_week hour
  // 例: /schedule_report 12345 Monday 9
  const args = command.text.split(' ');
  
  if (args.length < 3) {
    await say('必要なパラメータをすべて入力してください。構文: `/schedule_report [environment_id] [day_of_week] [hour]`');
    return;
  }
  
  const [environmentId, dayOfWeek, hour] = args;
  
  // 入力を検証
  const validDays = ['日曜日', '月曜日', '火曜日', '水曜日', '木曜日', '金曜日', '土曜日'];
  if (!validDays.includes(dayOfWeek)) {
    await say(`曜日が無効です。次から選択してください。: ${validDays.join(', ')}`);
    return;
  }
  
  const hourInt = parseInt(hour, 10);
  if (isNaN(hourInt) || hourInt  23) {
    await say('時間は0から23までの数字である必要があります。');
    return;
  }
  
  // 曜日をcron形式に変換
  const dayMap = {
    '日曜日': 0,
    '月曜日': 1,
    '火曜日': 2,
    '水曜日': 3,
    '木曜日': 4,
    '金曜日': 5,
    '土曜日': 6
  };
  
  const cronDay = dayMap[dayOfWeek];
  
  // レポートのcronスケジュールを作成
  const cronSchedule = `0 ${hourInt} * * ${cronDay}`;
  
  // 一意のジョブIDを生成
  const jobId = `report_${environmentId}_${Date.now()}`;
  
  // ジョブを設定
  try {
    const job = schedule.scheduleJob(cronSchedule, async function() {
      // レポートの作成と送信
      await generateWeeklyReport(environmentId, command.channel_id);
    });
    
    //後で取り消せるようにジョブを保存
    scheduledJobs[jobId] = {
      job,
      taskType: 'report',
      environmentId,
      cronSchedule,
      dayOfWeek,
      hour: hourInt,
      userId: command.user_id,
      createdAt: new Date().toISOString()
    };
    
    await say(`✅ 週次レポートを設定しました
*環境:* `${environmentId}`
*スケジュール:* Every ${dayOfWeek} at ${hourInt}:00
*ジョブID:* `${jobId}`

To cancel this report, use `/cancel_task ${jobId}``);
  } catch (error) {
    console.error('レポートの設定エラー:', error);
    await say(`❌ レポートの設定エラー: ${error.message}`);
  }
});

// 週次レポートを作成する機能
async function generateWeeklyReport(environmentId, channelId) {
  try {
    // 環境情報を取得
    const response = await kinstaRequest(`/sites/environments/${environmentId}`);
    
    if (!response || !response.site || !response.site.environments || !response.site.environments.length) {
      await web.chat.postMessage({
        channel: channelId,
        text: `⚠️ 週次レポートエラー: ID ${environmentId} の環境がありません`
      });
      return;
    }
    
    const env = response.site.environments[0];
    
    // 過去1週間のバックアップを取得
    const backupsResponse = await kinstaRequest(`/sites/environments/${environmentId}/backups`);
    
    let backupsCount = 0;
    let latestBackup = null;
    
    if (backupsResponse && backupsResponse.environment && backupsResponse.environment.backups) {
      const oneWeekAgo = new Date();
      oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
      
      const recentBackups = backupsResponse.environment.backups.filter(backup => {
        const backupDate = new Date(backup.created_at);
        return backupDate >= oneWeekAgo;
      });
      
      backupsCount = recentBackups.length;
      
      if (recentBackups.length > 0) {
        latestBackup = recentBackups.sort((a, b) => b.created_at - a.created_at)[0];
      }
    }
    
    // 環境ステータスを取得
    const statusEmoji = env.is_blocked ? '🔴' : '🟢';
    const statusText = env.is_blocked ? 'Blocked' : 'Running';
    
    // レポートメッセージを作成
    const reportDate = new Date().toLocaleDateString('en-US', {
      weekday: 'long',
      year: 'numeric',
      month: 'long',
      day: 'numeric'
    });
    
    const reportMessage = `📊 *週次レポート - ${reportDate}*
*サイト:* ${env.display_name}
*環境ID:* `${environmentId}`

*ステータス概要:*
• 現在のステータス: ${statusEmoji} ${statusText}
• PHPバージョン: ${env.container_info?.php_engine_version || 'Unknown'}
• プライマリドメイン: ${env.primaryDomain?.name || env.domains?.[0]?.name || 'N/A'}

*バックアップ概要:*
• 総バックアップ数(過去7日間): ${backupsCount}
• 最新のバックアップ: ${latestBackup ? new Date(latestBackup.created_at).toLocaleString() : 'N/A'}
• 最新のバックアップタイプ: ${latestBackup ? latestBackup.type : 'N/A'}

*推奨事項:*
• ${backupsCount === 0 ? '⚠️ 最近のバックアップが見つかりません。手動バックアップの作成を検討してください。' : '✅ 定期的なバックアップが作成されています。'}
• ${env.is_blocked ? '⚠️ サイトは現在ブロックされています。問題がないか確認してください。' : '✅ サイトは正常に稼動しています。'}

_これは自動レポートです。詳細情報を確認するには、/site_status ${environmentId} コマンドを使用してください;
    
    await web.chat.postMessage({
      channel: channelId,
      text: reportMessage
    });
  } catch (error) {
    console.error('Report generation error:', error);
    await web.chat.postMessage({
      channel: channelId,
      text: `❌ 週次レポートの作成エラー: ${error.message}`
    });
  }
}

エラー処理と監視

ボットが環境を変更したり、設定済みのタスクを起動したりといった操作を行うようになると、console.log()以上のものが必要になります。

これを以下クリーンかつメンテナンス可能なレイヤーに分解してみます。

Winstonによる構造化ログ

コンソールにログを出力する代わりに winstonを使って、構造化されたログをファイル、または必要に応じてLogglyDatadogのようなサービスに送信することができます。以下のコマンドでインストール可能です。

npm install winston

次にlogger.jsを設定します。

const winston = require('winston');
const fs = require('fs');
const path = require('path');

const logsDir = path.join(__dirname, '../logs');
if (!fs.existsSync(logsDir)) fs.mkdirSync(logsDir);

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json()
  ),
  defaultMeta: { service: 'wordpress-slack-bot' },
  transports: [
    new winston.transports.Console({ format: winston.format.simple() }),
    new winston.transports.File({ filename: path.join(logsDir, 'error.log'), level: 'error' }),
    new winston.transports.File({ filename: path.join(logsDir, 'combined.log') })
  ]
});

module.exports = logger;

次にapp.jsで、console.logまたはconsole.errorの呼び出しを次のように置き換えます。

const logger = require('./logger');

logger.info('キャッシュのクリアを開始', { userId: command.user_id });
logger.error('APIエラー', { error: err.message });

Slack経由で管理者に通知を送信する

すでに環境変数ADMIN_USERSが設定されているため、何か重要な障害が発生した際は、Slack経由でチームに直接通知するために使用することができます。

async function alertAdmins(message, metadata = {}) {
  for (const userId of ADMIN_USERS) {
    const dm = await web.conversations.open({ users: userId });
    const channel = dm.channel.id;

    let alert = `🚨 *${message}*n`;
    for (const [key, value] of Object.entries(metadata)) {
      alert += `• *${key}:* ${value}n`;
    }

    await web.chat.postMessage({ channel, text: alert });
  }
}

以下のように使用します。

await alertAdmins('Backup Failed', {
  environmentId,
  error: error.message,
  user: ``
});

簡単な指標でパフォーマンスを追跡

ボットの健全性を確認するだけであれば、Prometheusを本格的に使用する必要はなく、より軽量なパフォーマンス用オブジェクトで十分です。

const metrics = {
  apiCalls: 0,
  errors: 0,
  commands: 0,
  totalTime: 0,
  get avgResponseTime() {
    return this.apiCalls === 0 ? 0 : this.totalTime / this.apiCalls;
  }
};

kinstaRequest()ヘルパーの中で以下を変更します。

const start = Date.now();
try {
  metrics.apiCalls++;
  const res = await fetch(...);
  return await res.json();
} catch (err) {
  metrics.errors++;
  throw err;
} finally {
  metrics.totalTime += Date.now() - start;
}

/bot_performanceのようなコマンドで公開します。

app.command('/bot_performance', async ({ command, ack, say }) => {
  await ack();

  if (!ADMIN_USERS.includes(command.user_id)) {
    return await say('⛔ 許可されていません。');
  }

  const msg = `📊 *Bot Metrics*
• API Calls: ${metrics.apiCalls}
• Errors: ${metrics.errors}
• Avg Response Time: ${metrics.avgResponseTime.toFixed(2)}ms
• Commands Run: ${metrics.commands}`;

  await say(msg);
});

復旧手順の定義(任意)

SSH経由でキャッシュクリアを再試行するような復旧ロジックを実装するには、以下のようなヘルパーを作成します。

async function attemptRecovery(environmentId, issue) {
  logger.warn('Attempting recovery', { environmentId, issue });

  if (issue === 'cache_clear_failure') {
    // ここにフォールバックロジック
  }

  // 復旧ステータスオブジェクトを返す
  return { success: true, message: 'Fallback ran.' };
}

クリティカルパスでない限り、メインのコマンドロジックからは外しておくことをおすすめします。通常はエラーをログに記録し、管理者に警告を送信して、担当者がどうすべきかを判断するのが賢明です。

Slackbotのデプロイと管理

ボットの機能を作成したら、本番環境にデプロイしましょう。

Kinstaが提供するSevallaは、このようなボットをホスティングするのに適したサーバーです。Node.jsアプリ、環境変数、ログ、スケーラブルなデプロイメントをサポートしています。

Dockerを使ってボットをコンテナ化したり、Node.jsとバックグラウンドサービスをサポートするクラウドプラットフォームにデプロイすることも可能です。

最後に、本番稼動前には以下の点に注意してください。

  • すべてのシークレット(Slackトークン、Kinsta APIキー、SSHキー)に環境変数を使用する。
  • ログと稼働状況監視を設定して、何かが壊れたときに把握できるようにする。
  • PM2やDockerのrestart: alwaysポリシーのようなプロセスマネージャーでボットを実行し、クラッシュや再起動後もボットを存続させる。
  • 特に自動化にSSHキーを使っている場合は、SSHキーを安全に保管する。

まとめ

Slackbotをシンプルなコマンドハンドラから、本格的なインタラクティブ機能、自動化、監視機能を備えた強力なツールに変身させる方法をご紹介しました。これらの機能により、ボットの利便性と信頼性が高まり、何より格段に使いやすくなります。複数のWordPressサイトを管理している企業に特に役立つはずです。

さらに、Kinsta APIのパワーとKinstaのストレスフリーなクラウドサーバーと併用することで、スケーラブルで信頼性の高いセットアップを実現することができます。

Joel Olawanle Kinsta

Kinstaでテクニカルエディターとして働くフロントエンド開発者。オープンソースをこよなく愛する講師でもあり、JavaScriptとそのフレームワークを中心に200件以上の技術記事を執筆している。