🐸

【初心者向け】Discord.pyを使って社内ツールを実装してみる#01(Backlogの課題キーをリンクにする)

2024/08/07に公開

BacklogとDisordを連携する

Nulab社が提供する"Typetalk"のサービス終了が発表されたことをきっかけに、弊社ではDiscordへの移行を行いました。

Nulab社が提供する"Backlog"は、同社が提供する"Typetalk"との連携がすばらしく、非常に快適でした。例えば、Backlogの課題キーをリンクにする機能がついていました。

Backlogの課題キーとは、こういうやつです🐈

CAT-222 猫に餌をやる

厳密には、"CAT-222"の部分ですが、コピペするボタンをクリックすると{課題キー+件名}でコピーされるので、ここでの定義は件名までを含むものとします。

当然といえば当然ですが、Discordにはこの機能がついていません。

そこで今回は、この「課題キーをリンクにする」機能を持ったbotを作成していきます。ただ、元のメッセージをbotが編集することはDiscordのポリシー的に禁じられているので、botが元のメッセージの「中身」を編集して新しいメッセージを送信し、元のメッセージを削除するということをします。

📌アイコンがbotのアイコンになってしまうのでわかりにくい場合は、webhookを使うと成りすます 本人が送信したように見せることもできます。ただし、webhookはチャンネル依存のため、保守性の観点からおすすめはしません。

まずは最低限のbotから

複雑な処理を含める前に、まずは基礎的なbotを作成します。

Discord.devでの操作やinstallationは生成AIに聞けば教えてくれるので、省略します。

discord.pyを使ったdiscord-botのつくり方を初心者にもわかるように教えて!

みたいな感じで聞いてみると教えてくれます。いちおう、intentの設定をONにするところだけ忘れないようにしてください(なぜかデフォルトではOFFになっています)。

以下が基本的なbotの枠組みになります。誰かが"hello!"と送ったら"goodbye!"と送るビートルズかぶれのbotです。

# main.py
import discord
import os
from dotenv import load_dotenv

load_dotenv()

# このあたりはほとんど定型文みたいなもの
intents = discord.Intents.default()
intents.message_content = True
client = discord.Client(intents=intents)

# ログインしたらログ出力する
@client.event
async def on_ready():
    print("Bot is logged in and ready!")

# メッセージを受信したときの対応
@client.event
async def on_message(message):
    if message.content == "hello!":
        await message.channel.send("goodbye!")

TOKEN = os.getenv("DISCORD_TOKEN")
client.run(TOKEN)

ソースにTOKENを直に貼るのはよくないので、.envファイルに保存します(後にwebserverに上げた際に事故るのを防ぐため)。

# .env
DISCORD_TOKEN = "ここにボットのトークンを保存"

課題キーを分解する

課題キーは、

CAT-222 猫に餌をやる

狭義の課題キー+件名で構成されているとお話しました。しかし、実はもっと細かくすることもできます。そう、"CAT"はプロジェクトキーです。

課題キー(広義)は、プロジェクトキー+"-"+数字+任意の文字列ととらえることができます。プロジェクトキーで検索し、さらにプロジェクトキーらしく文字列の後に"-"や数字が来ることを指定すれば、かなりエラーは起こりにくくなります。

プロジェクトキーを保存する

検索ワードとして利用するプロジェクトキーは、あらかじめ手動で保存しておく必要があります。そこだけちょっと不便ですが、仕方ない部分ではあると思います(APIで取得できるのかも?)。

以下の手順で、プロジェクトキーをcsvに保存します。
(1)Backlogのダッシュボードで、組織のプロジェクトをすべてコピペ
(2)main.pyや.envを保存したのと同じディレクトリにproject_keys.csvを作成
(3)頭にproject_keysと書いて、その下にプロジェクトキーを列挙(下図参照)

project_keys
CAT
DOG
BIRD
SNAKE
DINOSAUR

csv = コンマ区切りですが、vscodeで表示すると1列のデータは","なしで表示してくれました(だから何ということでもない)。

プロジェクトキーを読み込む

プロジェクトキーで検索するために、csvファイルから読み込む関数を定義します。

def load_project_keys(csv_file):
    PROJECTKEYS = pd.read_csv(csv_file)
    return PROJECTKEYS["project_name"].tolist()

プロジェクトキーを検知してリンクを生成

メッセージが来たときの処理を記述します。全体が見えたほうがわかりやすいので、全体をお見せします。

@client.event 
async def on_message(message):
    # bot自身が送信したメッセージには反応しない
    if message.author == client.user:
        return
    # 万が一元のメッセージに型崩れしたmarkdownがあった場合の暴走を止める
    if "linkgenerator" in message.content:
        return
    # 暴走したときに"$stop"で強制ログアウト
    if message.content == "$stop":
        await message.channel.send("Bot is logging out...")
        await client.close()
        return

    new_content = message.content  # 元メッセージをリサイクル
    sender = message.author.display_name  # メッセージ送信者のサーバー表示名を取得

    # 各プロジェクトキーを検知してリンクを生成
    for project_key in project_keys:

        pattern = re.compile(
            rf"({project_key}-\d+)\s+(.+?)(?=\s|$)"
        )  # 課題キー+件名のフォーマット
        new_content = pattern.sub(
            lambda m: f"[{m.group(0)}](https://YOUR_ORGNIZATION_NAME_HERE.backlog.com/view/{m.group(1)})",
            new_content,
        ) # 課題キーが存在する位置を基準として、"m.group"で前後を取り扱っています。

    # リンクを生成する箇所があった場合(元のメッセージに変更があった場合)、新しいメッセージを送信して元のメッセージを削除
    if new_content != message.content:
        reply_content = f"{new_content}\nedited by linkgenerator as **{sender}**"
        await message.delete()
        await message.channel.send(reply_content)

分解してみると、一つ一つの処理はそこまで複雑ではないことがわかると思います。正規表現に関する部分などは、すでにさまざまなサイトで紹介されていますので、「Python 正規表現」などと検索してみるとよいかもしれません。

特筆すべき点があるとすれば、この部分でしょうか。

    # 万が一元のメッセージに型崩れしたmarkdownがあった場合の暴走を止める
    if "linkgenerator" in message.content:
        return
    if new_content != message.content:
        reply_content = f"{new_content}\nedited by linkgenerator as **{sender}**"

botが送信するメッセージには、"linkgenerator"が送信したメッセージであること、元の送信者が"sender"であることが書かれています。

さきほど、Discordでは成りすましが禁止されていると述べましたが、ポリシーの問題だけでなく、誰が送信したかわからないと会話にならないので(笑)、署名は重要になります。

また、ついでにbotが送信した情報であることを明示することで、万が一botが自分のメッセージに反応してしまった場合のトラブル(永遠にリンク化の形式を吐き続ける)を防ぐことができます。

もちろん、


    if message.author == client.user:
        return

をやっているので、自分自身のメッセージに反応することは基本的にないはずですが、誰かがメッセージをそのままコピーして貼った場合など、念には念を入れた措置です。

全部見たいでしょ?

いいから全部見せてよ!というあなたのために、ソース全文を貼っておきます。importするライブラリ系はめんどくさくて 書いていないですしね。

一番下のコードをコピペして、ご自分の環境で実行してみてください。「discord.py」、「requests」、「pandas」、「python-dotenv」をpip installするのと、.envにトークンを記述するのも忘れずに。

※たぶん大丈夫ですが、動作は保証いたしかねます。

# main.py
import discord
import re
import requests
import pandas as pd
from dotenv import load_dotenv
import os

load_dotenv()
discord_token = os.getenv("TOKEN")

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

# CSVファイルからプロジェクトキーを読み込む関数
def load_project_keys(csv_file):
    df = pd.read_csv(csv_file)
    return df["project_key"].tolist()

project_keys = load_project_keys("project_keys.csv")

@client.event
async def on_ready():
    print(f"logged in as {client.user}")

@client.event
async def on_message(message):
    if message.author == client.user:
        return

    if "linkgenerator" in message.content:
        return

    if message.content == "$stop":
        await message.channel.send("Bot is logging out...")
        await client.close()
        return

    new_content = message.content  
    sender = message.author.display_name
    avatar_url = message.author.avatar.url if message.author.avatar else None

    for project_key in project_keys:
        pattern = re.compile(
            rf"({project_key}-\d+)\s+(.+?)(?=\s|$)"
        )  
        new_content = pattern.sub(
            lambda m: f"[{m.group(0)}](https://YOUR_ORGANIZATION_NAME.backlog.com/view/{m.group(1)})",
            new_content,
        )

    if new_content != message.content:
        reply_content = f"{new_content}\nedited by linkgenerator as **{sender}**"

        # response = requests.post(webhook_url, json=data)
        # if response.status_code != 204:
        #     print(f"Error sending message through webhook: {response.status_code}, {response.text}")

        await message.delete()
        await message.channel.send(reply_content)

client.run(discord_token)

おわりに

今回ご紹介したソースには、問題がある場合もあります。うまくいかないよ!こうしたらもっとよくなるよ!というアドバイスがあれば、どしどしお寄せください。

みなさまのお役に立てていれば幸いです。

Discussion