NaN/NaN/NaN

「/invoice 山田商事 150000」とSlackに打つだけで、請求書PDFがチャンネルに届く——そんなワークフローを実現できたら、業務効率は大幅に上がります。

この記事では、Slack BotとFUNBREW PDF APIを組み合わせて、チャットからPDFを自動生成するシステムを構築する方法をゼロから解説します。スラッシュコマンドの実装から、Block Kitを使ったインタラクティブなオプション選択、cronによる定期レポートの自動投稿まで、実践的なコード例とともに紹介します。

API自体の基本的な使い方はクイックスタートガイドを参照してください。

Slack PDF自動生成のユースケース

Slack BotとPDF APIを組み合わせると、次のようなワークフローが実現できます。

スラッシュコマンド 生成されるもの 想定利用者
/invoice 顧客名 金額 請求書PDF 営業・経理
/report weekly 週次KPIレポート 管理職・マーケ
/certificate 氏名 修了証明書PDF 人事・研修担当
/quote 商品名 数量 見積書PDF 営業
/summary meeting 議事録PDF プロジェクトマネージャー

PDF生成のユースケース全般についてはユースケース一覧も参考にしてください。

準備:Slack Appの作成とBot設定

1. Slack Appを作成する

Slack APIにアクセスし「Create New App」をクリックします。「From scratch」を選択し、アプリ名とワークスペースを設定してください。

2. 必要なBot Token Scopesを追加

「OAuth & Permissions」から以下のスコープを追加します。

スコープ 用途
commands スラッシュコマンドの受信
chat:write メッセージの送信
files:write PDFファイルのアップロード
channels:read チャンネル情報の読み取り

3. スラッシュコマンドの登録

「Slash Commands」から「Create New Command」で以下を登録します。

Command:       /invoice
Request URL:   https://your-app.example.com/slack/commands
Short Desc:    請求書PDFを生成してチャンネルに送信
Usage Hint:    [顧客名] [金額]

4. Interactivity & Shortcutsの設定

Block Kitのインタラクティブボタンを使う場合は「Interactivity & Shortcuts」を有効にし、Request URLを設定します。

Request URL: https://your-app.example.com/slack/interactions

5. Bot TokenをFUNBREW PDF APIキーとともに.envに保存

SLACK_BOT_TOKEN=xoxb-your-bot-token
SLACK_SIGNING_SECRET=your-signing-secret
FUNBREW_API_KEY=your-funbrew-api-key
FUNBREW_API_URL=https://api.pdf.funbrew.cloud/v1
PORT=3000

プロジェクトのセットアップ

依存パッケージのインストール

Slack Bolt for JavaScriptを使います。軽量でスラッシュコマンド・インタラクションの処理が簡単です。

mkdir slack-pdf-bot && cd slack-pdf-bot
npm init -y
npm install @slack/bolt axios dotenv
npm install --save-dev nodemon

ディレクトリ構造

slack-pdf-bot/
├── src/
│   ├── index.js          # Boltアプリのエントリーポイント
│   ├── commands/
│   │   ├── invoice.js    # /invoice コマンド
│   │   └── report.js     # /report コマンド
│   ├── interactions/
│   │   └── pdf-options.js # Block Kitインタラクション
│   ├── pdf/
│   │   └── generator.js  # FUNBREW PDF API呼び出し
│   └── templates/
│       ├── invoice.html  # 請求書テンプレート
│       └── report.html   # レポートテンプレート
├── .env
└── package.json

Step 1: Slack PDF生成のためのAPI連携モジュール

まずPDF生成の中核となるモジュールを実装します。

// src/pdf/generator.js
const axios = require('axios');

const API_KEY = process.env.FUNBREW_API_KEY;
const API_URL = process.env.FUNBREW_API_URL || 'https://api.pdf.funbrew.cloud/v1';

/**
 * HTMLからPDFバイナリを生成する
 */
async function generatePdfFromHtml(html, options = {}) {
  const {
    engine = 'quality',
    format = 'A4',
    margin = { top: '20mm', bottom: '20mm', left: '20mm', right: '20mm' },
  } = options;

  const response = await axios.post(
    `${API_URL}/pdf/from-html`,
    { html, engine, format, margin },
    {
      headers: {
        Authorization: `Bearer ${API_KEY}`,
        'Content-Type': 'application/json',
      },
      responseType: 'arraybuffer', // バイナリで受け取る
      timeout: 30000,
    }
  );

  return Buffer.from(response.data);
}

/**
 * テンプレート変数を展開してHTMLを生成する
 */
function renderTemplate(template, variables) {
  return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
    return variables[key] !== undefined ? String(variables[key]) : '';
  });
}

module.exports = { generatePdfFromHtml, renderTemplate };

HTMLテンプレートの詳細な書き方についてはテンプレートエンジンガイドで解説しています。

Step 2: スラッシュコマンドの実装(/invoice)

請求書HTMLテンプレート

<!-- src/templates/invoice.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <style>
    body { font-family: 'Hiragino Sans', 'Yu Gothic', sans-serif; margin: 0; padding: 40px; color: #1f2937; }
    .header { display: flex; justify-content: space-between; margin-bottom: 40px; border-bottom: 2px solid #3b82f6; padding-bottom: 20px; }
    .title { font-size: 32px; font-weight: 700; color: #3b82f6; }
    .meta { color: #6b7280; font-size: 14px; }
    table { width: 100%; border-collapse: collapse; margin: 24px 0; }
    th { background: #f3f4f6; text-align: left; padding: 12px; font-size: 13px; }
    td { padding: 12px; border-bottom: 1px solid #e5e7eb; }
    .total { text-align: right; font-size: 18px; font-weight: 700; color: #1f2937; margin-top: 16px; }
    .footer { margin-top: 40px; font-size: 12px; color: #9ca3af; text-align: center; }
  </style>
</head>
<body>
  <div class="header">
    <div>
      <div class="title">請求書</div>
      <div class="meta">請求番号: {{invoice_number}}</div>
      <div class="meta">発行日: {{issue_date}}</div>
      <div class="meta">支払期限: {{due_date}}</div>
    </div>
    <div style="text-align:right">
      <strong>{{company_name}}</strong><br>
      <span class="meta">{{company_address}}</span>
    </div>
  </div>

  <p><strong>{{customer_name}} 御中</strong></p>

  <table>
    <thead>
      <tr>
        <th>品目</th>
        <th style="text-align:right">数量</th>
        <th style="text-align:right">単価</th>
        <th style="text-align:right">小計</th>
      </tr>
    </thead>
    <tbody>{{line_items_html}}</tbody>
  </table>

  <div class="total">
    <div>小計: ¥{{subtotal}}</div>
    <div>消費税 (10%): ¥{{tax}}</div>
    <div style="font-size:22px; color:#3b82f6; margin-top:8px">合計: ¥{{total}}</div>
  </div>

  <div class="footer">ご不明な点は {{contact_email}} までご連絡ください。</div>
</body>
</html>

/invoice コマンドハンドラ

// src/commands/invoice.js
const fs = require('fs');
const path = require('path');
const { generatePdfFromHtml, renderTemplate } = require('../pdf/generator');

const templatePath = path.join(__dirname, '../templates/invoice.html');
const invoiceTemplate = fs.readFileSync(templatePath, 'utf-8');

/**
 * /invoice コマンドの処理
 * 使い方: /invoice [顧客名] [金額]
 * 例: /invoice 山田商事 150000
 */
async function handleInvoiceCommand({ command, ack, respond, client }) {
  // 3秒以内にACKを返す(Slack要件)
  await ack();

  const args = command.text.trim().split(/\s+/);
  if (args.length < 2) {
    await respond({
      text: 'コマンドの形式が正しくありません。例: `/invoice 山田商事 150000`',
      response_type: 'ephemeral',
    });
    return;
  }

  const [customerName, amountStr] = args;
  const amount = parseInt(amountStr, 10);

  if (isNaN(amount)) {
    await respond({ text: '金額は数値で入力してください。', response_type: 'ephemeral' });
    return;
  }

  // 処理中メッセージを先に返す
  await respond({
    text: `請求書を生成中です... :hourglass_flowing_sand:`,
    response_type: 'ephemeral',
  });

  try {
    const tax = Math.floor(amount * 0.1);
    const total = amount + tax;
    const invoiceNumber = `INV-${Date.now()}`;
    const issueDate = new Date().toLocaleDateString('ja-JP');
    const dueDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toLocaleDateString('ja-JP');

    const html = renderTemplate(invoiceTemplate, {
      invoice_number: invoiceNumber,
      issue_date: issueDate,
      due_date: dueDate,
      company_name: '株式会社FUNBREW',
      company_address: '東京都渋谷区',
      customer_name: customerName,
      line_items_html: `<tr><td>サービス利用料</td><td style="text-align:right">1</td><td style="text-align:right">¥${amount.toLocaleString()}</td><td style="text-align:right">¥${amount.toLocaleString()}</td></tr>`,
      subtotal: amount.toLocaleString(),
      tax: tax.toLocaleString(),
      total: total.toLocaleString(),
      contact_email: 'billing@funbrew.cloud',
    });

    // PDF生成
    const pdfBuffer = await generatePdfFromHtml(html, { engine: 'quality', format: 'A4' });

    // SlackにPDFをアップロード
    await client.files.uploadV2({
      channel_id: command.channel_id,
      file: pdfBuffer,
      filename: `invoice-${invoiceNumber}.pdf`,
      initial_comment: `*${customerName}* 向けの請求書が生成されました :page_facing_up:\n請求番号: ${invoiceNumber} / 合計: ¥${total.toLocaleString()}`,
    });
  } catch (error) {
    console.error('Invoice generation failed:', error);
    await respond({
      text: `PDF生成に失敗しました: ${error.message}`,
      response_type: 'ephemeral',
    });
  }
}

module.exports = { handleInvoiceCommand };

Step 3: Boltアプリのエントリーポイント

// src/index.js
require('dotenv').config();
const { App } = require('@slack/bolt');
const { handleInvoiceCommand } = require('./commands/invoice');
const { handleReportCommand } = require('./commands/report');
const { handlePdfOptions } = require('./interactions/pdf-options');

const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  signingSecret: process.env.SLACK_SIGNING_SECRET,
  socketMode: false, // HTTP modeを使用
  port: process.env.PORT || 3000,
});

// スラッシュコマンドの登録
app.command('/invoice', handleInvoiceCommand);
app.command('/report', handleReportCommand);
app.command('/certificate', async ({ command, ack, respond, client }) => {
  await ack();
  await respond({ text: '証明書生成は別途実装してください。', response_type: 'ephemeral' });
});

// Block Kitインタラクションの登録
app.action('pdf_format_select', handlePdfOptions);
app.action('pdf_generate_confirm', handlePdfOptions);

// エラーハンドリング
app.error(async (error) => {
  console.error('Bolt app error:', error);
});

(async () => {
  await app.start();
  console.log(`Slack PDF Bot is running on port ${process.env.PORT || 3000}`);
})();

Step 4: 生成したPDFをSlackチャンネルにアップロード

files.uploadV2(2024年以降の推奨API)を使ってPDFをチャンネルに投稿します。

// src/pdf/uploader.js

/**
 * PDFバッファをSlackチャンネルにアップロードする
 */
async function uploadPdfToSlack(client, { channelId, pdfBuffer, filename, message }) {
  try {
    const result = await client.files.uploadV2({
      channel_id: channelId,
      file: pdfBuffer,
      filename: filename,
      initial_comment: message,
    });

    return {
      ok: true,
      fileId: result.file?.id,
      permalink: result.file?.permalink,
    };
  } catch (error) {
    // files:write スコープが不足している場合のエラーハンドリング
    if (error.code === 'slack_webapi_platform_error' && error.data?.error === 'missing_scope') {
      throw new Error('files:write スコープが不足しています。Slack Appの設定を確認してください。');
    }
    throw error;
  }
}

/**
 * ファイルURLを含むBlock Kitメッセージを構築する
 */
function buildPdfCompletionMessage(filename, fileUrl, metadata) {
  return {
    blocks: [
      {
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: `:page_facing_up: *${filename}* が生成されました`,
        },
      },
      {
        type: 'section',
        fields: Object.entries(metadata).map(([label, value]) => ({
          type: 'mrkdwn',
          text: `*${label}:*\n${value}`,
        })),
      },
      {
        type: 'actions',
        elements: [
          {
            type: 'button',
            text: { type: 'plain_text', text: 'PDFを開く' },
            url: fileUrl,
            style: 'primary',
          },
        ],
      },
    ],
  };
}

module.exports = { uploadPdfToSlack, buildPdfCompletionMessage };

Step 5: Block Kitでインタラクティブなオプション選択

PDFのフォーマット(用紙サイズ・向き)をユーザーが選べるインタラクティブメッセージを実装します。

オプション選択メッセージの送信

// src/commands/report.js
const { generatePdfFromHtml, renderTemplate } = require('../pdf/generator');
const { uploadPdfToSlack } = require('../pdf/uploader');
const fs = require('fs');
const path = require('path');

const reportTemplate = fs.readFileSync(
  path.join(__dirname, '../templates/report.html'),
  'utf-8'
);

/**
 * /report コマンド
 * 使い方: /report [weekly|monthly|custom]
 */
async function handleReportCommand({ command, ack, respond }) {
  await ack();

  // Block Kitでオプション選択UIを表示
  await respond({
    response_type: 'ephemeral',
    blocks: [
      {
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: ':bar_chart: *レポートPDFの設定を選択してください*',
        },
      },
      {
        type: 'section',
        block_id: 'report_type_block',
        text: { type: 'mrkdwn', text: '*レポート種別*' },
        accessory: {
          type: 'static_select',
          action_id: 'report_type_select',
          placeholder: { type: 'plain_text', text: '種別を選択' },
          options: [
            { text: { type: 'plain_text', text: '週次KPIレポート' }, value: 'weekly' },
            { text: { type: 'plain_text', text: '月次サマリーレポート' }, value: 'monthly' },
            { text: { type: 'plain_text', text: 'カスタムレポート' }, value: 'custom' },
          ],
        },
      },
      {
        type: 'section',
        block_id: 'format_block',
        text: { type: 'mrkdwn', text: '*用紙サイズ*' },
        accessory: {
          type: 'static_select',
          action_id: 'pdf_format_select',
          placeholder: { type: 'plain_text', text: 'サイズを選択' },
          options: [
            { text: { type: 'plain_text', text: 'A4 (縦)' }, value: 'A4' },
            { text: { type: 'plain_text', text: 'A4 (横)' }, value: 'A4-landscape' },
            { text: { type: 'plain_text', text: 'Letter' }, value: 'Letter' },
          ],
        },
      },
      {
        type: 'actions',
        elements: [
          {
            type: 'button',
            action_id: 'pdf_generate_confirm',
            text: { type: 'plain_text', text: 'PDFを生成' },
            style: 'primary',
            value: JSON.stringify({ channelId: command.channel_id, userId: command.user_id }),
          },
          {
            type: 'button',
            action_id: 'pdf_generate_cancel',
            text: { type: 'plain_text', text: 'キャンセル' },
          },
        ],
      },
    ],
  });
}

module.exports = { handleReportCommand };

インタラクションハンドラ

// src/interactions/pdf-options.js
const { generatePdfFromHtml } = require('../pdf/generator');
const { uploadPdfToSlack } = require('../pdf/uploader');

// ユーザーごとの選択状態を一時保持(本番ではRedis推奨)
const userSelections = new Map();

async function handlePdfOptions({ action, body, ack, respond, client }) {
  await ack();

  const userId = body.user.id;

  if (action.action_id === 'report_type_select') {
    userSelections.set(userId, {
      ...userSelections.get(userId),
      reportType: action.selected_option.value,
    });
    return;
  }

  if (action.action_id === 'pdf_format_select') {
    userSelections.set(userId, {
      ...userSelections.get(userId),
      format: action.selected_option.value,
    });
    return;
  }

  if (action.action_id === 'pdf_generate_confirm') {
    const selection = userSelections.get(userId) || {};
    const { channelId } = JSON.parse(action.value || '{}');

    await respond({ text: 'レポートを生成中です... :hourglass_flowing_sand:', response_type: 'ephemeral' });

    try {
      const html = buildReportHtml(selection.reportType || 'weekly');
      const pdfBuffer = await generatePdfFromHtml(html, {
        engine: 'quality',
        format: selection.format || 'A4',
      });

      await uploadPdfToSlack(client, {
        channelId: channelId || body.channel.id,
        pdfBuffer,
        filename: `report-${selection.reportType || 'weekly'}-${Date.now()}.pdf`,
        message: `:bar_chart: *${selection.reportType || '週次'}レポート* が生成されました`,
      });

      userSelections.delete(userId);
    } catch (error) {
      await respond({ text: `エラーが発生しました: ${error.message}`, response_type: 'ephemeral' });
    }
  }

  if (action.action_id === 'pdf_generate_cancel') {
    userSelections.delete(userId);
    await respond({ text: 'キャンセルしました。', response_type: 'ephemeral', replace_original: true });
  }
}

function buildReportHtml(reportType) {
  const titles = {
    weekly: '週次KPIレポート',
    monthly: '月次サマリーレポート',
    custom: 'カスタムレポート',
  };
  return `
    <html><head><meta charset="UTF-8">
    <style>body{font-family:sans-serif;padding:40px;} h1{color:#3b82f6;}</style>
    </head><body>
    <h1>${titles[reportType] || 'レポート'}</h1>
    <p>生成日時: ${new Date().toLocaleString('ja-JP')}</p>
    <p>(実際のデータをここに挿入してください)</p>
    </body></html>
  `;
}

module.exports = { handlePdfOptions };

Step 6: Slack PDF定期レポートの自動投稿

cron(node-cron)を使って、毎週月曜朝9時に週次レポートを自動投稿します。

// src/scheduler.js
const cron = require('node-cron');
const { generatePdfFromHtml } = require('./pdf/generator');
const { uploadPdfToSlack } = require('./pdf/uploader');
const { fetchWeeklyMetrics } = require('./data/metrics'); // 実際のデータ取得処理

const REPORT_CHANNEL_ID = process.env.SLACK_REPORT_CHANNEL_ID; // 例: C01234567

/**
 * 週次レポートのcronジョブ
 * 毎週月曜日 09:00 JST に実行
 */
function startScheduledReports(client) {
  // node-cronはUTCなのでJST(+9)を引いた00:00 UTCを指定
  cron.schedule('0 0 * * 1', async () => {
    console.log('[Scheduler] 週次レポート生成を開始します');

    try {
      const metrics = await fetchWeeklyMetrics();
      const html = buildWeeklyReportHtml(metrics);

      const pdfBuffer = await generatePdfFromHtml(html, {
        engine: 'quality',
        format: 'A4',
      });

      const weekLabel = getWeekLabel();
      await uploadPdfToSlack(client, {
        channelId: REPORT_CHANNEL_ID,
        pdfBuffer,
        filename: `weekly-report-${weekLabel}.pdf`,
        message: `:calendar: *${weekLabel} 週次KPIレポート* を自動投稿しました。`,
      });

      console.log(`[Scheduler] 週次レポートを投稿しました: ${weekLabel}`);
    } catch (error) {
      console.error('[Scheduler] 週次レポート生成エラー:', error);

      // 失敗した場合もSlackに通知
      await client.chat.postMessage({
        channel: REPORT_CHANNEL_ID,
        text: `:warning: 週次レポートの自動生成に失敗しました: ${error.message}`,
      });
    }
  }, {
    timezone: 'Asia/Tokyo',
  });

  console.log('[Scheduler] 週次レポートのcronジョブを登録しました(毎週月曜 09:00 JST)');
}

function getWeekLabel() {
  const now = new Date();
  const year = now.getFullYear();
  const start = new Date(now);
  start.setDate(now.getDate() - now.getDay() + 1); // 今週月曜
  return `${year}-W${String(Math.ceil(start.getDate() / 7)).padStart(2, '0')}`;
}

function buildWeeklyReportHtml(metrics) {
  return `
    <!DOCTYPE html>
    <html lang="ja">
    <head>
      <meta charset="UTF-8">
      <style>
        body { font-family: 'Hiragino Sans', sans-serif; padding: 40px; color: #1f2937; }
        h1 { color: #3b82f6; border-bottom: 2px solid #3b82f6; padding-bottom: 8px; }
        .metric { display: inline-block; background: #f3f4f6; border-radius: 8px; padding: 16px 24px; margin: 8px; text-align: center; }
        .metric-value { font-size: 28px; font-weight: 700; color: #3b82f6; }
        .metric-label { font-size: 13px; color: #6b7280; margin-top: 4px; }
        table { width: 100%; border-collapse: collapse; margin-top: 24px; }
        th { background: #f3f4f6; padding: 10px 12px; text-align: left; font-size: 13px; }
        td { padding: 10px 12px; border-bottom: 1px solid #e5e7eb; }
      </style>
    </head>
    <body>
      <h1>週次KPIレポート</h1>
      <p>集計期間: ${metrics.periodStart} 〜 ${metrics.periodEnd}</p>

      <div>
        <div class="metric">
          <div class="metric-value">${metrics.totalSales?.toLocaleString('ja-JP') || '-'}円</div>
          <div class="metric-label">総売上</div>
        </div>
        <div class="metric">
          <div class="metric-value">${metrics.newUsers || '-'}</div>
          <div class="metric-label">新規ユーザー</div>
        </div>
        <div class="metric">
          <div class="metric-value">${metrics.conversionRate || '-'}%</div>
          <div class="metric-label">コンバージョン率</div>
        </div>
        <div class="metric">
          <div class="metric-value">${metrics.pdfGenerated || '-'}</div>
          <div class="metric-label">PDF生成数</div>
        </div>
      </div>

      <h2>チャネル別内訳</h2>
      <table>
        <thead>
          <tr><th>チャネル</th><th>セッション</th><th>コンバージョン</th><th>売上</th></tr>
        </thead>
        <tbody>
          ${(metrics.channels || []).map(ch => `
            <tr>
              <td>${ch.name}</td>
              <td>${ch.sessions?.toLocaleString()}</td>
              <td>${ch.conversions}</td>
              <td>¥${ch.revenue?.toLocaleString()}</td>
            </tr>
          `).join('')}
        </tbody>
      </table>
    </body>
    </html>
  `;
}

module.exports = { startScheduledReports };

node-cronのインストールを忘れずに。

npm install node-cron

src/index.js にスケジューラーを追加します。

// src/index.js の末尾に追加
const { startScheduledReports } = require('./scheduler');

(async () => {
  await app.start();
  startScheduledReports(app.client); // スケジューラー起動
  console.log(`Slack PDF Bot is running on port ${process.env.PORT || 3000}`);
})();

Step 7: セキュリティとアクセス制御

Slackリクエストの署名検証

Bolt SDKは署名検証を自動で行いますが、Expressなどを直接使う場合は必ず手動で検証してください。

const crypto = require('crypto');

/**
 * Slackリクエストの署名を検証する
 * Bolt SDKを使わない場合に使用
 */
function verifySlackSignature(req) {
  const signingSecret = process.env.SLACK_SIGNING_SECRET;
  const timestamp = req.headers['x-slack-request-timestamp'];
  const signature = req.headers['x-slack-signature'];

  // リプレイ攻撃の防止(5分以上古いリクエストを拒否)
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp, 10)) > 300) {
    throw new Error('リクエストのタイムスタンプが古すぎます');
  }

  const rawBody = req.rawBody; // express.raw() で取得したボディ
  const baseString = `v0:${timestamp}:${rawBody}`;
  const hmac = crypto.createHmac('sha256', signingSecret);
  const expectedSignature = `v0=${hmac.update(baseString).digest('hex')}`;

  if (!crypto.timingSafeEqual(
    Buffer.from(signature, 'utf-8'),
    Buffer.from(expectedSignature, 'utf-8')
  )) {
    throw new Error('署名が一致しません');
  }
}

チャンネル・ユーザーごとのアクセス制御

本番環境では、誰でも /invoice を叩けると困ります。ユーザーや所属チャンネルに基づいてアクセスを制限しましょう。

// src/middleware/auth.js

// 許可するSlackユーザーID(Slack設定から確認)
const ALLOWED_USER_IDS = (process.env.ALLOWED_SLACK_USER_IDS || '').split(',');

// 許可するチャンネルID(ここ以外では動作させない)
const ALLOWED_CHANNEL_IDS = (process.env.ALLOWED_SLACK_CHANNEL_IDS || '').split(',');

/**
 * ユーザーが許可リストに含まれているか検証するミドルウェア
 */
async function requireAllowedUser({ command, ack, respond, next }) {
  if (ALLOWED_USER_IDS.length > 0 && !ALLOWED_USER_IDS.includes(command.user_id)) {
    await ack();
    await respond({
      text: ':no_entry: このコマンドを実行する権限がありません。管理者にお問い合わせください。',
      response_type: 'ephemeral',
    });
    return;
  }
  await next();
}

/**
 * チャンネルが許可リストに含まれているか検証するミドルウェア
 */
async function requireAllowedChannel({ command, ack, respond, next }) {
  if (ALLOWED_CHANNEL_IDS.length > 0 && !ALLOWED_CHANNEL_IDS.includes(command.channel_id)) {
    await ack();
    await respond({
      text: ':no_entry: このコマンドはこのチャンネルでは使用できません。',
      response_type: 'ephemeral',
    });
    return;
  }
  await next();
}

module.exports = { requireAllowedUser, requireAllowedChannel };

ミドルウェアをコマンドに適用します。

// src/index.js でのミドルウェア適用
const { requireAllowedUser, requireAllowedChannel } = require('./middleware/auth');

// ミドルウェアを挟んでコマンドを登録
app.command('/invoice',
  requireAllowedUser,
  requireAllowedChannel,
  handleInvoiceCommand
);

APIキーの管理

FUNBREW PDFのAPIキーはコードに埋め込まず、必ず環境変数で管理します。

# 本番環境(例: Railway, Render, Fly.io)
railway variables set FUNBREW_API_KEY=sk-your-key
# または
fly secrets set FUNBREW_API_KEY=sk-your-key

# ローカル開発
echo "FUNBREW_API_KEY=sk-your-key" >> .env
echo ".env" >> .gitignore  # 必ず.gitignoreに追加

APIキーの管理全般についてはセキュリティガイドで詳しく解説しています。

デプロイ

Railway / Render / Fly.io へのデプロイ

Slack BotはHTTPSエンドポイントが必要なため、インターネットから到達可能なホスティングが必要です。

// package.json
{
  "scripts": {
    "start": "node src/index.js",
    "dev": "nodemon src/index.js"
  }
}
# Dockerfile(本番用)
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY src/ ./src/
EXPOSE 3000
CMD ["node", "src/index.js"]

DockerやKubernetesへのデプロイ詳細はDocker・Kubernetes運用ガイドを参照してください。

ローカル開発時のngrok利用

Slack からのWebhookを受け取るには公開URLが必要です。開発中はngrokが便利です。

# ngrokのインストールと起動
brew install ngrok
ngrok http 3000

# 表示されたURLをSlack Appの設定に登録
# 例: https://abc123.ngrok.io/slack/commands

Step 8: エラーハンドリングとリトライ戦略

PDF生成に失敗した場合、Slackユーザーに分かりやすいエラーメッセージを返し、重要なエラーはリトライします。

リトライ付きPDF生成関数

// src/pdf/generator.js — リトライ対応版

const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 1000;

/**
 * 指数バックオフ付きリトライでPDFを生成する
 */
async function generatePdfWithRetry(html, options = {}, attempt = 1) {
  try {
    return await generatePdfFromHtml(html, options);
  } catch (error) {
    // リトライ不要なエラー(400系はリクエスト内容の問題)
    if (error.response?.status >= 400 && error.response?.status < 500) {
      const message = error.response?.data?.message || error.message;
      throw new Error(`PDF生成エラー(入力内容を確認してください): ${message}`);
    }

    if (attempt >= MAX_RETRIES) {
      throw new Error(`PDF生成に${MAX_RETRIES}回失敗しました: ${error.message}`);
    }

    // 指数バックオフ(1秒 → 2秒 → 4秒)
    const delay = RETRY_DELAY_MS * Math.pow(2, attempt - 1);
    console.warn(`[PDF] 試行 ${attempt} 失敗。${delay}ms 後にリトライします...`);
    await new Promise(resolve => setTimeout(resolve, delay));

    return generatePdfWithRetry(html, options, attempt + 1);
  }
}

module.exports = { generatePdfFromHtml, generatePdfWithRetry, renderTemplate };

エラーの種類別ハンドリング

Slack Bot で発生しうるエラーを種類別に分類して適切に処理します。

// src/utils/error-handler.js

/**
 * エラーをユーザー向けメッセージに変換する
 */
function formatErrorMessage(error) {
  // Slack API エラー
  if (error.code === 'slack_webapi_platform_error') {
    switch (error.data?.error) {
      case 'missing_scope':
        return ':lock: Slackの権限が不足しています。Bot の `files:write` スコープを確認してください。';
      case 'channel_not_found':
        return ':x: チャンネルが見つかりません。Botをチャンネルに招待してください(`/invite @YourBot`)。';
      case 'not_in_channel':
        return ':x: Botがこのチャンネルにいません。`/invite @YourBot` で招待してください。';
      default:
        return `:warning: Slack APIエラー: ${error.data?.error}`;
    }
  }

  // PDF 生成エラー(入力問題)
  if (error.message?.includes('入力内容を確認してください')) {
    return `:x: ${error.message}`;
  }

  // タイムアウト
  if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
    return ':hourglass: PDF生成がタイムアウトしました。HTMLが複雑な場合は `quality` エンジンの代わりに `fast` エンジンをお試しください。';
  }

  // その他のサーバーエラー
  return `:warning: 予期せぬエラーが発生しました。しばらくしてから再試行してください。\nエラー詳細: ${error.message}`;
}

/**
 * エラーを Slack に通知し、ログにも記録する
 */
async function handleCommandError(error, { respond, command }) {
  console.error(`[SlackBot] コマンド "${command?.command}" でエラー発生:`, {
    userId: command?.user_id,
    channelId: command?.channel_id,
    error: error.message,
    stack: error.stack,
  });

  const userMessage = formatErrorMessage(error);

  await respond({
    text: userMessage,
    response_type: 'ephemeral',
  });
}

module.exports = { formatErrorMessage, handleCommandError };

Step 9: Slack App の配布とインストール

チームメンバーが自分のワークスペースにBotをインストールできるようにするには、OAuthフローを実装します。

マルチワークスペース対応の OAuth フロー

// src/oauth.js
const { App, ExpressReceiver } = require('@slack/bolt');
const express = require('express');

const receiver = new ExpressReceiver({
  signingSecret: process.env.SLACK_SIGNING_SECRET,
  clientId: process.env.SLACK_CLIENT_ID,
  clientSecret: process.env.SLACK_CLIENT_SECRET,
  stateSecret: process.env.SLACK_STATE_SECRET || 'my-secret',
  scopes: ['commands', 'chat:write', 'files:write', 'channels:read'],
  // トークンの保存先(本番ではDBを使用)
  installationStore: {
    storeInstallation: async (installation) => {
      // DB に保存(例: installation.team.id をキーにする)
      await db.saveInstallation(installation.team.id, installation);
    },
    fetchInstallation: async (installQuery) => {
      // DB から取得
      return await db.getInstallation(installQuery.teamId);
    },
    deleteInstallation: async (installQuery) => {
      await db.deleteInstallation(installQuery.teamId);
    },
  },
});

const app = new App({ receiver });

// インストールページ(Slack の "Add to Slack" ボタンをここに設置)
receiver.router.get('/', (req, res) => {
  res.send(`
    <html><body>
      <h1>Slack PDF Bot</h1>
      <a href="/slack/install">
        <img src="https://platform.slack-edge.com/img/add_to_slack.png" />
      </a>
    </body></html>
  `);
});

module.exports = { app, receiver };

App Manifest を使ったワンクリック設定

Slack App Manifest を使うと、スラッシュコマンドの設定を自動化できます。

# manifest.yaml
display_information:
  name: PDF Bot
  description: Generate PDFs from Slack slash commands
  background_color: "#1a1a2e"

features:
  bot_user:
    display_name: PDF Bot
    always_online: true
  slash_commands:
    - command: /invoice
      url: https://your-app.example.com/slack/commands
      description: 請求書PDFをチャンネルに送信
      usage_hint: "[顧客名] [金額]"
      should_escape: false
    - command: /report
      url: https://your-app.example.com/slack/commands
      description: レポートPDFを生成
      usage_hint: "[weekly|monthly]"
      should_escape: false

oauth_config:
  scopes:
    bot:
      - commands
      - chat:write
      - files:write
      - channels:read

settings:
  interactivity:
    is_enabled: true
    request_url: https://your-app.example.com/slack/interactions
  org_deploy_enabled: false
  socket_mode_enabled: false

Slack API の「Your Apps」→「Create from Manifest」でこのYAMLを貼り付けると、スラッシュコマンド設定が一括適用されます。

本番デプロイのチェックリスト

項目 確認内容
HTTPSエンドポイント Slack は HTTP 不可。SSL/TLS 必須
環境変数 SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET, FUNBREW_API_KEY を確認
署名検証 Bolt SDK 使用時は自動。カスタム実装は手動で検証
タイムアウト PDF生成は最大30秒。Slackの3秒ACK制限に注意(即ACKして非同期処理)
ファイルスコープ files:write スコープの付与を確認
エラー通知 Sentry や Datadog でエラーを監視
レート制限 Slack API の Tier1〜3 制限に従い、大量送信時はキューを使用

まとめ

この記事では、Slack BotとFUNBREW PDF APIを組み合わせてPDFを自動生成するシステムを構築しました。

実装した機能 概要
スラッシュコマンド /invoice でチャットから即座にPDF生成
Block Kit UI 用紙サイズ・種別をインタラクティブに選択
ファイルアップロード files.uploadV2 でPDFをチャンネルに投稿
定期レポート cron で毎週月曜に自動投稿
セキュリティ 署名検証・ユーザー制御・APIキー管理
エラーハンドリング リトライ・種別別メッセージ・ログ記録
App 配布 OAuth フローとApp Manifestでチームへ配布

次のステップとして、バッチ処理と組み合わせることで、月末に大量の請求書を一括生成してSlackで通知するワークフローも実現できます。Webhook連携を使えば、PDF生成完了をリアルタイムで受け取ることも可能です。ぜひプレイグラウンドでFUNBREW PDF APIを試してみてください。

関連記事

Powered by FUNBREW PDF