@graighleさんのこの一言で、ちょっと作ってみようと思い試してみました。
@akinekoさん、ネタにしてごめんねw
Twitterに関する操作は「Twitter4J - A Java library for the Twitter API」を使わせていただきました。
注意事項
- ボットがどうあるべきかはよく知りませんので、何じゃこりゃっていう設計でも大目に見て下やってさい。
- ツッコミどころ満載なのは承知の上で公開します。*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