😘

【DiscordBotをつくってみよう】BacklogAPIで課題を追加する

2024/11/08に公開

この記事では、DiscordのチャットからBacklogにタスクを追加できるBotの作り方について紹介していきます。

こんな方におすすめ

  • DiscordBotをつくってみたい
  • Discordを社内チャットとして運用している
  • Backlogを利用している
  • Pythonで社内ツールをつくってみたい
    ※Backlogは有料のサービスであり、契約していない場合には利用できません。また、APIキーなどの取得には管理者権限が必要になるため、管理者に確認をとってから始めましょう。

完成イメージ

このDiscord Botを導入すると、「/task」コマンドでBacklogにタスクの追加を行うことができます。これにより、いつでもどこでもBacklogを開かずに作業できます。

プロジェクトや担当者の設定はこんな風に、セレクトメニューから選べます。

入力中にミスがあった場合には、途中でやっぱりやめることもできます。

大まかな手順

(0)Backlogの管理者権限を取得する。
(1)この記事を参考にPythonスクリプトを実装する。
(2)Discord.devにアクセスし、「Applications」から「New Appilication」を選択し、好きな名前をつけ、APIキーを取得。スコープは「bot」「applications.commands」「Send Messages」「Use Slash Commands」あたりを選んでください(私も詳しくないので、過不足があったらすみません)。
(3)Pythonコードを走らせる環境を整え、デプロイする(HerokuRenderなど)。

※(2)の情報は取り扱いにご注意ください。

レポジトリ解説

プロジェクトは以下のファイルによって構成されています。BacklogのAPIに送信するデータには、必須のものと選択可能なものがあります。必要に応じカスタマイズしてみてください。

Discord Botのソースコード

  • main.py
  • .env

Backlogのデータ

  • projectkeys.json
  • assignees.json
  • issuetypeids.json

レポジトリはこちらです。
https://github.com/EngineerCafeJP/Backlog-bot

内容は以下で詳しく説明します。

Discord Botのソースコード

main.py

こちらはメインのPythonコードです。
送信フォームは、タイトル・概要はテキスト入力で、プロジェクト・期限・担当者は選択式です。送信する内容はカスタマイズできますので、詳しくは公式のAPIライブラリをご覧ください。
https://developer.nulab.com/ja/docs/backlog/

import discord
import requests
import os
import json
from datetime import datetime, timedelta
from discord.ext import commands
from discord import app_commands
from discord.ui import Select, View, Button
from dotenv import load_dotenv

load_dotenv()

# APIキーやエンドポイントなどの設定
backlog_api_key = os.getenv("BACKLOG_APIKEY")
discord_api_key = os.getenv("DISCORD_APIKEY")
space_id = os.getenv("SPACE_ID")
endpoint = f"https://{space_id}.backlog.com/api/v2/issues"

with open("assignees.json", "r") as file:
    assignees = json.load(file)

with open("projectkey.json", "r") as file:
    projects = json.load(file)

with open("issuetypeids.json", "r") as file:
    issue_type_ids = json.load(file)

intents = discord.Intents.default()
intents.message_content = True

bot = commands.Bot(command_prefix="!", intents=intents)


@bot.tree.command(name="task", description="Backlogに新しい課題を作成します")
async def create_task(interaction: discord.Interaction, title: str, description: str):

    task_data = {"title": title, "description": description}

    # プロジェクトのセレクトメニューを作成
    select_project = Select(
        placeholder="プロジェクトを選んでください",
        options=[
            discord.SelectOption(label=name, value=id) for name, id in projects.items()
        ],
    )

    async def select_project_callback(project_interaction: discord.Interaction):
        selected_project = select_project.values[0]
        # project_idと課題種別を辞書に追加
        task_data["projectId"] = selected_project

        # projectkeyに該当する課題種別を取得して登録
        task_data["issueTypeId"] = issue_type_ids[selected_project]

        # メニューを無効化し、メッセージを更新
        select_project.disabled = True
        await project_interaction.response.edit_message(
            content="プロジェクトが設定されました", view=project_view
        )

        # 担当者のセレクトメニューを作成
        select_assignee = Select(
            placeholder="担当者を選んでください",
            options=[
                discord.SelectOption(label=name, value=id)
                for name, id in assignees.items()
            ],
        )

        async def select_assignee_callback(assignee_interaction: discord.Interaction):
            selected_assignee = select_assignee.values[0]

            # 担当者IDを辞書に追加
            task_data["assigneeId"] = selected_assignee

            # メニューを無効化し、メッセージを更新
            select_assignee.disabled = True
            await assignee_interaction.response.edit_message(
                content="担当者が設定されました", view=assignee_view
            )

            # ここから期限日のセレクトメニューを追加
            # 現在の日付から20日後までのリストを作成(Discordの制限により最大25)
            today = datetime.now().date()
            date_options = [today + timedelta(days=i) for i in range(0, 20)]

            # 「設定しない」オプションを追加
            deadline_options = [
                discord.SelectOption(label="設定しない", value="no_deadline")
            ] + [
                discord.SelectOption(
                    label=date.strftime("%Y-%m-%d"), value=date.strftime("%Y-%m-%d")
                )
                for date in date_options
            ]

            # 選択肢が25を超えないように調整(20日分なので問題なし)
            select_deadline = Select(
                placeholder="期限日を選んでください",
                min_values=1,
                max_values=1,
                options=deadline_options,
            )

            async def select_deadline_callback(
                deadline_interaction: discord.Interaction,
            ):
                selected_deadline = select_deadline.values[0]

                if selected_deadline == "no_deadline":
                    # 期限日を設定しない場合
                    task_data["dueDate"] = None  # または必要に応じてフィールドを削除
                else:
                    # 期限日を辞書に追加
                    task_data["dueDate"] = selected_deadline

                # メニューを無効化し、メッセージを更新
                select_deadline.disabled = True
                await deadline_interaction.response.edit_message(
                    content="期限日が設定されました", view=deadline_view
                )

                # 確認ボタンを作成
                confirm_button = Button(
                    label="はい!", style=discord.ButtonStyle.success
                )
                cancel_button = Button(
                    label="やっぱやめる", style=discord.ButtonStyle.danger
                )

                # 確認ボタンのコールバック
                async def confirm_callback(confirm_interaction: discord.Interaction):
                    # メニューを削除して、「登録しておきます!」と表示
                    await confirm_interaction.response.edit_message(
                        content="登録しておきます!", view=None
                    )

                    # Backlog APIにタスクを送信
                    payload = {
                        "projectId": task_data["projectId"],
                        "summary": task_data["title"],
                        "issueTypeId": task_data["issueTypeId"],
                        "priorityId": "3",  # 優先度中で固定
                        "description": task_data["description"],
                        "assigneeId": task_data["assigneeId"],
                    }

                    if task_data["dueDate"]:
                        payload["dueDate"] = task_data["dueDate"]

                    response = requests.post(
                        endpoint,
                        params={"apiKey": backlog_api_key},
                        data=payload,
                        headers={"Content-Type": "application/x-www-form-urlencoded"},
                    )

                    if response.status_code in (200, 201):
                        issue = response.json()
                        issue_url = (
                            f"https://{space_id}.backlog.com/view/{issue['issueKey']}"
                        )
                        assignee_label = "不明"
                        project_label = "不明"

                        # 担当者ラベルの取得
                        for option in select_assignee.options:
                            if option.value == selected_assignee:
                                assignee_label = option.label
                                break

                        # プロジェクトラベルの取得
                        for option in select_project.options:
                            if option.value == selected_project:
                                project_label = option.label
                                break

                        due_date_display = (
                            task_data["dueDate"] if task_data["dueDate"] else "設定なし"
                        )

                        await confirm_interaction.followup.send(
                            content=(
                                f"🎉 課題が無事に登録されました!\n"
                                f"課題の件名: {issue['summary']}\n"
                                f"担当者: {assignee_label}\n"
                                f"プロジェクト: {project_label}\n"
                                f"期限日: {due_date_display}\n"  # 期限日を表示
                                f"確認する: {issue_url}"
                            ),
                            ephemeral=False,  # メッセージを送信したユーザー以外にも表示
                        )
                    else:
                        await confirm_interaction.followup.send(
                            content=(
                                f" 課題の登録に失敗しました...またチャレンジしてね! ({response.status_code})"
                            ),
                            ephemeral=True,
                        )

                # キャンセルボタンのコールバック
                async def cancel_callback(cancel_interaction: discord.Interaction):
                    await cancel_interaction.response.send_message(
                        "キャンセルしました。最初からやり直してください。",
                        ephemeral=True,
                    )

                confirm_button.callback = confirm_callback
                cancel_button.callback = cancel_callback

                # ボタンを表示するためのViewを作成
                confirm_view = View()
                confirm_view.add_item(confirm_button)
                confirm_view.add_item(cancel_button)

                await deadline_interaction.followup.send(
                    "登録しますか?", view=confirm_view, ephemeral=True
                )

            select_deadline.callback = select_deadline_callback
            deadline_view = View()
            deadline_view.add_item(select_deadline)

            await assignee_interaction.followup.send(
                "期限日を選んでください:", view=deadline_view, ephemeral=True
            )

        select_assignee.callback = select_assignee_callback
        assignee_view = View()
        assignee_view.add_item(select_assignee)

        await project_interaction.followup.send(
            "担当者を選んでください:", view=assignee_view, ephemeral=True
        )

    select_project.callback = select_project_callback
    project_view = View()
    project_view.add_item(select_project)

    await interaction.response.send_message(
        "プロジェクトを選んでください:", view=project_view, ephemeral=True
    )


# Botが起動したときの処理
@bot.event
async def on_ready():
    print(f"Logged in as {bot.user} (ID: {bot.user.id})")
    try:
        synced = await bot.tree.sync()
        print(f"Synced {len(synced)} command(s)")
    except Exception as e:
        print(e)


# Discord-Botの起動
bot.run(discord_api_key)

.env

いわゆる環境変数を格納するファイルです。ここには、3種類を記述します。

  • DiscordのAPIキー:Discord.devで作成したBotアプリのAPIキーです。
  • BacklogのAPIキー:管理者権限がある人が取得できます。個人設定>APIから取得してください。
  • SPACE_ID:Backlogに設定している組織名です。「/FUKUOKA.backlog.com」のFUKUOKAに当たる部分です。

Backlogに送信するデータ

BacklogAPIの送信パラメータには、Backlogが発行するプロジェクトIDや担当者IDが必要です(プロジェクト名や担当者名を直接入力して指定することはできません)。そのため、事前にBacklogからデータを収集します。
以下では、プロジェクトID、担当者ID、課題種別IDを自動的に読み込んで取得するスクリプトを紹介します。

projectkeys.json(プロジェクト名とプロジェクトキー)

プロジェクトキーの一覧を保存します。プロジェクトキーについてはこちらを参考にしてください。
https://backlog.com/ja/enterprise-help/userguide/userguide355/
取得には以下のスクリプトを参考にしてみてください。

import requests
import os
from dotenv import load_dotenv

# .envファイルに保存した環境変数を取得
load_dotenv()

# APIキーとエンドポイントの設定
backlog_api_key = os.getenv("BACKLOG_APIKEY")
space_id = os.getenv("SPACE_ID")
endpoint = f"https://{space_id}.backlog.com/api/v2/projects"

# プロジェクト一覧を取得
response = requests.get(endpoint, params={"apiKey": backlog_api_key})

if response.status_code == 200:
    projects = response.json()
    for project in projects:
        print(f"Project Name: {project['name']}, Project ID: {project['id']}")
else:
    print(f"Failed to retrieve projects. Status code: {response.status_code}")

実行すると、

Project Name: FUKUOKATOWER, Project ID: 00123

のようにプロジェクト名とIDの列が表示されます。

※ご紹介したmain.pyでは、projectkeys.jsonに保存して使います。

{
    "FUKUOKATOWER": "00123",
    "OHORIPARK": "00226"
}

assignees.json(担当者)

Backlogでは、担当者を担当者IDで管理しています。
以下は、任意のプロジェクトの担当者IDを取得するスクリプトです。
※ご紹介したmain.pyでは、assignees.jsonに保存して使います。

backlog_api_key = os.getenv("BACKLOG_APIKEY")
space_id = os.getenv("SPACE_ID")
project_id = "Projectkey"  # プロジェクトキーを入力してください
endpoint = f"https://{space_id}.backlog.com/api/v2/projects/{project_id}/users"

response = requests.get(endpoint, params={"apiKey": backlog_api_key})

if response.status_code == 200:
    users = response.json()
    for user in users:
        print(f"Name: {user['name']}, ID: {user['id']}")
else:
    print(f"Failed to retrieve users: {response.status_code} {response.text}")

issuetypeids.json(課題種別)

Backlogの課題には、種別が割り振られています。
https://support-ja.backlog.com/hc/ja/articles/360036146413-種別の概要

Backlogの仕様上「タスク」「バグ」など共通の種別でもプロジェクト毎に種別IDが異なります。
ご紹介したmain.pyでは、種別「タスク」で登録します。

以下、プロジェクト毎の「タスク」の種別IDを自動的に取得するスクリプトを紹介します。
※ご紹介したmain.pyでは、issuetypeids.jsonに保存して使います。

import requests
import os
from dotenv import load_dotenv

# .envファイルに保存した環境変数を取得
load_dotenv()

# APIキーとエンドポイントの設定
backlog_api_key = os.getenv("BACKLOG_APIKEY")
space_id = os.getenv("SPACE_ID")

# プロジェクトキーを保存したファイルから読み込む
project_keys_file = "projectkey.json"

# ファイルからプロジェクトキーを読み込む
with open(project_keys_file, "r") as file:
    project_keys = file.readlines()

# プロジェクトキーごとにissuetype keyを取得
for project_key in project_keys:
    project_key = project_key.strip()  # 改行や空白を除去
    endpoint = f"https://{space_id}.backlog.com/api/v2/projects/{project_key}/issueTypes"

    # 課題タイプ一覧を取得
    response = requests.get(endpoint, params={"apiKey": backlog_api_key})

    if response.status_code == 200:
        issue_types = response.json()
        print(f"Project Key: {project_key}")
        for issue_type in issue_types:
            print(f"  Issue Type: {issue_type['name']}, Issue Type ID: {issue_type['id']}")
    else:
        print(f"Failed to retrieve issue types for Project Key: {project_key}. Status code: {response.status_code}")

デプロイ

デプロイのやり方は人それぞれだと思います。まだどこのwebサーバーにも課金していない場合には、まずはrender.comなど無料のサーバーを利用してみることをおすすめします。もちろん、経験やリソースがある方なら、自力でサーバーを立ててみるのもよいと思います。

最後に

今回の記事では、Nulab社製タスク管理ツールBacklogとDiscordの連携について紹介してみました。必要経費とは言うものの、無料のDiscordと比べ、チャットツールへの出費は決して小さくありません。スタートアップやコミュニティなど、少ないながらもユースケースは存在すると思います。公式の連携機能にDiscordが含まれていない、と思ったあなたの役に立てれば幸いです。もし、改善点やご意見などあれば、お気軽にお声かけください。

Discussion