打ち首こくまろ

限界オタクの最終処分場

ルーテさんbot改造計画(前編)

こちらは ルーテさんアドベントカレンダー 6日目の記事です。

4年半前に作ったルーテさんbotですが、最近ロクなメンテナンスや機能追加などもせず、セリフを吐き続けるだけのネットデブリと化していました... 本当に申し訳ないです。

このbottwittbotで動かしていたのですが、このサービスも最近機能追加がなく、また柔軟性も無いため、自作のbotに移行したいなぁとずっと思っていました。そこで、今回のアドベントカレンダーを機に、思い切って移行したいと思います。

前編は、とりあえずtwittbotで行っていた「セリフのツイート」「自動フォロー返し」と独自の占い機能の実装、後編はクラウド上へのデプロイと若干の機能追加、という流れで行きたいと思います。

簡単なプログラミングの知識があれば、以下の記事の流れをなぞるだけで簡単にbotっぽいものが作れる... はず。

使う言語・ライブラリ

botといえばnode.js+hubotが有名ですが、個人的にあんまりnode.jsが好きじゃないので、使い慣れたPython+twitterライブラリで行きたいと思います。

Pythontwitterライブラリにはtweepyもありますが、こちらはちょっとゴタゴタがあるのと、あと最近開発が進んでいないみたいなので見送りました。

事前準備

ライブラリのインストール

pipコマンドを使ってtwitterライブラリをインストールします。

$ pip install python-twitter

アクセストークンの取得

プログラムからTwitterAPIにアクセスするわけなのですが、それに必要なトークンを取得しておきます。

アプリの登録

ルーテさんbotのアカウントでhttps://apps.twitter.com/にアクセスし、「Create New App」をクリックします。ここで自分の作るアプリを登録するわけです。

f:id:realizemoon:20171206213919p:plain

上記のような画面が出てくるので適当に入力(名称は昔あったtwitterクライアント「ラーメン大陸」を意識しています)。

アクセストークンの生成

f:id:realizemoon:20171206214752p:plain

上記のような画面が出てくるので、「manage keys and access tokens」をクリック。

f:id:realizemoon:20171206215006p:plain

一番下の「create my access token」をクリックすると、access tokenが生成されるのでメモしておきます。

画面上に出てきたconsumer_keyとaccess_tokenは、流出すると悪用される恐れがあるため厳重に管理しましょう。

動かしてみる

取得した各種トークンを使って、Twitterで呟く簡単なスクリプトを作ってみます。

# -*- conding: utf-8 -*-                                                         

import twitter
from datetime import datetime

# 取得した各種トークンを使って、
# Apiにアクセスするオブジェクトを生成
api = twitter.Api(consumer_key=“XXXXXX”,
                  consumer_secret=“XXXXXX”,
                  access_token_key=“XXXXXX”,
                  access_token_secret=“XXXXXX”)

api.PostUpdate("こんばんは。これはテスト投稿です。")

f:id:realizemoon:20171206215441p:plain

いけた!!

これを少し改良して、今までのbotのようにセリフの中からランダムで呟くスクリプトを作成します。

# -*- coding: utf-8 -*-                                                         

import api 
import random

def main():
    with open('dialogs.txt', 'r') as f:
        dialogs = f.readlines()

    dialog = random.choice(dialogs)
    a = api.create_api()
    a.PostUpdate(dialog)

main()

'dialogs.txt'の中にルーテさんのセリフを一行ごとに書いておいて、スクリプトの中で読み込み、ランダムな一行を選んでつぶやきます。

f:id:realizemoon:20171206215858p:plain

二回連続でスクリプトを実行したところ。うまくいっていそうです。

ストリームを取得する

botの機能には、自発的に呟くのに加え、何かしらのイベントに対して実行する受動的な機能があります。例えば、フォローを受けてフォロー返しする機能。

TwitterAPIを使って、そのようなイベントを取得することができます。

# -*- coding: utf-8 -*-                                                                                                            

import api 
import random

def main():
    a = api.create_api()
    stream = a.GetUserStream()
    for s in stream:
        print(s)

main()

GetUserStream()でイベントを取得するストリームを生成、その下のfor文でストリームからイベントが出てくるたびにイベントを出力するようにしています。

このスクリプトを実行した状態でルーテさんbotをフォローし、どんなイベントが流れてくるか見てみます。

{'source': {'translator_type': 'none', 'is_translation_enabled': False, 'id_str': '930593122333073408', 'default_profile': False, 'profile_sidebar_fill_color': '000000', 'followers_count': 37, 'description': 'ファイアーエムブレムの財布\nアイドルマスター/グリモア/音ゲー', 'profile_sidebar_border_color': '000000', 'profile_text_color': '000000', 'url': None, 'screen_name': 'malo_yaka', 'verified': False, 'profile_background_tile': False, 'friends_count': 70, 'name': 'まろみ🐥', 'protected': False, 'profile_background_image_url': 'http://abs.twimg.com/images/themes/theme1/bg.png', 'utc_offset': -28800, 'default_profile_image': False, 'notifications': None, 'favourites_count': 334, 'created_at': 'Wed Nov 15 00:27:44 +0000 2017', 'profile_image_url_https': 'https://pbs.twimg.com/profile_images/936961594029780992/vI2CWXiS_normal.jpg', 'profile_image_url': 'http://pbs.twimg.com/profile_images/936961594029780992/vI2CWXiS_normal.jpg', 'geo_enabled': False, 'profile_background_image_url_https': 'https://abs.twimg.com/images/themes/theme1/bg.png', 'follow_request_sent': None, 'listed_count': 1, 'profile_banner_url': 'https://pbs.twimg.com/profile_banners/930593122333073408/1511252242', 'profile_background_color': '000000', 'statuses_count': 1024, 'contributors_enabled': False, 'time_zone': 'Pacific Time (US & Canada)', 'following': None, 'id': 930593122333073408, 'profile_use_background_image': False, 'location': 'にほん', 'is_translator': False, 'profile_link_color': '981CEB', 'lang': 'ja'}, 'created_at': 'Tue Dec 05 12:30:04 +0000 2017', 'event': 'follow', 'target': {'translator_type': 'none', 'is_translation_enabled': False, 'id_str': '1550736276', 'default_profile': True, 'profile_sidebar_fill_color': 'DDEEF6', 'followers_count': 61, 'description': '『ファイアーエムブレム 聖魔の光石』に登場する魔道士、ルーテの非公式botです。原作には無かったセリフもつぶやくのでご注意を。ただ今試験運用中なので、不備などありましたらすみません。ご意見・ご要望・ネタ提供などあれば@malo_yakaまで。', 'profile_sidebar_border_color': 'C0DEED', 'profile_text_color': '333333', 'url': None, 'screen_name': 'fe_lute_bot', 'verified': False, 'profile_background_tile': False, 'friends_count': 69, 'name': 'ルーテ(工事中)', 'protected': False, 'profile_background_image_url': 'http://abs.twimg.com/images/themes/theme1/bg.png', 'utc_offset': None, 'default_profile_image': False, 'notifications': None, 'favourites_count': 0, 'created_at': 'Thu Jun 27 14:32:29 +0000 2013', 'profile_image_url_https': 'https://pbs.twimg.com/profile_images/378800000068234163/93385047b96c811d056014d81a4302f5_normal.png', 'profile_image_url': 'http://pbs.twimg.com/profile_images/378800000068234163/93385047b96c811d056014d81a4302f5_normal.png', 'geo_enabled': False, 'profile_background_image_url_https': 'https://abs.twimg.com/images/themes/theme1/bg.png', 'follow_request_sent': None, 'listed_count': 3, 'profile_background_color': 'C0DEED', 'statuses_count': 32331, 'contributors_enabled': False, 'time_zone': None, 'following': None, 'id': 1550736276, 'profile_use_background_image': True, 'location': 'ルネス', 'is_translator': False, 'profile_link_color': '1DA1F2', 'lang': 'ja'}}

ウッ...

ちゃんとしたオブジェクトではなく、大量のデータが入ったディクショナリが流れてくるようです。

# -*- coding: utf-8 -*-                                                                                                            

import api 
import random
from pprint import pprint

def main():
    a = api.create_api()
    stream = a.GetUserStream()
    for s in stream:
        pprint(s)

main()

printではなくpprintを使って整形。

{'created_at': 'Tue Dec 05 12:35:50 +0000 2017',
 'event': 'follow',
 'source': {'contributors_enabled': False,
            'created_at': 'Wed Nov 15 00:27:44 +0000 2017',
            'default_profile': False,
            'default_profile_image': False,
            'description': 'ファイアーエムブレムの財布\nアイドルマスター/グリモア/音ゲー',
            'favourites_count': 334,
            'follow_request_sent': None,
            'followers_count': 37,
            'following': None,
            'friends_count': 70,
            'geo_enabled': False,
            'id': 930593122333073408,
            'id_str': '930593122333073408',
            'is_translation_enabled': False,
            'is_translator': False,
            'lang': 'ja',
            'listed_count': 1,
            'location': 'にほん',
            'name': 'まろみ🐥',
            'notifications': None,
            'profile_background_color': '000000',
            'profile_background_image_url': 'http://abs.twimg.com/images/themes/theme1/bg.png',
            'profile_background_image_url_https': 'https://abs.twimg.com/images/themes/theme1/bg.png',
            'profile_background_tile': False,
            'profile_banner_url': 'https://pbs.twimg.com/profile_banners/930593122333073408/1511252242',
            'profile_image_url': 'http://pbs.twimg.com/profile_images/936961594029780992/vI2CWXiS_normal.jpg',
            'profile_image_url_https': 'https://pbs.twimg.com/profile_images/936961594029780992/vI2CWXiS_normal.jpg',
            'profile_link_color': '981CEB',
            'profile_sidebar_border_color': '000000',
            'profile_sidebar_fill_color': '000000',
            'profile_text_color': '000000',
            'profile_use_background_image': False,
            'protected': False,
            'screen_name': 'malo_yaka',
            'statuses_count': 1026,
            'time_zone': 'Pacific Time (US & Canada)',
            'translator_type': 'none',
            'url': None,
            'utc_offset': -28800,
            'verified': False},
 'target': {'contributors_enabled': False,
            'created_at': 'Thu Jun 27 14:32:29 +0000 2013',
            'default_profile': True,
            'default_profile_image': False,
            'description': '『ファイアーエムブレム '
                           '聖魔の光石』に登場する魔道士、ルーテの非公式botです。原作には無かったセリフもつぶやくのでご注意を。ただ今試験運用中なので、不備などありましたらすみません。ご意見・ご要望・ネタ提供などあれば@malo_yakaまで。',
            'favourites_count': 0,
            'follow_request_sent': None,
            'followers_count': 61,
            'following': None,
            'friends_count': 69,
            'geo_enabled': False,
            'id': 1550736276,
            'id_str': '1550736276',
            'is_translation_enabled': False,
            'is_translator': False,
            'lang': 'ja',
            'listed_count': 3,
            'location': 'ルネス',
            'name': 'ルーテ(工事中)',
            'notifications': None,
            'profile_background_color': 'C0DEED',
            'profile_background_image_url': 'http://abs.twimg.com/images/themes/theme1/bg.png',
            'profile_background_image_url_https': 'https://abs.twimg.com/images/themes/theme1/bg.png',
            'profile_background_tile': False,
            'profile_image_url': 'http://pbs.twimg.com/profile_images/378800000068234163/93385047b96c811d056014d81a4302f5_normal.png',
            'profile_image_url_https': 'https://pbs.twimg.com/profile_images/378800000068234163/93385047b96c811d056014d81a4302f5_normal.png',
            'profile_link_color': '1DA1F2',
            'profile_sidebar_border_color': 'C0DEED',
            'profile_sidebar_fill_color': 'DDEEF6',
            'profile_text_color': '333333',
            'profile_use_background_image': True,
            'protected': False,
            'screen_name': 'fe_lute_bot',
            'statuses_count': 32331,
            'time_zone': None,
            'translator_type': 'none',
            'url': None,
            'utc_offset': None,
            'verified': False}}

見やすくなった。

頭の方の‘event’を見ればイベントの内容が判別できるみたいです。

# -*- coding: utf-8 -*-                                                                                                            

import api 
import random
from pprint import pprint

def main():
    a = api.create_api()
    stream = a.GetUserStream()
    for s in stream:
        pprint(s)
        if 'event' not in s:
            continue
        event = s['event']
        if event == 'follow':
            a.CreateFriendship(user_id=s['source']['id'])

main()

これでフォロー返しに成功、しかしまるで刺し違えるようにbotも死んでしまった。

これはフォロー返しした時にもこちらに通知が来たせいで、自分に対してフォローするような誤作動を起こしてしまったせい。以下のようにするとうまくいった。

# -*- coding: utf-8 -*-

import api
import random
import re

from pprint import pprint

def main():
    # 自分のID
    my_id = None

    a = api.create_api()
    stream = a.GetUserStream()

    for s in stream:
        pprint(s)

        # フォロー通知
        if 'event' in s and s['event'] == 'follow':
            # 自分発のフォローでなければ
            if my_id != s['source']['id']:
                a.CreateFriendship(user_id=s['source']['id'])
                # ここで自分のIDを記憶しておく(もっといい方法があるはず...)
                my_id = s['target']['id']
        else:
            continue

main()

コメントに書いてる通りですが、もっとスマートな方法があるはず...

リプライに反応する

リプライを飛ばしてみてイベントを見てみる。

{'contributors': None,
 'coordinates': None,
 'created_at': 'Wed Dec 06 11:19:07 +0000 2017',
 'entities': {'hashtags': [],
              'symbols': [],
              'urls': [],
              'user_mentions': [{'id': 1550736276,
                                 'id_str': '1550736276',
                                 'indices': [0, 12],
                                 'name': 'ルーテ(工事中)',
                                 'screen_name': 'fe_lute_bot'}]},
 'favorite_count': 0,
 'favorited': False,
 'filter_level': 'low',
 'geo': None,
 'id': 938367194253049856,
 'id_str': '938367194253049856',
 'in_reply_to_screen_name': 'fe_lute_bot',
 'in_reply_to_status_id': None,
 'in_reply_to_status_id_str': None,
 'in_reply_to_user_id': 1550736276,
 'in_reply_to_user_id_str': '1550736276',
 'is_quote_status': False,
 'lang': 'ja',
 'place': None,
 'quote_count': 0,
 'reply_count': 0,
 'retweet_count': 0,
 'retweeted': False,
 'source': '<a href="http://twitter.com/download/iphone" '
           'rel="nofollow">Twitter for iPhone</a>',
 'text': '@fe_lute_bot こんにちは',
 'timestamp_ms': '1512559147786',
 'truncated': False,
 'user': {'contributors_enabled': False,
          'created_at': 'Wed Nov 15 00:27:44 +0000 2017',
          'default_profile': False,
          'default_profile_image': False,
          'description': 'ファイアーエムブレムの財布\nアイドルマスター/グリモア/音ゲー',
          'favourites_count': 338,
          'follow_request_sent': None,
          'followers_count': 38,
          'following': None,
          'friends_count': 72,
          'geo_enabled': False,
          'id': 930593122333073408,
          'id_str': '930593122333073408',
          'is_translator': False,
          'lang': 'ja',
          'listed_count': 1,
          'location': 'にほん',
          'name': 'まろみ🐥',
          'notifications': None,
          'profile_background_color': '000000',
          'profile_background_image_url': 'http://abs.twimg.com/images/themes/theme1/bg.png',
          'profile_background_image_url_https': 'https://abs.twimg.com/images/themes/theme1/bg.png',
          'profile_background_tile': False,
          'profile_banner_url': 'https://pbs.twimg.com/profile_banners/930593122333073408/1511252242',
          'profile_image_url': 'http://pbs.twimg.com/profile_images/936961594029780992/vI2CWXiS_normal.jpg',
          'profile_image_url_https': 'https://pbs.twimg.com/profile_images/936961594029780992/vI2CWXiS_normal.jpg',
          'profile_link_color': '981CEB',
          'profile_sidebar_border_color': '000000',
          'profile_sidebar_fill_color': '000000',
          'profile_text_color': '000000',
          'profile_use_background_image': False,
          'protected': False,
          'screen_name': 'malo_yaka',
          'statuses_count': 1087,
          'time_zone': 'Pacific Time (US & Canada)',
          'translator_type': 'none',
          'url': None,
          'utc_offset': -28800,
          'verified': False}}

フォローした時と形式が全然違う...

公式ドキュメントを見ると、ディクショナリの中にin_reply_to_screen_name等があればリプライと判別できるらしい。クソAPIすぎる...

とりあえず、リプライを取得できることが分かったので、「占って」とリプライを飛ばすとルーテさんが占ってくれる機能を実装してみる。

# -*- coding: utf-8 -*-                                                                              

import api 
import random
import re

from pprint import pprint

from apps import fortune

def main():
    # 自分のID
    my_id = None
    
    a = api.create_api()
    stream = a.GetUserStream()

    for s in stream:
        pprint(s)

        # リプライ
        if 'in_reply_to_user_id' in s:
            if my_id != s['user']['id']:
                # 占い機能
                if "占って" in s['text']: 
                    a.PostUpdate("@{0} {1}".format(
                        s['user']['screen_name'],
                        fortune.get_fortune()
                    ))  
                    my_id = s['in_reply_to_user_id']
        # フォロー通知
        elif 'event' in s and s['event'] == 'follow':
            # 自分発のフォローでなければ
            if my_id != s['source']['id']:
                a.CreateFriendship(user_id=s['source']['id'])
                # ここで自分のIDを記憶しておく(もっといい方法があるはず...)
                my_id = s['target']['id']
        else:
            continue

main()
# -*- coding: utf-8 -*-                                                                              

import random
import os

def num2kanji(num):
    KNUM = [u"", u"一", u"二", u"三", u"四", u"五", 
            u"六", u"七", u"八", u"九"]
    DIGIT1 = (u"", u"十", u"百", u"千")

    str_num = str(num)
    knum = []
    sn = str_num[-1:-5:-1]
    for j, n in enumerate(map(int, sn)):
        if n != 0:
            knum.append(DIGIT1[j])
            if not(n == 1 and j): 
                knum.append(KNUM[n])
    knum.reverse()
    return "".join(knum).rstrip()

def get_fortune():
    m = random.randint(2, 9999)
    max_str = num2kanji(m)
    fortune = num2kanji(random.randint(0, m)) 
    ans = 'あなたの運勢は{0}段階中の{1}段階目です。'.format(max_str, fortune)

    # luckyitem.txtの中からランダムでラッキーアイテムをチョイスする
    base = os.path.dirname(os.path.abspath(__file__))
    path = os.path.normpath(os.path.join(base, './luckyitem.txt'))
    with open(path, 'r') as f:
        lucky = f.readlines()
    ans += 'ラッキーアイテムは{0}です。'.format(random.choice(lucky)[:-1])
    return ans 

f:id:realizemoon:20171206223207p:plain

やったぜ。ちょっとテキストは充実させたい。

後半に続きます。