涼しい風

Discord.pyでdiscordbotを作っていた人のブログです。開発のお役に立てれば幸いです。2019/11/10を持って更新は終了しました。

Discord.pyで簡単なグローバルチャットを作成

Discord公式APIのラッパー、Discord.py を利用して、グローバルチャットを作成してみます。

グローバルチャットとは?

理解が合っているのかも不明ですが、例えばABCの3つのDiscordサーバーがあったとき、Aサーバーに投稿するとその内容がBサーバー、Cサーバーに転送され、Bサーバーに投稿すれば内容はAサーバーとCサーバーに・・・というものです。

一部のDiscordサーバーで行われているようですが、そこまで有名ではないかもしれない(自分も最近まで知らなかった)

そこまで難しくなさそうなので実装してみましょう。

実装

基本

見栄え的に、アイコンや名前を変えられるWebhookを使って実装することが多いようですが、まずはbotアカウントで送信させてみます。

import discord

client = discord.Client()

@client.event
async def on_ready():
    client.global_list = []#グローバルチャット参加チャンネルのリスト

@client.event
async def on_message(message):

    if message.author == message.guild.me:
        return

    if message.content == "!global":
        if message.channel not in client.global_list:
            client.global_list.append(message.channel)
            await message.channel.send("グローバルチャットのチャンネルに登録しました。")
        else:
            await message.channel.send("既に登録されています。")
        return

    embed = discord.Embed(title=f"サーバー: {message.guild.name}",description=message.clean_content )
    embed.set_author(name=message.author.name,icon_url=message.author.avatar_url)

    for ch in client.global_list:
        if message.channel != ch: 
            await ch.send(embed=embed)

client.run("token")

f:id:coolwind0202:20190930161434p:plain
実行結果

Webhook使用版

Webhookを使えばアイコンとかを変えられます。

import discord

client = discord.Client()

@client.event
async def on_ready():
   client.global_list = []

@client.event
async def on_message(message):

    if message.author == message.guild.me:
        return

    if message.webhook_id:
        return

    global_tmp = [w for w in await message.channel.webhooks() if w in client.global_list]

    if message.content == "!global":
        if global_tmp:
            await message.channel.send("既に登録されています。")
            return

        new_w = await message.channel.create_webhook(name="global")
        client.global_list.append(new_w)
        await message.channel.send("グローバルチャットのチャンネルに登録しました。")
        return

    for webhook in client.global_list:
        if message.channel != webhook.channel:
            await webhook.send(content=message.content,username=message.author.name,avatar_url=message.author.avatar_url)

client.run(token)

f:id:coolwind0202:20190930162837p:plain
実行例
f:id:coolwind0202:20190930162837p:plain
実行例

応用

特定の名前のチャンネルを作成することでその名前からグローバルチャット用のチャンネルを検索できるようにします。

import discord

client = discord.Client()

@client.event
async def on_ready():
    client.global_list = [] #グローバルチャット参加チャンネルのリスト
    for guild in client.guilds:
        tmp = discord.utils.get(guild.text_channels,name="global_test")
        if tmp: client.global_list.append(tmp)

@client.event
async def on_message(message):

    if message.author == message.guild.me:
        return

    if message.content == "!global":
        if discord.utils.get(message.guild.text_channels,name="global_test"):
            await message.channel.send("既に参加しています。")
            return
        ch = await message.guild.create_text_channel("global_test")
        client.global_list.append(ch)
        return

    embed = discord.Embed(title=f"サーバー: {message.guild.name}",description=message.clean_content )
    embed.set_author(name=message.author.name,icon_url=message.author.avatar_url)

    if message.channel in client.global_list:
        for ch in client.global_list:
            if message.guild != ch.guild:
                await ch.send(embed=embed)

client.run('token')

起動時にglobal_testチャンネルがあればそれを対象に追加します。 !globalと送信されたら、global_testチャンネルを作成しそれを対象に追加します。

おわり

この記事ではよくあるグローバルチャットの機能を全て実装できているわけではないと思います。 それでも作成の第一歩の一助となれば幸いです。

listenでイベント発生を受け取る

Discord公式APIのラッパー、Discord.py とそのフレームワークBot Commands Framework で使用できる「listen」を紹介します。

listenとは

Listenはdiscord.ext.commands.Botクラスに含まれるデコレータです。

例えば、botのいるチャンネルでのメッセージを拾う場合は、

import discord
from discord.ext import commands

bot = commands.Bot(command_prefix="/")
token=""

@bot.event
async def on_message(message):
    print(message.content)

bot.run(token)

と書く事ができますが、これをlistenデコレータを使うと、

@bot.listen("on_message")
async def listen_test(message):
    print(message.content)

bot.run(token)

全然変わってない?むしろめんどくさい?

listenデコレータなら、同じイベントを受け取りたいとき両方受け取ることができます。

on_messageを例に説明すると、

@bot.event
async def on_message(message):
    print(message.content)

@bot.event
async def on_message(message):
    await message.channel.send(message.content)

と書いたとき、どう実行されるのかというと一番下のon_messageが優先して実行されますよね。 普通はこれを繋げて書く事で処理を実現します。

でもlistenなら、

@bot.listen('on_message')
async def on_message_1(message):
    print(message.content)

@bot.listen('on_message')
async def on_message_2(message):
    if message.content=="!ping":
        await message.channel.send("pong!")

任意の関数名を使用できるので、複数の関数が同じイベントの発生を受け取ることができるのです。

aiohttpでGoogleスプレッドシートを直接操作

Discord公式APIのラッパー、Discord.py と、GoogleスプレッドシートAPIGoogle sheets APIを使って、Googleスプレッドシートを操作してみます。

概説

Pythonには、Google sheets APIを簡単に操作できるライブラリ、gspread_asyncioが存在します。 これを使えばAPIを直接叩くまでもなくスプレッドシートを読み書きできます。

coolwind0202.hatenadiary.jp

しかし、この記事を書いた当時、aiohttpで通信した方がコマンド処理を遮断せずに済むのでは?と思っていたのですが、実際はほとんど遮断していませんでした。 なのでもうこの記事の存在意義は無に等しいのですが、一応残しておくことにします。

Google sheets APIの仕様

GoogleスプレッドシートAPI、即ちGoogle sheets APIは、利用する際に操作に対する認可が行われている必要があります。

gspreadであれば、Googleサービスアカウントの作成時についてくるJSONファイルから認証情報を作成し、それで操作の認可を得ることができます。

しかし、直接叩く場合は、「Oauth2.0クライアントID」を作成した上で、適宜認証、認可を行います。

console.cloud.google.com Google cloud platformで操作を行います。

qiita.com 方法はここで紹介されているので省略します。ただし、7番の「アプリケーションの種類」はWebアプリケーションを選択し、また「認証済みのリダイレクトURI」はhttp://localhost/oauth2callbackと入力して保存してください。 f:id:coolwind0202:20190929144936p:plain

認可コード、リフレッシュトークンの発行

ここからは、操作に必要な認可コードを発行します。

先のGoogle cloud platformで入手したクライアントIDを使用します。

https://accounts.google.com/o/oauth2/auth?response_type=code&client_id=[クライアントID]&redirect_uri=http://localhost/oauth2callback&scope=https://www.googleapis.com/auth/spreadsheets&access_type=offline&approval_prompt=force

このURLにブラウザなどでアクセスしてください。

f:id:coolwind0202:20190929150805p:plain
ログイン画面(ユーザー情報部分は加工)

このような画面が表示されるので、Googleにログインしてください。

f:id:coolwind0202:20190929150919p:plain もし警告が表示されたら、下の詳細をクリックし、[アプリ名](安全ではないページ)に移動をクリックします。

f:id:coolwind0202:20190929151121p:plain
許可の確認画面(ユーザー情報部分を加工)
f:id:coolwind0202:20190929160519p:plain
確認画面2
f:id:coolwind0202:20190929160609p:plain
許可画面3
指示通り許可をクリックします。

f:id:coolwind0202:20190929151341p:plain ここが超重要で、URL欄の&code=以下が認可コードになります。(&scope=以下は別物です) この認可コードを控えておきます


認可コードは、数分経つと有効期限が切れてしまうのですが、リフレッシュトークンを使えば何年も使い続けることが可能になっています。 今度はそのリフレッシュトークンを生成します。

'''
>>Discord botのコードではありません!<<
>>python環境で別途実行してください!<<
'''

import requests
import json

URL = "https://www.googleapis.com/oauth2/v4/"

code = "" #認可コードをここに
client_id = "" #クライアントIDをここに
client_secret = "" #クライアントシークレットをここに

payload = { 'code': code, 'client_id': client_id, 'client_secret': client_secret, 'redirect_uri': 'http://localhost/oauth2callback', 'grant_type': 'authorization_code' }

r = requests.post(URL,data=json.dumps(payload))
print(r.json())

あくまで雛型なので、IDやシークレットを引用符内に書き込んでください。postmanとかある人はそちらでも全然問題無いです。

実行すると、以下のJSONが返ってきます。

{
  "access_token": "[アクセストークン]",
  "expires_in": 3600,
  "refresh_token": "[リフレッシュトークン]",
  "scope": "https://www.googleapis.com/auth/spreadsheets",
  "token_type": "Bearer"
}

このうち、[リフレッシュトークン]内の文字列は絶対に保持しておいてください

もしBad requestなどが返ってきた場合、認可コード生成からやり直します。有効期限の関係で、なるはやで。

実装

ようやくbot実装にありつけます。

import discord
import aiohttp
import asyncio

client = discord.Client()

@client.event
async def on_ready():

    client.gs_refresh_token = "" #リフレッシュトークンを入力
    client.gs_id = "" #クライアントIDを入力
    client.gs_secret = "" #クライアントシークレットを入力
    client.gs_uri = "http://localhost/oauth2callback" #リダイレクトURIを入力
    client.gs_scope = "https://www.googleapis.com/auth/spreadsheets"

    client.session = aiohttp.ClientSession()
    client.token_task = client.loop.create_task(regetToken())    

async def regetToken():

    await client.wait_until_ready()
    

    TOKEN_URL = "https://www.googleapis.com/oauth2/v4/token"

    data = { 'client_id': client.gs_id, 'client_secret': client.gs_secret, 'grant_type': 'refresh_token',
             'refresh_token': client.gs_refresh_token }
    
    while not client.is_closed():
        async with client.session.post(TOKEN_URL,data=data) as resp:
            resp_json = await resp.json()

        print(f"アクセストークン生成の試行結果:\n{resp}")
        client.access_token = resp_json['access_token']
        
        await asyncio.sleep(3600)

@client.event
async def on_message(message):

    if message.content == "!spread":
        SPREAD_ID = "" #スプレッドシートのIDを入力
        SHEET_URL = f"https://sheets.googleapis.com/v4/spreadsheets/{SPREAD_ID}:batchUpdate"
        

        r = {
            "requests": [
                {
                    "updateSpreadsheetProperties": {
                        "properties": {
                            "title": "タイトル"
                        },
                        "fields": "title"
                    }
                }]
            }

        headers = { 'Authorization': f"Bearer {client.access_token}" }
  
        async with client.session.post(SHEET_URL,json=r,headers=headers) as resp:
            print(f"編集の試行結果:\n{resp}")

            if resp.status!=200:
                await message.channel.send("編集に失敗しました。")
            else:
                await message.channel.send("編集に成功しました!")

client.run(token)

f:id:coolwind0202:20190929165514p:plain
実行例

ここまでの過程をミスなく通過できていれば、この画像のようにシートのタイトルが変更されているはずです。

終わり

この記事では、Google sheets APIを直接叩いてみました。

今回は例と言うことでタイトルのみですが、スプレッドシートAPIにはほかにも様々なオプションが存在するので試してみてください。

最後にAPIのリファレンスを置いておきます。読むの慣れるのこの準備以上に時間かかるかもしれない・・・

developers.google.com


参考サイト

docomoのRepl-AIをDiscord botと連携しようとした

Discord公式APIDiscord.py と、docomoの提供するチャットボット用API「Repl-AI」を連携して雑談botを作ろうと、した・・・のですが、雑談APIの無料提供が既に終了していたため、一応連携の方法を共有しておこうと思います。

GUIによって簡単にチャットの想定するシナリオを作成できるので、コマンドを使うbotはともかく、単純なチャットボットを作るにはもってこいのサービスだと思うんですねこれ。

f:id:coolwind0202:20190926223207p:plain
作成画面のイメージ

repl-ai.jp

この記事では、登録やシナリオの作成については説明せず、Discord botで利用する方法に焦点を当てます。

repl-ai.jp

こちらのページをご覧ください。

実装

Repl-AIの良いところは、シナリオを作成できることです。 対話するユーザーのユーザーIDを保持しておくことで、各ユーザーそれぞれの対話状況に応じたメッセージを送信することができます。

ここでは、DMチャンネルに問い合わせがあったときに特定の返信を行うコードを書いてみます。

import discord
import aiohttp

client = discord.Client()
token = ""

@client.event
async def on_ready():
    client.mes_list = {}

@client.event
async def on_message(message):

    if message.author.bot:
        return

    API_KEY = "" #APIキーをここに
    BOT_ID = "" #botのIDをここに
    TOPIC_ID = "" #使用したいシナリオのIDをここに

    if isinstance(message.channel,discord.DMChannel):

        headers = { 'Content-Type': 'application/json' , 'x-api-key': API_KEY }

        if str(message.author.id) not in client.mes_list.keys():
            await getuserid(message.author.id, API_KEY, BOT_ID)

        async with aiohttp.ClientSession(headers=headers) as session:
            user_id = client.mes_list[str(message.author.id)]

            data = { 'appUserId': user_id, 'botId': BOT_ID, 'voiceText': message.content, 'initTalkingFlag': True, 'initTopicId': TOPIC_ID }

            async with session.post( 'https://api.repl-ai.jp/v1/dialogue', json=data ) as r:
                raw_data = await r.json()

        if 'systemText' not in raw_data.keys():
            await message.channel.send('エラーが発生')
            return
            
        await message.channel.send( raw_data['systemText']['expression'] )

async def getuserid(author_id, API_KEY, BOT_ID):
    data_get = { 'botId': BOT_ID }
    headers = { 'Content-Type': 'application/json', 'x-api-key': API_KEY }


    async with aiohttp.ClientSession(headers=headers) as session:      
        async with session.post( 'https://api.repl-ai.jp/v1/registration', json=data_get ) as r:
            r_json = await r.json()

            id = r_json['appUserId']
                
            client.mes_list[str(author_id)] = id

client.run(token)

使い方次第でいい感じに色んな返答をしてくれるbotにできると思います。

ユーザーIDを分別せずに毎回同じ反応をさせる場合には、initTalkingFlagをFalseにすれば良いです。 毎回セッションを作成するのはどうなのか?とも思いましたが動作に支障は無いです。

Discord.pyのWebhook操作 [作成、編集、発言…]

Discord公式APIのラッパー、Discord.py でサーバーのWebhookを設定する方法を紹介します。

Webhookという仕組みをご存知でしょうか。この記事を読んでいるからには恐らく周知の事実かと思いますので、簡単に言うと、webサービスwebサービスとを繋げて、簡単に通知を設定できる仕組みです。

先に取得の方法を説明しておきましょう、サーバー内に作成済のWebhookは以下のように取得します。

@client.event
async def on_message(message):
    print(await message.guild.webhooks()) # サーバー内のwebhookのリストが表示される

しかしこのawait guild.webhooks()、驚くべきことに、認証サーバーに設置されているAnnouncement ChannelsをFollowすると追加されるWebhookがサーバー内に存在していると例外を吐く!悲しい!

作成

まずは「作成」してみましょう。なお、これはTextChannelに対してのみ行える操作です。

import discord

client = discord.Client()
token = ""

@client.event
async def on_message(message):

    webhooks = await message.guild.webhooks() # 既存のwebhookの取得
    
    if not webhooks:
        await message.channel.send("Webhookがないので作成します。")
        try:
            await message.channel.create_webhook(name="Webuhukku")
        except:
            await message.channel.send("Webhookの作成に失敗しました。")
        else:
            await message.channel.send("ついに初作成に成功しました・・・")
    else:
         await message.channel.send("既に作成されています。")
client.run(token)

メッセージが送信されたとき、もしサーバーにWebhookがなければ新規作成するコードです。

編集

お次は編集です。

import discord

client = discord.Client()
token = ""

@client.event
async def on_message(message):

    webhooks = await message.guild.webhooks() # 既存のwebhookの取得
    
    if not webhooks:
        await message.channel.send("Webhookがないので作成します。")
        try:
            await message.channel.create_webhook(name="Webuhukku")
        except:
            await message.channel.send("Webhookの作成に失敗しました。")
        else:
            await message.channel.send("ついに初作成に成功しました・・・")
    else:
         webhook = webhooks[0]
         await webhook.edit(name="名前")
         await message.channel.send("テキスト")

client.run(token)

もしチャンネル内にwebhookがあれば、そのうち1つの名前を変更するコードです。(悪質・・・) await webhook.edit()が肝。

発言

botで投稿できるのでわざわざこうする意味は無いんですけどね

import discord

client = discord.Client()
token = ""

@client.event
async def on_message(message):
    webhooks = await message.guild.webhooks() # 既存のwebhookの取得
    
    if not webhooks:
        await message.channel.send("Webhookがないので作成します。")
        try:
            await message.channel.create_webhook(name="Webuhukku")
        except:
            await message.channel.send("Webhookの作成に失敗しました。")
        else:
            await message.channel.send("ついに初作成に成功しました・・・")
    
     if message.content == "/say":
         webhook = webhooks[0]
         await webhook.send("テキスト,username="peach")

client.run(token)

on_messageにこれを分岐無しで書いてて無限ループに陥って小一時間格闘してたのは内緒です。

リクルートのA3RT Talk APIでbotと喋る

Discord公式APIのラッパー、Discord.py と、リクルート社が配信しているAPIA3RT に含まれるTalkAPIを利用して、雑談botを作成してみます。

なお、A3RTの利用にはメールアドレスが必要になります。

a3rt.recruit-tech.co.jp

画面下の「APIキー発行」を押すとメールアドレスの登録を求められるはずなので、利用登録を終えたら、改めて「APIキーを発行」してください。登録したアドレスにAPIキーが送信されています。

実装

Discord.pyにおいては、Discord側との接続が途切れるといけないので、aiohttpの使用が推奨されています。

また、APIに対してpostすることでjsonデータが返ってきます。

import discord

client = discord.Client()
token = ""

@client.event
async def on_message(message):

    if message.content.startswith("/t"):
        msg = message.content.split(" ")[1]

        payload = { "apikey" : "APIキーをここに" ,"query": msg }

        async with aiohttp.ClientSession() as session:
            async with session.post('https://api.a3rt.recruit-tech.co.jp/talk/v1/smalltalk',data=payload) as resp:
                script = await resp.json()

        await message.channel.send(script["results"][0]["reply"])

client.run(token)

f:id:coolwind0202:20190923001432p:plain
実行例

何故か全く意思疎通が出来ませんでした、普通の会話ならちゃんと応答してくれますw

原因は何なんでしょうね・・・

もしかして:シャミ子

[Discord.py]livedoor-LWWS でお天気botを作成する

Discord公式APIのラッパー、Discord.py および、livedoor天気情報で配信されている お天気Webサービス仕様 - Weather Hacks - livedoor 天気情報を利用して、天気予報や現在の天気を通知するbotを作成してみましょう。

なお、コード例ではBot Commands Frameworkを使用しています。 また、Discord.pyのWebリクエストにおいては、通信を切断しないためにaiohttpを使用することが推奨されています。

livedoor天気情報のお天気Webサービスについて

livedoor天気情報のお天気Webサービス(lwws)は、URLを直接リクエストすることでJSONデータを取得することができます。

パラメータには地域情報を示すIDを参照するようです。


まずはIDの取得を行っておきましょう。1次細分区定義表 - livedoor 天気情報から抜き出せそうです。

こちらで確認した限りでは、<city title="地域名" id="ID" source="rssのURL">という形式のようなので、正規表現でそのまま取ってきましょうか。

async with aiohttp.ClientSession() as session:
        async with session.get('http://weather.livedoor.com/forecast/rss/primary_area.xml') as resp:
            script = await resp.text()

    bot.area_id = re.findall(r'<city title="(.*)" id="(.*)" .*"', script)

bot.area_idは、('地域名','id')というタプルのリストになっています。 タプルのリストからIDを抜き出すために、area_search = [t[1] for i,t in enumerate(bot.area_id) if t[0] == area] というように書きます。

実装

実際に書いていきます。

なお、lwwsが返すJSONデータの形式は、記事冒頭のリンクにありますのでご査収ください。

import discord
from discord.ext import commands

bot = commands.Bot(command_prefix='!')
token = "" #ローカルなら直書きでも大丈夫です

@bot.event
async def on_ready():
    async with aiohttp.ClientSession() as session:
            async with session.get('http://weather.livedoor.com/forecast/rss/primary_area.xml') as resp:
                script = await resp.text()
    bot.area_id = re.findall(r'<city title="(.*)" id="(.*)" .*"', script) #IDと地域のリスト

@bot.command()
async def weather(ctx,area):

    area_search = [t[1] for i,t in enumerate(bot.area_id) if t[0] == area] # 地域名(t[0])を検索

    if not area_search:
        await ctx.send("その地域名は見つかりませんでした。")
        #地域の検索結果が空だったので処理を終了
        return

    base_url = "http://weather.livedoor.com/forecast/webservice/json/v1?city="

    async with aiohttp.ClientSession() as session:
        async with session.get(base_url+area_search[0]) as resp:
            script = await resp.json() #結果をjsonデータに変換
    
    weather_embed = discord.Embed(title=area+" のお天気 / "+script["publicTime"]+" 発表 ") # 埋め込みの作成
    weather_embed.description = script["description"]["text"] # 説明を概況に

    for i in script["forecasts"]:
        # 各日に対する処理

        if i["temperature"]["max"]:
            max = "最高気温:" + i["temperature"]["max"]["celsius"] +"℃ "
            min = "最低気温:" + i["temperature"]["min"]["celsius"] +"℃"
        else:
            max = "最高気温:未発表 "
            min = "最低気温:未発表"

        weather_embed.add_field(name=i["dateLabel"], value="**"+i["telop"]+"**\n"+max+min,
                                inline=False)

    copy = script["copyright"]

    weather_embed.set_footer(text=copy["provider"][0]["name"]+"/"+copy["provider"][0]["link"])
    weather_embed.set_author(name=copy["image"]["title"]+"/"+copy["title"],
                             icon_url=copy["image"]["url"])

    if script["forecasts"][0]["telop"] == "晴れ":
        weather_embed.color = 0xff7f00 # 装飾
    elif script["forecasts"][0]["telop"] == "雨":
        weather_embed.color = 0x7f7fff
    else:
        weather_embed.color = 0xaed8ef

    await ctx.send(embed=weather_embed) # 送信!!!!

bot.run(token)

f:id:coolwind0202:20190922230820p:plain
実行例

カッコが多いせいでなんだかすごく複雑に見えますが、実際にはデータを一つ一つ埋め込みに設定しているだけです。

for文を使っている部分は、jsonのforecasts以下が配列になっていたためこのようにしました。わからなくてもコピペで動くようになってます。