Skip to content

Slack / Discord Bot 設計

概要

Issue Outsource Platform の通知・コミュニケーション基盤として、Slack Bot(社内PM・レビュワー向け)と Discord Bot(委託者向け)を構築する。 両Botは共通の通知バックエンドから配信され、チャネル設計・メッセージテンプレート・インタラクティブアクションを統一的に管理する。


1. Bot 構成

アーキテクチャ

┌──────────────────────────────────────────────┐
│           Notification Service (FastAPI)       │
│                                                │
│  ┌──────────┐  ┌───────────┐  ┌────────────┐ │
│  │ Trigger   │→│ Router /  │→│ Dispatcher │ │
│  │ Listener  │  │ Formatter │  │            │ │
│  └──────────┘  └───────────┘  └─────┬──────┘ │
│                                      │        │
└──────────────────────────────────────┼────────┘

                 ┌─────────────────────┼─────────────────┐
                 │                     │                  │
          ┌──────▼──────┐     ┌───────▼───────┐  ┌──────▼──────┐
          │  Slack Bot   │     │ Discord Bot   │  │ Email/InApp │
          │  (Bolt SDK)  │     │ (discord.py)  │  │  Workers    │
          └──────┬──────┘     └───────┬───────┘  └─────────────┘
                 │                     │
          ┌──────▼──────┐     ┌───────▼───────┐
          │ Slack API    │     │ Discord API   │
          │ (Web API +   │     │ (Gateway +    │
          │  Socket Mode)│     │  REST API)    │
          └─────────────┘     └───────────────┘

Slack Bot

項目詳細
フレームワークSlack Bolt for Python
接続方式Socket Mode(WebSocket)
スコープchat:write, channels:read, users:read, commands, reactions:write
App ManifestSlack App 設定で管理
デプロイバックエンドと同一プロセス or 独立 Worker

Discord Bot

項目詳細
フレームワークdiscord.py (v2.x)
接続方式Gateway (WebSocket) + REST API
Intentsguilds, guild_messages, message_content
権限Send Messages, Embed Links, Use External Emojis, Add Reactions
デプロイ独立プロセス(常時接続)

2. チャネル設計

Slack チャネル構成

#issue-outsource-general    ← 全体通知・アナウンス
#issue-outsource-reviews    ← MR提出・レビュー関連
#issue-outsource-alerts     ← 期限・予算アラート
#proj-{project_slug}        ← プロジェクト別チャネル(大規模時)
チャネル通知タイプ参加者
#issue-outsource-generalIssue着手、承認・報酬確定PM全員
#issue-outsource-reviewsMR提出、レビューFB、レビュー滞留レビュワー全員
#issue-outsource-alerts期限アラート、予算超過PM、管理者
#proj-{slug}プロジェクト固有の全通知プロジェクトメンバー

チャネル運用ポリシー:

  • プロジェクト数が5件以下: 統合チャネル(general / reviews / alerts)のみ
  • プロジェクト数が6件以上: プロジェクト別チャネルを自動作成
  • アーカイブ: プロジェクト完了後30日でチャネルアーカイブ

Discord チャネル構成

[カテゴリ: Issue Outsource]
├── #announcements          ← 新規Issue公開、プロジェクト公開
├── #issue-updates          ← Issue状態変更、レビューFB
├── #rewards                ← 報酬確定、支払完了
├── #general                ← 自由コミュニケーション
└── [カテゴリ: Projects]
    ├── #proj-{slug}-1      ← プロジェクト別
    └── #proj-{slug}-2
チャネル通知タイプアクセス
#announcements新規Issue公開、新規プロジェクト全委託者(読み取り専用)
#issue-updatesレビューFB、期限アラート、Issue取り下げ全委託者
#rewards承認・報酬確定、支払完了全委託者
#generalなし(手動コミュニケーション用)全委託者
#proj-{slug}プロジェクト固有通知プロジェクト参加者

3. メッセージテンプレート

3.1 新規Issue公開(Discord)

json
{
  "embeds": [{
    "title": "📋 新しいIssueが公開されました",
    "description": "{issue_title}",
    "color": 3447003,
    "fields": [
      { "name": "報酬", "value": "¥{reward_amount}", "inline": true },
      { "name": "期限", "value": "{deadline}", "inline": true },
      { "name": "難易度", "value": "{difficulty_label}", "inline": true },
      { "name": "必要スキル", "value": "{skills}", "inline": false },
      { "name": "概要", "value": "{description_summary}", "inline": false }
    ],
    "footer": { "text": "Issue #{issue_id} | {project_name}" },
    "timestamp": "{created_at}"
  }],
  "components": [{
    "type": 1,
    "components": [
      {
        "type": 2,
        "style": 5,
        "label": "Issue詳細を見る",
        "url": "{issue_url}"
      },
      {
        "type": 2,
        "style": 1,
        "label": "着手する",
        "custom_id": "issue_apply:{issue_id}"
      }
    ]
  }]
}

3.2 Issue着手(Slack)

json
{
  "blocks": [
    {
      "type": "header",
      "text": { "type": "plain_text", "text": "🚀 Issue着手通知" }
    },
    {
      "type": "section",
      "fields": [
        { "type": "mrkdwn", "text": "*Issue:*\n<{issue_url}|#{issue_id} {issue_title}>" },
        { "type": "mrkdwn", "text": "*担当者:*\n{assignee_name}" },
        { "type": "mrkdwn", "text": "*期限:*\n{deadline}" },
        { "type": "mrkdwn", "text": "*報酬:*\n¥{reward_amount}" }
      ]
    },
    {
      "type": "actions",
      "elements": [
        {
          "type": "button",
          "text": { "type": "plain_text", "text": "GitLabで確認" },
          "url": "{issue_url}",
          "action_id": "view_issue"
        }
      ]
    }
  ]
}

3.3 MR提出(Slack)

json
{
  "blocks": [
    {
      "type": "header",
      "text": { "type": "plain_text", "text": "📝 MRレビュー依頼" }
    },
    {
      "type": "section",
      "fields": [
        { "type": "mrkdwn", "text": "*MR:*\n<{mr_url}|!{mr_iid} {mr_title}>" },
        { "type": "mrkdwn", "text": "*提出者:*\n{author_name}" },
        { "type": "mrkdwn", "text": "*関連Issue:*\n<{issue_url}|#{issue_id}>" },
        { "type": "mrkdwn", "text": "*変更規模:*\n+{additions} / -{deletions} ({files_changed} files)" }
      ]
    },
    {
      "type": "actions",
      "elements": [
        {
          "type": "button",
          "text": { "type": "plain_text", "text": "レビュー開始" },
          "style": "primary",
          "url": "{mr_url}",
          "action_id": "start_review"
        },
        {
          "type": "button",
          "text": { "type": "plain_text", "text": "後で確認" },
          "action_id": "snooze_review:{mr_id}"
        }
      ]
    }
  ]
}

3.4 レビューFB - 差し戻し(Discord)

json
{
  "embeds": [{
    "title": "🔄 レビューフィードバック",
    "description": "MR !{mr_iid} に修正リクエストがあります。",
    "color": 15844367,
    "fields": [
      { "name": "Issue", "value": "#{issue_id} {issue_title}", "inline": true },
      { "name": "レビュワー", "value": "{reviewer_name}", "inline": true },
      { "name": "フィードバック概要", "value": "{feedback_summary}", "inline": false }
    ],
    "footer": { "text": "残り期限: {remaining_time}" }
  }],
  "components": [{
    "type": 1,
    "components": [
      {
        "type": 2,
        "style": 5,
        "label": "MRを確認する",
        "url": "{mr_url}"
      }
    ]
  }]
}

3.5 承認・報酬確定(Slack + Discord 共通ペイロード)

Slack版:

json
{
  "blocks": [
    {
      "type": "header",
      "text": { "type": "plain_text", "text": "✅ 承認・報酬確定" }
    },
    {
      "type": "section",
      "fields": [
        { "type": "mrkdwn", "text": "*Issue:*\n<{issue_url}|#{issue_id} {issue_title}>" },
        { "type": "mrkdwn", "text": "*委託者:*\n{contractor_name}" },
        { "type": "mrkdwn", "text": "*報酬金額:*\n¥{reward_amount}" },
        { "type": "mrkdwn", "text": "*承認者:*\n{approver_name}" }
      ]
    },
    {
      "type": "context",
      "elements": [
        { "type": "mrkdwn", "text": "報酬は次回支払サイクルで処理されます。" }
      ]
    }
  ]
}

Discord版:

json
{
  "embeds": [{
    "title": "🎉 報酬が確定しました!",
    "description": "Issue #{issue_id} が承認され、報酬が確定しました。",
    "color": 5763719,
    "fields": [
      { "name": "Issue", "value": "{issue_title}", "inline": false },
      { "name": "報酬金額", "value": "¥{reward_amount}", "inline": true },
      { "name": "承認日", "value": "{approved_at}", "inline": true }
    ]
  }]
}

3.6 期限アラート(Slack + Discord)

Slack版:

json
{
  "blocks": [
    {
      "type": "header",
      "text": { "type": "plain_text", "text": "⏰ 期限アラート" }
    },
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": "*<{issue_url}|#{issue_id} {issue_title}>* の期限まで *{remaining_hours}時間* です。"
      }
    },
    {
      "type": "section",
      "fields": [
        { "type": "mrkdwn", "text": "*担当者:*\n{assignee_name}" },
        { "type": "mrkdwn", "text": "*期限:*\n{deadline}" },
        { "type": "mrkdwn", "text": "*進捗:*\n{progress_status}" }
      ]
    },
    {
      "type": "actions",
      "elements": [
        {
          "type": "button",
          "text": { "type": "plain_text", "text": "期限延長をリクエスト" },
          "style": "danger",
          "action_id": "request_extension:{issue_id}"
        }
      ]
    }
  ]
}

3.7 予算超過アラート(Slack)

json
{
  "blocks": [
    {
      "type": "header",
      "text": { "type": "plain_text", "text": "🚨 予算超過アラート" }
    },
    {
      "type": "section",
      "fields": [
        { "type": "mrkdwn", "text": "*プロジェクト:*\n{project_name}" },
        { "type": "mrkdwn", "text": "*予算:*\n¥{budget_total}" },
        { "type": "mrkdwn", "text": "*消化済み:*\n¥{budget_used} ({budget_percentage}%)" },
        { "type": "mrkdwn", "text": "*超過額:*\n¥{over_amount}" }
      ]
    },
    {
      "type": "actions",
      "elements": [
        {
          "type": "button",
          "text": { "type": "plain_text", "text": "予算詳細を確認" },
          "url": "{budget_url}",
          "action_id": "view_budget"
        },
        {
          "type": "button",
          "text": { "type": "plain_text", "text": "新規Issueを停止" },
          "style": "danger",
          "action_id": "pause_issues:{project_id}",
          "confirm": {
            "title": { "type": "plain_text", "text": "確認" },
            "text": { "type": "plain_text", "text": "このプロジェクトの新規Issue公開を一時停止しますか?" },
            "confirm": { "type": "plain_text", "text": "停止する" },
            "deny": { "type": "plain_text", "text": "キャンセル" }
          }
        }
      ]
    }
  ]
}

3.8 レビュー滞留アラート(Slack)

json
{
  "blocks": [
    {
      "type": "header",
      "text": { "type": "plain_text", "text": "⚠️ レビュー滞留アラート" }
    },
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": "以下のMRが *{stale_days}日間* レビューされていません。"
      }
    },
    {
      "type": "section",
      "fields": [
        { "type": "mrkdwn", "text": "*MR:*\n<{mr_url}|!{mr_iid} {mr_title}>" },
        { "type": "mrkdwn", "text": "*提出者:*\n{author_name}" },
        { "type": "mrkdwn", "text": "*レビュワー:*\n{reviewer_name}" },
        { "type": "mrkdwn", "text": "*提出日:*\n{submitted_at}" }
      ]
    },
    {
      "type": "actions",
      "elements": [
        {
          "type": "button",
          "text": { "type": "plain_text", "text": "今すぐレビュー" },
          "style": "primary",
          "url": "{mr_url}",
          "action_id": "review_now"
        },
        {
          "type": "button",
          "text": { "type": "plain_text", "text": "レビュワー変更" },
          "action_id": "reassign_reviewer:{mr_id}"
        }
      ]
    }
  ]
}

4. ボタンアクション設計

Slack アクション(Block Kit Interactive)

action_id処理内容レスポンス
view_issueGitLab Issueページへ遷移URLリンク(処理なし)
start_reviewGitLab MRページへ遷移URLリンク(処理なし)
snooze_review:{mr_id}レビューリマインダーを6時間後に再送エフェメラルメッセージで確認
request_extension:{issue_id}期限延長モーダルを表示モーダル(延長日数 + 理由入力)
pause_issues:{project_id}プロジェクトのIssue公開を一時停止確認ダイアログ → 実行結果通知
reassign_reviewer:{mr_id}レビュワー変更モーダルを表示モーダル(新レビュワー選択)

Discord アクション(Button Interaction)

custom_id処理内容レスポンス
issue_apply:{issue_id}Issue着手申請エフェメラルメッセージで申請確認
view_issue:{issue_id}Issue詳細表示エフェメラル Embed で詳細表示

5. Slash コマンド

Slack

コマンド説明レスポンス
/outsource-status担当Issueの進捗一覧エフェメラルメッセージ
/outsource-reviews未レビューMR一覧エフェメラルメッセージ
/outsource-budget {project}プロジェクト予算状況エフェメラルメッセージ

Discord

コマンド説明レスポンス
/issues公開中Issue一覧Embed リスト
/my-issues自分の着手中Issue一覧エフェメラル Embed
/rewards報酬履歴エフェメラル Embed

6. 技術実装詳細

Slack Bot 実装

python
# slack_bot.py (概要)
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler

app = App(token=os.environ["SLACK_BOT_TOKEN"])

@app.event("app_mention")
async def handle_mention(event, say):
    """Bot メンション時のハンドラ"""
    pass

@app.action("snooze_review")
async def handle_snooze(ack, body, client):
    """レビュースヌーズアクション"""
    await ack()
    mr_id = body["actions"][0]["action_id"].split(":")[1]
    # 6時間後にリマインダー再送をスケジュール
    await schedule_reminder(mr_id, delay_hours=6)
    await client.chat_postEphemeral(
        channel=body["channel"]["id"],
        user=body["user"]["id"],
        text="6時間後にリマインダーを再送します。"
    )

@app.action("request_extension")
async def handle_extension_request(ack, body, client):
    """期限延長リクエストモーダル表示"""
    await ack()
    issue_id = body["actions"][0]["action_id"].split(":")[1]
    await client.views_open(
        trigger_id=body["trigger_id"],
        view=build_extension_modal(issue_id)
    )

Discord Bot 実装

python
# discord_bot.py (概要)
import discord
from discord import app_commands

class OutsourceBot(discord.Client):
    def __init__(self):
        intents = discord.Intents.default()
        intents.message_content = True
        super().__init__(intents=intents)
        self.tree = app_commands.CommandTree(self)

    async def on_interaction(self, interaction: discord.Interaction):
        if interaction.type == discord.InteractionType.component:
            custom_id = interaction.data["custom_id"]
            if custom_id.startswith("issue_apply:"):
                issue_id = custom_id.split(":")[1]
                await handle_issue_apply(interaction, issue_id)

@bot.tree.command(name="issues", description="公開中のIssue一覧を表示")
async def list_issues(interaction: discord.Interaction):
    issues = await fetch_open_issues()
    embed = build_issues_embed(issues)
    await interaction.response.send_message(embed=embed)

共通メッセージフォーマッター

python
# notification_formatter.py
class NotificationFormatter:
    """チャネル別メッセージフォーマッター"""

    def format(self, trigger_type: str, payload: dict, channel: str) -> dict:
        template = self._get_template(trigger_type, channel)
        return self._render(template, payload)

    def _get_template(self, trigger_type: str, channel: str) -> dict:
        """テンプレートDBまたはYAMLから取得"""
        pass

    def _render(self, template: dict, payload: dict) -> dict:
        """プレースホルダーを実データで置換"""
        pass

7. エラーハンドリング

エラー種別対処
Slack API レート制限 (429)Exponential backoff で再送
Discord API レート制限discord.py 内蔵のレート制限ハンドラに委任
チャネル未参加チャネル参加を試行、失敗時はDM送信にフォールバック
ユーザー不在(退出済み)PF内通知 + メールにフォールバック
メッセージ送信失敗リトライキューに投入(最大3回)
Bot トークン無効Critical アラートを管理者メールに送信