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 Manifest | Slack App 設定で管理 |
| デプロイ | バックエンドと同一プロセス or 独立 Worker |
Discord Bot
| 項目 | 詳細 |
|---|---|
| フレームワーク | discord.py (v2.x) |
| 接続方式 | Gateway (WebSocket) + REST API |
| Intents | guilds, 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-general | Issue着手、承認・報酬確定 | PM全員 |
#issue-outsource-reviews | MR提出、レビュー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_issue | GitLab Issueページへ遷移 | URLリンク(処理なし) |
start_review | GitLab 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:
"""プレースホルダーを実データで置換"""
pass7. エラーハンドリング
| エラー種別 | 対処 |
|---|---|
| Slack API レート制限 (429) | Exponential backoff で再送 |
| Discord API レート制限 | discord.py 内蔵のレート制限ハンドラに委任 |
| チャネル未参加 | チャネル参加を試行、失敗時はDM送信にフォールバック |
| ユーザー不在(退出済み) | PF内通知 + メールにフォールバック |
| メッセージ送信失敗 | リトライキューに投入(最大3回) |
| Bot トークン無効 | Critical アラートを管理者メールに送信 |