なか日記

一度きりの人生、楽しく生きよう。

JavaでTDDっぽく簡単なボットを作ってみる

@graighleさんのこの一言で、ちょっと作ってみようと思い試してみました。
@akinekoさん、ネタにしてごめんねw

Twitterに関する操作は「Twitter4J - A Java library for the Twitter API」を使わせていただきました。

注意事項

  • ボットがどうあるべきかはよく知りませんので、何じゃこりゃっていう設計でも大目に見て下やってさい。
  • ツッコミどころ満載なのは承知の上で公開します。*1
  • どういう設計にすればよかったのか、意見をもらえると嬉しいなぁ

方針

  • 指定された人のツイートに任意の文字列を付けてつぶやく*2
  • @が付いているツイートについてはつぶやかない*3
  • 常駐型にして1分間隔で指定された人のツイートをチェック、前回チェック以降のツイートをつぶやく

クラスの役割

Akineko
  • コンストラクタでツイートに付与する文字列を指定する
  • 文字列もしくは文字列のリストを受け取ってつぶやく
  • @が付いているツイートについてはつぶやかない(())
AkinekoController
  • @akinekoのツイートを取得する
  • 最新のステータスIDを管理する
  • 前回取得時からの差分のみ、Akinekoにつぶやくよう指示する

事前確認

事前にTwitter4jの使い方をテストクラスを作成して確認しておきます。

Twitter4jTest

Twitter4jの動作を確認するためのテストクラスです。
なので、TDDというよりは動作結果を目で見て確認するためのものになりますが、こういう使い方もありよね?

package com.nakaji.AkinekoBotTest;

import java.text.SimpleDateFormat;
import java.util.*;
import org.junit.*;

import twitter4j.*;
import static org.junit.Assert.*;

public class Twitter4jTest {
	private static String twitterID = "nakaji_test";
	private static String twitterPassword = "********";
	private static Twitter twitter;

	@BeforeClass
	public static void BeforeClass() {
		// このファクトリインスタンスは再利用可能でスレッドセーフです
		twitter = new TwitterFactory().getInstance(twitterID, twitterPassword);
	}

	@Test
	public void タイムラインの取得() throws TwitterException {
		// @akineko のタイムラインを取得する
		List<Status> statuses = twitter.getUserTimeline("akineko");

		// 取得結果を表示
		System.out.println("Showing timeline.");
		for (Status status : statuses) {
			System.out.println(String.valueOf(status.getId()) + ":" + status.getUser().getName() + ":"
					+ new SimpleDateFormat("yyyy/MM/dd HH:mm:ss").format(status.getCreatedAt()) + ":"
					+ status.getText());
		}
	}

	@Test
	public void ステータスの更新() throws TwitterException {
		// 同じメッセージを送信出来ないので、タイムスタンプを付与している
		Date now = new Date();
		Status status = twitter.updateStatus("Twitter4jの確認" + (new SimpleDateFormat("yyyy/MM/dd HH:mm:ss").format(now)));

		// 成功したら、結果がStatusとして返ってくる
		System.out.println("Successfully updated the status to [" + status.getText() + "].");
	}

	@Test
	public void タイムライン取得時のステータスが0件の場合() throws TwitterException {
		Paging paging = new Paging();
		// 99999999999より大きいIDを指定することで、結果が0件になるようにしてる
		paging.setSinceId(99999999999l);

		List<Status> statuses = twitter.getUserTimeline("nakaji_test", paging);
		assertTrue(statuses.isEmpty());
	}
}

Akinekoの作成

AkinekoTest

Akinekoのテストクラス。
ツイートする文字列を生成するakineko.sayと、実際にTwitterへ投稿するAkineko.tweetのテストを行っています。

package com.nakaji.AkinekoBotTest;

import java.text.SimpleDateFormat;
import java.util.*;
import org.junit.*;
import twitter4j.*;
import static org.junit.Assert.*;
import static org.hamcrest.CoreMatchers.*;
import com.nakaji.AkinekoBot.*;

public class AkinekoTest {

	private static Akineko akineko;
	private static String keyWord = "(性的な意味で)";
	private static String twitterID = "nakaji_test";
	private static String twitterPassword = "*******";

	@BeforeClass
	public static void initialize() {
		Twitter twitter = new TwitterFactory().getInstance(twitterID, twitterPassword);
		akineko = new Akineko(twitter, keyWord);
	}

	@Test
	public void 空文字列では何もしない() {
		assertThat(akineko.say(""), is(""));
	}

	@Test
	public void 最後に性的な意味でを付ける() {
		assertThat(akineko.say("Akinexつくるお"), is("Akinexつくるお" + keyWord));
	}

	@Test
	public void アットマーク付きの場合はなしもしない() {
		assertThat(akineko.say("@hoge ボソッ"), is(""));
		assertThat(akineko.say(". @hoge @moge ボソッ"), is(""));
	}

	@Test
	public void ツイート() throws TwitterException {
		Date now = new Date();
		String time = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss").format(now);
		assertThat(akineko.tweet("Maidroidつくるお!" + time).getText(), is("Maidroidつくるお!" + time + keyWord));
		assertNull(akineko.tweet("@hogehoge Maidroidつくるお!" + time));
	}

	@Test
	public void 連続ツイート() throws TwitterException {
		Date now = new Date();
		String time = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss").format(now);

		List<String> messages = new ArrayList<String>();
		messages.add("テストデータ1 " + time);
		messages.add("テストデータ2 " + time);
		messages.add("@hoge テストデータ3 " + time);	//@が付いているのでツイートされない
		messages.add("テストデータ4 " + time);

		List<Status> results = akineko.tweet(messages);

		assertThat(results.get(0).getText(), is("テストデータ1 " + time + keyWord));
		assertThat(results.get(1).getText(), is("テストデータ2 " + time + keyWord));
		assertThat(results.get(2).getText(), is("テストデータ4 " + time + keyWord));
	}
}
Akineko

sayメソッドは公開したくないけど、テストのためにpublicに…

package com.nakaji.AkinekoBot;

import java.util.*;
import java.util.regex.Pattern;
import twitter4j.*;

public class Akineko {
	private String keyWord;
	private Twitter twitter;

	public Akineko(Twitter twitter, String hentaiWord) {
		this.keyWord = hentaiWord;
		this.twitter = twitter;
	}

	public String say(String message) {
		// RTやST、@は相手に迷惑だから黙っとく
		if ((message.length() == 0) || (Pattern.compile("^@[a-z|A-Z]+.*|.* @[a-z|A-Z]+.*").matcher(message).matches()))
			return "";

		return message + keyWord;
	}

	public Status tweet(String message) throws TwitterException {
		String tweet = this.say(message);
		if (tweet.isEmpty())
			return null;

		return twitter.updateStatus(tweet);
	}

	public List<Status> tweet(List<String> messages) throws TwitterException {
		List<Status> results = new ArrayList<Status>();

		for (String message : messages) {
			Status result = this.tweet(message);

			if (result != null)
				results.add(result);
		}
		return results;
	}
}

AkinekoControllerの作成

AkinekoControllerTest

AkinekoControllerのテストクラス。
Akinekoに対してツイートするように指示をするけど、ここではTwitterに投稿させたくない*4ので、Akinekoを継承したAkinekoDummyを使用しています。

package com.nakaji.AkinekoBotTest;

import java.util.*;
import org.junit.*;
import twitter4j.*;
import static org.junit.Assert.*;
import static org.hamcrest.CoreMatchers.*;
import com.nakaji.AkinekoBot.*;

//Twitterへの投稿はAkinekoでテストできているのでここでは行わない
public class AkinekoControllerTest {

	private static Twitter twitter;
	private static AkinekoDummy akineko;
	private static AkinekoController contoller;
	private static String twitterID = "nakaji_test";
	private static String twitterPassword = "*******";

	@BeforeClass
	public static void BeforeSetup() throws TwitterException {
		twitter = new TwitterFactory().getInstance(twitterID, twitterPassword);
		akineko = new AkinekoDummy(twitter, "性的な意味で");
		contoller = new AkinekoController(akineko, twitter, "nakaji");
	}

	@Test
	public void 最新のステータスIDを取得する() {
		System.out.println(String.format("StatusID : %d", contoller.getStatusID()));
		assertTrue(contoller.getStatusID() > 0);
	}

	@Test
	public void 秋猫にツイートさせる() throws TwitterException {

		long statusID = 12352234138l;	//テスト用に何件か取得できるIDを指定する
		contoller.setStatusID(statusID);

		List<Status> results = contoller.tweet();

		assertFalse(results.isEmpty());
		assertThat(results.get(results.size() - 1).getId(), is(1000l + results.size() - 1));
	}
}
AkinekoDummy

Twitterに投稿しないので、投稿後の結果(Status)が取得できません。なので、Statusも同様にStatusDummyを使用するようにしています。

package com.nakaji.AkinekoBotTest;

import java.util.*;
import twitter4j.*;
import com.nakaji.AkinekoBot.*;

//ダミーはTwitterへの投稿を行わない
class AkinekoDummy extends Akineko {

	private static long statusID = 1000;

	public AkinekoDummy(Twitter twitter, String hentaiWord) {
		super(twitter, hentaiWord);
	}

	public Status tweet(String message) throws TwitterException {
		String tweet = this.say(message);
		if (tweet.isEmpty())
			return null;

		//ポスト後に一意なIDを振ったステータスを返却する
		return new StatusDummy(statusID++, tweet);
	}

	public List<Status> tweet(List<String> statuses) throws TwitterException {
		List<Status> results = new ArrayList<Status>();

		for (String status : statuses) {
			Status result = this.tweet(status);

			if (result != null) {
				results.add(result);
			}
		}
		return results;
	}
}
StatusDummy

Statusを継承しますが、テストに必要な箇所(idとtextに関する箇所)のみ実装しています。

package com.nakaji.AkinekoBotTest;

import java.util.Date;

import twitter4j.*;

class StatusDummy implements Status {
	private long id;
	private String text;

	public StatusDummy(long id, String text) {
		this.id = id;
		this.text = text;
	}

	@Override
	public Date getCreatedAt() {
		// TODO 自動生成されたメソッド・スタブ
		return null;
	}

	@Override
	public GeoLocation getGeoLocation() {
		// TODO 自動生成されたメソッド・スタブ
		return null;
	}

	@Override
	public long getId() {
		return this.id;
	}

	@Override
	public String getInReplyToScreenName() {
		// TODO 自動生成されたメソッド・スタブ
		return null;
	}

	@Override
	public long getInReplyToStatusId() {
		// TODO 自動生成されたメソッド・スタブ
		return 0;
	}

	@Override
	public int getInReplyToUserId() {
		// TODO 自動生成されたメソッド・スタブ
		return 0;
	}

	@Override
	public Place getPlace() {
		// TODO 自動生成されたメソッド・スタブ
		return null;
	}

	@Override
	public Status getRetweetedStatus() {
		// TODO 自動生成されたメソッド・スタブ
		return null;
	}

	@Override
	public String getSource() {
		// TODO 自動生成されたメソッド・スタブ
		return null;
	}

	@Override
	public String getText() {
		return this.text;
	}

	@Override
	public User getUser() {
		// TODO 自動生成されたメソッド・スタブ
		return null;
	}

	@Override
	public boolean isFavorited() {
		// TODO 自動生成されたメソッド・スタブ
		return false;
	}

	@Override
	public boolean isRetweet() {
		// TODO 自動生成されたメソッド・スタブ
		return false;
	}

	@Override
	public boolean isTruncated() {
		// TODO 自動生成されたメソッド・スタブ
		return false;
	}

	@Override
	public RateLimitStatus getRateLimitStatus() {
		// TODO 自動生成されたメソッド・スタブ
		return null;
	}
}
AkinekoController

特定の人のツイートをチェックし、Akinekoにツイートするように指示するクラス。
setStatusIDもpublicにしたくないけど、テストのためにやむを得ず…

package com.nakaji.AkinekoBot;

import java.util.*;
import twitter4j.*;

public class AkinekoController {
	private Twitter twitter;
	private Akineko akineko;
	private String checkUserName;

	private long statusID = 0;

	public long getStatusID() {
		return statusID;
	}

	public void setStatusID(long statusID) {
		this.statusID = statusID;
	}

	public AkinekoController(Akineko akineko, Twitter twitter, String checkUserName) throws TwitterException {
		this.twitter = twitter;
		this.akineko = akineko;
		this.checkUserName = checkUserName;
		statusID = twitter.getPublicTimeline().get(0).getId();
	}

	public List<Status> tweet() throws TwitterException {
		Paging paging = new Paging();
		paging.setSinceId(statusID);

		List<Status> statuses = twitter.getUserTimeline(checkUserName, paging);

		if (statuses.isEmpty())
			return statuses;

		// ツイートを降順にソート
		Collections.sort(statuses, new Comparator<Status>() {
			@Override
			public int compare(Status o1, Status o2) {
				return o1.getId() > o2.getId() ? -1 : 1;
			}
		});

		List<String> messages = new ArrayList<String>();

		for (Status status : statuses) {
			messages.add(status.getText());
			System.out.println(status.getText());
		}

		List<Status> results = akineko.tweet(messages);
		statusID = results.get(results.size() - 1).getId();

		return results;
	}
}

AkinekoBot

public static void mainを定義。
起動時の引数にボットのID、パスワード及びチェックするユーザ名を指定してやります。
一分間隔で指定されたユーザの投稿をチェックして、つぶやきます。

package com.nakaji.AkinekoBot;

import twitter4j.*;

public class AkinekoBot {

	/**
	 * @param args
	 * @throws TwitterException
	 */
	public static void main(String[] args) throws TwitterException {

		Twitter twitter = new TwitterFactory().getInstance(args[0], args[1]);
		Akineko akineko = new Akineko(twitter, "性的な意味で");
		AkinekoController controller = new AkinekoController(akineko, twitter, args[2]);

		while (true) {
			controller.tweet();

			try {
				Thread.sleep(600000);
			} catch (InterruptedException e) {
				e.printStackTrace();
				return;
			}
		}
	}
}

実行結果

こんな感じ

所感

ざっと作って投稿したけど、もっとよく考えて設計すればテストクラスももっとスッキリすると思います。時間がかかるテストはNG*5といわれていますが、身をもって体感したって感じですね。Twitterに投稿したり、実際にデータを取得したりするので時間がかかってしょうがない…

AkinekoとAkinekoContollerを分ける必要性

Akinekoはつぶやくだけ、つぶやく内容はAkinekoControllerが指示するという考え方をしたけど、無理に分ける必要はなかったような…勝手にAkinekoが他人のツイートをチェックして、勝手につぶやくようにしても良かったですね。

クラス名

結果的に、別にAkinekoじゃなくて良かったんじゃね?と思いますが、まぁ、ネタということで…

その他やり残し
  • 140文字を越えた時の対処
  • 最後がURLで終わっている場合は、URLの前に「性的な意味で」を付けたい
  • 公式RTの場合はどんな動作になるのか確認

追記

やりたいことを追加しました。

アクセス修飾子の変更

id:bleis-tiftさんに教えてもらったやり方で、公開したくないメソッドをprotectedに変更する。

スケジューリングにScheduledThreadPoolExecutorクラスを使ってみる

id:Akinekoさんに教えてもらったクラスを調べて使ってみる。

クラス名の変更

数名から、これはひどいと指摘されたので名前変えますw
http://twitter.com/you_and_i/status/12453719666
http://twitter.com/akineko/status/12456133874


*1:エラー時の処理とか全然考えてないし…

*2:RTすると本人に迷惑がかかるから

*3:@されてる人に迷惑がかかるから

*4:投稿できるかどうかはAkinekoで行っているから

*5:テストするのがめんどくさくなるから