commit 6a3fcb6d8a12c7eb8345c48a47e8c43906b91126
Author: みぞ@CrazyBeatCoder <mizo0203@mizo0203.com>
Date:   Mon Aug 20 15:30:48 2018 +0900

    Add feature Mastodon's timeline talking. fix #422 @16.0h

diff --git a/.idea/artifacts/TimelineTalker_jar.xml b/.idea/artifacts/TimelineTalker_jar.xml
index 28e1340..38afae2 100644
--- a/.idea/artifacts/TimelineTalker_jar.xml
+++ b/.idea/artifacts/TimelineTalker_jar.xml
@@ -2,9 +2,19 @@
   <artifact type="jar" name="TimelineTalker:jar">
     <output-path>$PROJECT_DIR$/out/artifacts/TimelineTalker_jar</output-path>
     <root id="archive" name="TimelineTalker.jar">
-      <element id="module-output" name="TimelineTalker"/>
-      <element id="extracted-dir" path="$MAVEN_REPOSITORY$/org/twitter4j/twitter4j-core/4.0.6/twitter4j-core-4.0.6.jar" path-in-jar="/" />
-      <element id="extracted-dir" path="$MAVEN_REPOSITORY$/org/twitter4j/twitter4j-stream/4.0.6/twitter4j-stream-4.0.6.jar" path-in-jar="/" />
+      <element id="module-output" name="TimelineTalker" />
+      <element id="extracted-dir" path="$MAVEN_REPOSITORY$/com/google/code/gson/gson/2.8.0/gson-2.8.0.jar" path-in-jar="/" />
+      <element id="extracted-dir" path="$MAVEN_REPOSITORY$/io/reactivex/rxjava2/rxjava/2.0.8/rxjava-2.0.8.jar" path-in-jar="/" />
+      <element id="extracted-dir" path="$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib/1.2.0/kotlin-stdlib-1.2.0.jar" path-in-jar="/" />
+      <element id="extracted-dir" path="$MAVEN_REPOSITORY$/com/github/sys1yagi/mastodon4j/mastodon4j/1.6.0/mastodon4j-1.6.0.jar" path-in-jar="/" />
+      <element id="extracted-dir" path="$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib-jre7/1.2.0/kotlin-stdlib-jre7-1.2.0.jar" path-in-jar="/" />
+      <element id="extracted-dir" path="$MAVEN_REPOSITORY$/org/jetbrains/annotations/13.0/annotations-13.0.jar" path-in-jar="/" />
+      <element id="extracted-dir" path="$MAVEN_REPOSITORY$/com/github/sys1yagi/mastodon4j/mastodon4j-rx/1.6.0/mastodon4j-rx-1.6.0.jar" path-in-jar="/" />
+      <element id="extracted-dir" path="$MAVEN_REPOSITORY$/com/squareup/okio/okio/1.11.0/okio-1.11.0.jar" path-in-jar="/" />
+      <element id="extracted-dir" path="$MAVEN_REPOSITORY$/org/reactivestreams/reactive-streams/1.0.0/reactive-streams-1.0.0.jar" path-in-jar="/" />
+      <element id="extracted-dir" path="$MAVEN_REPOSITORY$/org/twitter4j/twitter4j-core/4.0.7/twitter4j-core-4.0.7.jar" path-in-jar="/" />
+      <element id="extracted-dir" path="$MAVEN_REPOSITORY$/commons-io/commons-io/2.6/commons-io-2.6.jar" path-in-jar="/" />
+      <element id="extracted-dir" path="$MAVEN_REPOSITORY$/com/squareup/okhttp3/okhttp/3.6.0/okhttp-3.6.0.jar" path-in-jar="/" />
     </root>
   </artifact>
 </component>
\ No newline at end of file
diff --git a/TimelineTalker.iml b/TimelineTalker.iml
index 354d5ea..7d5abf7 100644
--- a/TimelineTalker.iml
+++ b/TimelineTalker.iml
@@ -9,7 +9,17 @@
     </content>
     <orderEntry type="inheritedJdk" />
     <orderEntry type="sourceFolder" forTests="false" />
-    <orderEntry type="library" name="Maven: org.twitter4j:twitter4j-core:4.0.6" level="project" />
     <orderEntry type="library" name="Maven: org.twitter4j:twitter4j-core:4.0.7" level="project" />
+    <orderEntry type="library" name="Maven: com.github.sys1yagi.mastodon4j:mastodon4j:1.6.0" level="project" />
+    <orderEntry type="library" name="Maven: org.jetbrains.kotlin:kotlin-stdlib-jre7:1.2.0" level="project" />
+    <orderEntry type="library" name="Maven: org.jetbrains.kotlin:kotlin-stdlib:1.2.0" level="project" />
+    <orderEntry type="library" name="Maven: org.jetbrains:annotations:13.0" level="project" />
+    <orderEntry type="library" name="Maven: com.squareup.okhttp3:okhttp:3.6.0" level="project" />
+    <orderEntry type="library" name="Maven: com.squareup.okio:okio:1.11.0" level="project" />
+    <orderEntry type="library" name="Maven: com.google.code.gson:gson:2.8.0" level="project" />
+    <orderEntry type="library" name="Maven: com.github.sys1yagi.mastodon4j:mastodon4j-rx:1.6.0" level="project" />
+    <orderEntry type="library" name="Maven: io.reactivex.rxjava2:rxjava:2.0.8" level="project" />
+    <orderEntry type="library" name="Maven: org.reactivestreams:reactive-streams:1.0.0" level="project" />
+    <orderEntry type="library" name="Maven: commons-io:commons-io:2.6" level="project" />
   </component>
 </module>
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index de0f279..76cf6b8 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1,44 +1,69 @@
-<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
-	<modelVersion>4.0.0</modelVersion>
+<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
 
-	<groupId>com.mizo0203</groupId>
-	<artifactId>TimelineTalker</artifactId>
-	<version>0.0.1-SNAPSHOT</version>
-	<packaging>jar</packaging>
+    <groupId>com.mizo0203</groupId>
+    <artifactId>TimelineTalker</artifactId>
+    <version>0.0.1-SNAPSHOT</version>
+    <packaging>jar</packaging>
 
-	<properties>
-		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
-	</properties>
+    <properties>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+    </properties>
 
-	<build>
-		<sourceDirectory>src</sourceDirectory>
-		<outputDirectory>target/classes</outputDirectory>
-		<resources>
-			<resource>
-				<directory>src</directory>
-				<excludes>
-					<exclude>**/*.java</exclude>
-				</excludes>
-			</resource>
-		</resources>
-		<plugins>
-			<plugin>
-				<artifactId>maven-compiler-plugin</artifactId>
-				<version>3.1</version>
-				<configuration>
-					<source>1.7</source>
-					<target>1.7</target>
-				</configuration>
-			</plugin>
-		</plugins>
-	</build>
+    <repositories>
+        <repository>
+            <id>jitpack.io</id>
+            <url>https://jitpack.io</url>
+        </repository>
+    </repositories>
 
-	<dependencies>
-		<dependency>
-			<groupId>org.twitter4j</groupId>
-			<artifactId>twitter4j-core</artifactId>
-			<version>[4.0,)</version>
-		</dependency>
-	</dependencies>
+    <build>
+        <sourceDirectory>src</sourceDirectory>
+        <outputDirectory>target/classes</outputDirectory>
+        <resources>
+            <resource>
+                <directory>src</directory>
+                <excludes>
+                    <exclude>**/*.java</exclude>
+                </excludes>
+            </resource>
+        </resources>
+        <plugins>
+            <plugin>
+                <artifactId>maven-compiler-plugin</artifactId>
+                <version>3.1</version>
+                <configuration>
+                    <source>1.7</source>
+                    <target>1.7</target>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.twitter4j</groupId>
+            <artifactId>twitter4j-core</artifactId>
+            <version>[4.0,)</version>
+        </dependency>
+        <dependency>
+            <groupId>com.github.sys1yagi.mastodon4j</groupId>
+            <artifactId>mastodon4j</artifactId>
+            <version>[1.6,)</version>
+        </dependency>
+        <dependency>
+            <groupId>com.github.sys1yagi.mastodon4j</groupId>
+            <artifactId>mastodon4j-rx</artifactId>
+            <version>[1.6,)</version>
+        </dependency>
+
+        <!-- https://mvnrepository.com/artifact/commons-io/commons-io -->
+        <dependency>
+            <groupId>commons-io</groupId>
+            <artifactId>commons-io</artifactId>
+            <version>2.6</version>
+        </dependency>
+
+    </dependencies>
 </project>
diff --git a/src/META-INF/MANIFEST.MF b/src/META-INF/MANIFEST.MF
index 59a0968..cb07946 100644
--- a/src/META-INF/MANIFEST.MF
+++ b/src/META-INF/MANIFEST.MF
@@ -1,5 +1,5 @@
 Manifest-Version: 1.0
 Implementation-Title: TimelineTalker
 Implementation-Version: 1.1
-Main-Class: com.mizo0203.timeline.talker.Application
+Main-Class: com.mizo0203.timeline.talker.Main
 
diff --git a/src/com/mizo0203/timeline/talker/Application.java b/src/com/mizo0203/timeline/talker/Application.java
deleted file mode 100644
index 5258e30..0000000
--- a/src/com/mizo0203/timeline/talker/Application.java
+++ /dev/null
@@ -1,79 +0,0 @@
-package com.mizo0203.timeline.talker;
-
-import twitter4j.conf.Configuration;
-import twitter4j.conf.ConfigurationBuilder;
-
-/**
- * Java アプリケーション起動時に実行されるクラス
- * 
- * @author みぞ@CrazyBeatCoder
- */
-public class Application {
-
-  public static void main(String[] args) {
-    TimelineTalker timelineTalker;
-    Talker talker;
-
-    try {
-      talker = new Talker();
-      timelineTalker =
-              new TimelineTalker(new Arguments(args).twitterConfiguration, talker);
-    } catch (IllegalArgumentException | IllegalStateException e) {
-      System.err.println(e.getMessage());
-      return;
-    }
-
-    timelineTalker.start();
-    talker.talkAsync("アプリケーションを起動しました", Talker.YukkuriVoice.REIMU);
-  }
-
-  /**
-   * Java アプリケーション起動時に指定する引数のデータクラス
-   * 
-   * @author みぞ@CrazyBeatCoder
-   */
-  private static class Arguments {
-
-    private final Configuration twitterConfiguration;
-
-    private Arguments(String[] args) throws IllegalArgumentException {
-      if (args.length < Argument.values().length) {
-        StringBuilder exceptionMessage = new StringBuilder();
-        exceptionMessage.append(Argument.values().length + " つの引数を指定してください。\n");
-        for (Argument arg : Argument.values()) {
-          exceptionMessage.append((arg.ordinal() + 1) + " つ目: " + arg.detail + "\n");
-        }
-        throw new IllegalArgumentException(exceptionMessage.toString());
-      }
-
-      String consumer_key = args[Argument.CONSUMER_KEY.ordinal()];
-      String consumer_secret = args[Argument.CONSUMER_SECRET.ordinal()];
-      String access_token = args[Argument.ACCESS_TOKEN.ordinal()];
-      String access_token_secret = args[Argument.ACCESS_TOKEN_SECRET.ordinal()];
-
-      twitterConfiguration = new ConfigurationBuilder().setOAuthConsumerKey(consumer_key)
-          .setOAuthConsumerSecret(consumer_secret).setOAuthAccessToken(access_token)
-          .setOAuthAccessTokenSecret(access_token_secret).build();
-    }
-
-  }
-
-  /**
-   * Java アプリケーション起動時に指定する引数の定義
-   * 
-   * @author みぞ@CrazyBeatCoder
-   */
-  private enum Argument {
-    CONSUMER_KEY("Twitter Application's Consumer Key (API Key)"), //
-    CONSUMER_SECRET("Twitter Application's Consumer Secret (API Secret)"), //
-    ACCESS_TOKEN("Twitter Account's Access Token"), //
-    ACCESS_TOKEN_SECRET("Twitter Account's Access Token Secret"), //
-    ;
-
-    private final String detail;
-
-    private Argument(String detail) {
-      this.detail = detail;
-    }
-  }
-}
diff --git a/src/com/mizo0203/timeline/talker/Arguments.java b/src/com/mizo0203/timeline/talker/Arguments.java
new file mode 100644
index 0000000..be320a2
--- /dev/null
+++ b/src/com/mizo0203/timeline/talker/Arguments.java
@@ -0,0 +1,207 @@
+package com.mizo0203.timeline.talker;
+
+import com.google.gson.Gson;
+import com.sys1yagi.mastodon4j.MastodonClient;
+import okhttp3.OkHttpClient;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import twitter4j.conf.Configuration;
+import twitter4j.conf.ConfigurationBuilder;
+
+/**
+ * Java アプリケーション起動時に指定する引数のデータクラス
+ *
+ * @author みぞ@CrazyBeatCoder
+ */
+/* package */ class Arguments {
+
+  @Nullable /* package */ final Configuration twitterConfiguration;
+  @Nullable /* package */ final MastodonClient mastodonClient;
+
+  /* package */ Arguments(String[] args) throws IllegalArgumentException {
+    if (args.length == Argument.values().length) {
+      twitterConfiguration =
+          createTwitterConfiguration(
+              args[Argument.TWITTER_CONSUMER_KEY.ordinal()],
+              args[Argument.TWITTER_CONSUMER_SECRET.ordinal()],
+              args[Argument.TWITTER_ACCESS_TOKEN.ordinal()],
+              args[Argument.TWITTER_ACCESS_TOKEN_SECRET.ordinal()]);
+      mastodonClient =
+          createMastodonClient(
+              args[Argument.MASTODON_INSTANCE_NAME.ordinal()],
+              args[Argument.MASTODON_ACCESS_TOKEN.ordinal()]);
+    } else if (args.length == Argument.Twitter.values().length) {
+      twitterConfiguration =
+          createTwitterConfiguration(
+              args[Argument.Twitter.CONSUMER_KEY.ordinal()],
+              args[Argument.Twitter.CONSUMER_SECRET.ordinal()],
+              args[Argument.Twitter.ACCESS_TOKEN.ordinal()],
+              args[Argument.Twitter.ACCESS_TOKEN_SECRET.ordinal()]);
+      mastodonClient = null;
+    } else if (args.length == Argument.Mastodon.values().length) {
+      twitterConfiguration = null;
+      mastodonClient =
+          createMastodonClient(
+              args[Argument.Mastodon.INSTANCE_NAME.ordinal()],
+              args[Argument.Mastodon.ACCESS_TOKEN.ordinal()]);
+    } else {
+      throw createIllegalArgumentException();
+    }
+  }
+
+  private static Configuration createTwitterConfiguration(
+      String twitterConsumerKey,
+      String twitterConsumerSecret,
+      String twitterAccessToken,
+      String twitterAccessTokenSecret) {
+    return new ConfigurationBuilder()
+        .setOAuthConsumerKey(twitterConsumerKey)
+        .setOAuthConsumerSecret(twitterConsumerSecret)
+        .setOAuthAccessToken(twitterAccessToken)
+        .setOAuthAccessTokenSecret(twitterAccessTokenSecret)
+        .build();
+  }
+
+  private static MastodonClient createMastodonClient(
+      String mastodonInstanceName, String mastodonAccessToken) {
+    return new MastodonClient.Builder(mastodonInstanceName, new OkHttpClient.Builder(), new Gson())
+        .accessToken(mastodonAccessToken)
+        .useStreamingApi()
+        .build();
+  }
+
+  private static IllegalArgumentException createIllegalArgumentException() {
+    return new IllegalArgumentException(
+        Argument.createExceptionMessage()
+            + "\n"
+            + Argument.Twitter.createExceptionMessage()
+            + "\n"
+            + Argument.Mastodon.createExceptionMessage());
+  }
+
+  /**
+   * Java アプリケーション起動時に指定する引数の定義
+   *
+   * @author みぞ@CrazyBeatCoder
+   */
+  private enum Argument {
+    TWITTER_CONSUMER_KEY, //
+    TWITTER_CONSUMER_SECRET, //
+    TWITTER_ACCESS_TOKEN, //
+    TWITTER_ACCESS_TOKEN_SECRET, //
+    MASTODON_INSTANCE_NAME, //
+    MASTODON_ACCESS_TOKEN, //
+    ;
+
+    @NotNull
+    private static String createExceptionMessage() {
+      StringBuilder exceptionMessage =
+          new StringBuilder("Twitter と Mastodon の両方を読み上げる場合、 ")
+              .append(values().length)
+              .append(" つの引数を指定してください。\n");
+      for (Argument arg : values()) {
+        exceptionMessage
+            .append(arg.ordinal() + 1)
+            .append(" つ目: ")
+            .append(arg.getDetail())
+            .append("\n");
+      }
+
+      return exceptionMessage.toString();
+    }
+
+    @NotNull
+    private String getDetail() throws IllegalStateException {
+      switch (this) {
+        case TWITTER_CONSUMER_KEY:
+          return "Twitter Application's Consumer Key (API Key)";
+        case TWITTER_CONSUMER_SECRET:
+          return "Twitter Application's Consumer Secret (API Secret)";
+        case TWITTER_ACCESS_TOKEN:
+          return "Twitter Account's Access Token";
+        case TWITTER_ACCESS_TOKEN_SECRET:
+          return "Twitter Account's Access Token Secret";
+        case MASTODON_INSTANCE_NAME:
+          return "Mastodon Instance Name";
+        case MASTODON_ACCESS_TOKEN:
+          return "Mastodon Account's Access Token";
+        default:
+          throw new IllegalStateException("getDetail this: " + this);
+      }
+    }
+
+    private enum Twitter {
+      CONSUMER_KEY, //
+      CONSUMER_SECRET, //
+      ACCESS_TOKEN, //
+      ACCESS_TOKEN_SECRET, //
+      ;
+
+      @NotNull
+      private static String createExceptionMessage() {
+        StringBuilder exceptionMessage =
+            new StringBuilder("Twitter のみを読み上げる場合、 ")
+                .append(values().length)
+                .append(" つの引数を指定してください。\n");
+        for (Argument.Twitter arg : values()) {
+          exceptionMessage
+              .append(arg.ordinal() + 1)
+              .append(" つ目: ")
+              .append(arg.getDetail())
+              .append("\n");
+        }
+        return exceptionMessage.toString();
+      }
+
+      @NotNull
+      private String getDetail() throws IllegalStateException {
+        switch (this) {
+          case CONSUMER_KEY:
+            return TWITTER_CONSUMER_KEY.getDetail();
+          case CONSUMER_SECRET:
+            return TWITTER_CONSUMER_SECRET.getDetail();
+          case ACCESS_TOKEN:
+            return TWITTER_ACCESS_TOKEN.getDetail();
+          case ACCESS_TOKEN_SECRET:
+            return TWITTER_ACCESS_TOKEN_SECRET.getDetail();
+          default:
+            throw new IllegalStateException("getDetail this: " + this);
+        }
+      }
+    }
+
+    private enum Mastodon {
+      INSTANCE_NAME, //
+      ACCESS_TOKEN, //
+      ;
+
+      @NotNull
+      private static String createExceptionMessage() {
+        StringBuilder exceptionMessage =
+            new StringBuilder("Mastodon のみを読み上げる場合、 ")
+                .append(values().length)
+                .append(" つの引数を指定してください。\n");
+        for (Argument.Mastodon arg : values()) {
+          exceptionMessage
+              .append(arg.ordinal() + 1)
+              .append(" つ目: ")
+              .append(arg.getDetail())
+              .append("\n");
+        }
+        return exceptionMessage.toString();
+      }
+
+      @NotNull
+      private String getDetail() throws IllegalStateException {
+        switch (this) {
+          case INSTANCE_NAME:
+            return MASTODON_INSTANCE_NAME.getDetail();
+          case ACCESS_TOKEN:
+            return MASTODON_ACCESS_TOKEN.getDetail();
+          default:
+            throw new IllegalStateException("getDetail this: " + this);
+        }
+      }
+    }
+  }
+}
diff --git a/src/com/mizo0203/timeline/talker/Main.java b/src/com/mizo0203/timeline/talker/Main.java
new file mode 100644
index 0000000..c1ac2c4
--- /dev/null
+++ b/src/com/mizo0203/timeline/talker/Main.java
@@ -0,0 +1,38 @@
+package com.mizo0203.timeline.talker;
+
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Java アプリケーション起動時に実行されるクラス
+ *
+ * @author みぞ@CrazyBeatCoder
+ */
+public class Main {
+
+  public static void main(@NotNull String[] args) {
+    TimelineTalker twitterTimelineTalker;
+    TimelineTalker mastodonTimelineTalker;
+    Talker talker;
+
+    try {
+      Arguments arguments = new Arguments(args);
+      talker = new Talker();
+
+      if (arguments.twitterConfiguration != null) {
+        twitterTimelineTalker = new TwitterTimelineTalker(arguments.twitterConfiguration, talker);
+        twitterTimelineTalker.start();
+      }
+
+      if (arguments.mastodonClient != null) {
+        mastodonTimelineTalker = new MastodonTimelineTalker(arguments.mastodonClient, talker);
+        mastodonTimelineTalker.start();
+      }
+
+    } catch (@NotNull IllegalArgumentException | IllegalStateException e) {
+      System.err.println(e.getMessage());
+      return;
+    }
+
+    talker.talkAlternatelyAsync("アプリケーションを起動しました");
+  }
+}
diff --git a/src/com/mizo0203/timeline/talker/MastodonTimelineTalker.java b/src/com/mizo0203/timeline/talker/MastodonTimelineTalker.java
new file mode 100644
index 0000000..0b47f76
--- /dev/null
+++ b/src/com/mizo0203/timeline/talker/MastodonTimelineTalker.java
@@ -0,0 +1,120 @@
+package com.mizo0203.timeline.talker;
+
+import com.mizo0203.timeline.talker.util.DisplayNameUtil;
+import com.mizo0203.timeline.talker.util.HTMLParser;
+import com.mizo0203.timeline.talker.util.UrlUtil;
+import com.sys1yagi.mastodon4j.MastodonClient;
+import com.sys1yagi.mastodon4j.api.Handler;
+import com.sys1yagi.mastodon4j.api.entity.Account;
+import com.sys1yagi.mastodon4j.api.entity.Notification;
+import com.sys1yagi.mastodon4j.api.entity.Status;
+import com.sys1yagi.mastodon4j.api.method.Streaming;
+import org.jetbrains.annotations.NotNull;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+public class MastodonTimelineTalker implements TimelineTalker {
+
+  @NotNull private final Streaming mStreaming;
+  @NotNull private final OnStatusEvent mOnStatusEvent;
+
+  /* package */ MastodonTimelineTalker(MastodonClient client, Talker talker) {
+    mStreaming = new Streaming(client);
+    mOnStatusEvent = new OnStatusEvent(talker);
+  }
+
+  @Override
+  public void start() {
+
+    try {
+      mStreaming.user(mOnStatusEvent);
+    } catch (Exception e) {
+      e.printStackTrace();
+    }
+  }
+
+  private static class OnStatusEvent implements Handler {
+
+    private final Talker mTalker;
+
+    private OnStatusEvent(Talker talker) {
+      mTalker = talker;
+    }
+
+    @Override
+    public void onStatus(@NotNull Status status) {
+
+      try {
+        final StringBuffer buffer = new StringBuffer();
+        final Status reblogStatus = status.getReblog();
+
+        String displayName = "誰か";
+        if (status.getAccount() != null) {
+          displayName = status.getAccount().getDisplayName();
+        }
+        if (reblogStatus != null) {
+          String reblogDisplayName = "誰か";
+          if (status.getAccount() != null) {
+            displayName = status.getAccount().getDisplayName();
+          }
+          buffer.append(DisplayNameUtil.removeContext(displayName)).append("さんがブースト。");
+          buffer.append(DisplayNameUtil.removeContext(reblogDisplayName)).append("さんから、");
+          buffer.append(
+              new HTMLParser().parse(reblogStatus.getContent(), StandardCharsets.UTF_8, true));
+        } else {
+          buffer.append(DisplayNameUtil.removeContext(displayName)).append("さんから、");
+          buffer.append(new HTMLParser().parse(status.getContent(), StandardCharsets.UTF_8, true));
+        }
+
+        final String talkText = UrlUtil.convURLEmpty(buffer).replaceAll("\n", "。");
+        mTalker.talkAlternatelyAsync(talkText);
+      } catch (IOException e) {
+        e.printStackTrace();
+      }
+    }
+
+    @Override
+    public void onNotification(@NotNull Notification notification) {
+      final StringBuilder buffer = new StringBuilder();
+      final Account account = notification.getAccount();
+
+      if (account == null) {
+        return;
+      }
+
+      switch (notification.getType()) {
+        case "mention":
+          buffer
+              .append(DisplayNameUtil.removeContext(account.getDisplayName()))
+              .append("さんがあなたをメンションしました。");
+          break;
+        case "reblog":
+          buffer
+              .append(DisplayNameUtil.removeContext(account.getDisplayName()))
+              .append("さんがあなたのトゥートをブーストしました。");
+          break;
+        case "favourite":
+          buffer
+              .append(DisplayNameUtil.removeContext(account.getDisplayName()))
+              .append("さんがあなたのトゥートをお気に入りに登録しました。");
+          break;
+        case "follow":
+          buffer
+              .append(DisplayNameUtil.removeContext(account.getDisplayName()))
+              .append("さんにフォローされました。");
+          break;
+        default:
+          return;
+      }
+
+      final String talkText = buffer.toString();
+      mTalker.talkAlternatelyAsync(talkText);
+    }
+
+    @Override
+    public void onDelete(long id) {
+      /* no op */
+    }
+  }
+}
diff --git a/src/com/mizo0203/timeline/talker/RuntimeUtil.java b/src/com/mizo0203/timeline/talker/RuntimeUtil.java
deleted file mode 100644
index a528d7a..0000000
--- a/src/com/mizo0203/timeline/talker/RuntimeUtil.java
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.mizo0203.timeline.talker;
-
-import java.io.IOException;
-
-public class RuntimeUtil {
-
-  public static void execute(String[] cmdarray) {
-    try {
-      Process process = Runtime.getRuntime().exec(cmdarray);
-      process.waitFor();
-      process.destroy();
-    } catch (IOException | InterruptedException e) {
-      e.printStackTrace();
-    }
-  }
-
-}
diff --git a/src/com/mizo0203/timeline/talker/Talker.java b/src/com/mizo0203/timeline/talker/Talker.java
index c464c6b..addbd1c 100644
--- a/src/com/mizo0203/timeline/talker/Talker.java
+++ b/src/com/mizo0203/timeline/talker/Talker.java
@@ -1,5 +1,8 @@
 package com.mizo0203.timeline.talker;
 
+import com.mizo0203.timeline.talker.util.RuntimeUtil;
+import org.jetbrains.annotations.NotNull;
+
 import java.io.*;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
@@ -10,58 +13,68 @@ public class Talker {
 
   private final ExecutorService mSingleThreadExecutor = Executors.newSingleThreadExecutor();
 
+  @NotNull private YukkuriVoice mNextVoice = Talker.YukkuriVoice.REIMU;
+
   public Talker() throws IllegalStateException, SecurityException {
     File file = new File(AQUESTALK_PI_PATH);
     if (!file.isFile()) {
-      throw new IllegalStateException(file.getPath() + " に AquesTalk Pi がありません。\n"
-          + "https://www.a-quest.com/products/aquestalkpi.html\n" + "からダウンロードしてください。");
+      throw new IllegalStateException(
+          file.getPath()
+              + " に AquesTalk Pi がありません。\n"
+              + "https://www.a-quest.com/products/aquestalkpi.html\n"
+              + "からダウンロードしてください。");
     }
     if (!file.canExecute()) {
       throw new IllegalStateException(file.getPath() + " に実行権限がありません。");
     }
   }
 
-  public void talkAsync(final String text, final YukkuriVoice voice) {
-    mSingleThreadExecutor.submit(new Runnable() {
-
-      @Override
-      public void run() {
-        try {
-          File file = new File("text.txt");
-          PrintWriter pw = new PrintWriter(new BufferedWriter(new FileWriter(file)));
-          pw.println(text);
-          pw.flush();
-          pw.close();
-          RuntimeUtil.execute(new String[] {AQUESTALK_PI_PATH, "-v", voice.value, "-f", "text.txt",
-              "-o", "out.wav"});
-          RuntimeUtil.execute(new String[] {"sh", "-c", "aplay < out.wav"}); // 起動コマンドを指定する
-          Thread.sleep(2000);
-        } catch (IOException | InterruptedException e) {
-          e.printStackTrace();
-        }
-      }
-
-    });
+  public void talkAlternatelyAsync(final String text) {
+    mSingleThreadExecutor.submit(
+        new Runnable() {
+
+          @Override
+          public void run() {
+            try {
+              File file = new File("text.txt");
+              PrintWriter pw = new PrintWriter(new BufferedWriter(new FileWriter(file)));
+              pw.println(text);
+              pw.flush();
+              pw.close();
+              RuntimeUtil.execute(
+                  new String[] {
+                    AQUESTALK_PI_PATH, "-v", mNextVoice.value, "-f", "text.txt", "-o", "out.wav"
+                  });
+              RuntimeUtil.execute(new String[] {"sh", "-c", "aplay < out.wav"}); // 起動コマンドを指定する
+
+              // 読み上げは、霊夢と魔理沙が交互に行なう
+              if (mNextVoice == Talker.YukkuriVoice.REIMU) {
+                mNextVoice = Talker.YukkuriVoice.MARISA;
+              } else {
+                mNextVoice = Talker.YukkuriVoice.REIMU;
+              }
+
+              Thread.sleep(2000);
+            } catch (@NotNull IOException | InterruptedException e) {
+              e.printStackTrace();
+            }
+          }
+        });
   }
 
-  public static enum YukkuriVoice {
+  public enum YukkuriVoice {
 
-    /**
-     * ゆっくりボイス - 霊夢
-     */
+    /** ゆっくりボイス - 霊夢 */
     REIMU("f1"), //
 
-    /**
-     * ゆっくりボイス - 魔理沙
-     */
+    /** ゆっくりボイス - 魔理沙 */
     MARISA("f2"), //
     ;
 
     private final String value;
 
-    private YukkuriVoice(String value) {
+    YukkuriVoice(String value) {
       this.value = value;
     }
   }
-
 }
diff --git a/src/com/mizo0203/timeline/talker/TimelineTalker.java b/src/com/mizo0203/timeline/talker/TimelineTalker.java
index 11039d3..2bd6de4 100644
--- a/src/com/mizo0203/timeline/talker/TimelineTalker.java
+++ b/src/com/mizo0203/timeline/talker/TimelineTalker.java
@@ -1,118 +1,6 @@
 package com.mizo0203.timeline.talker;
 
-import twitter4j.*;
-import twitter4j.conf.Configuration;
+/* package */ interface TimelineTalker {
 
-import java.util.Collections;
-import java.util.Locale;
-import java.util.Timer;
-import java.util.TimerTask;
-import java.util.concurrent.TimeUnit;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-public class TimelineTalker {
-
-  /**
-   * ISO 639 言語コード - 日本語 (ja)
-   */
-  public static final String LANG_JA = Locale.JAPAN.getLanguage();
-
-  private final RequestHomeTimelineTimerTask mRequestHomeTimelineTimerTask;
-
-  public TimelineTalker(Configuration configuration, Talker talker) {
-    Twitter twitter = new TwitterFactory(configuration).getInstance();
-    mRequestHomeTimelineTimerTask = new RequestHomeTimelineTimerTask(twitter, talker);
-  }
-
-  private static String getUserNameWithoutContext(String name) {
-    Pattern p = Pattern.compile("([^@＠]+).+");
-    Matcher m = p.matcher(name);
-    return m.replaceFirst("$1");
-  }
-
-  public void start() {
-    new Timer().schedule(mRequestHomeTimelineTimerTask, 0L, TimeUnit.MINUTES.toMillis(1));
-  }
-
-  private static class RequestHomeTimelineTimerTask extends TimerTask {
-
-    private static final int HOME_TIMELINE_COUNT_MAX = 200;
-    private static final int HOME_TIMELINE_COUNT_MIN = 1;
-
-    private final Twitter mTwitter;
-    private final Talker mTalker;
-
-    private Talker.YukkuriVoice mYukkuriVoice = Talker.YukkuriVoice.REIMU;
-
-    /**
-     * mStatusSinceId より大きい（つまり、より新しい） ID を持つ HomeTimeline をリクエストする
-     */
-    private long mStatusSinceId = 1L;
-
-    private boolean mIsUpdatedStatusSinceId = false;
-
-    private RequestHomeTimelineTimerTask(Twitter twitter, Talker talker) {
-      mTwitter = twitter;
-      mTalker = talker;
-    }
-
-    /**
-     * The action to be performed by this timer task.
-     */
-    @Override
-    public void run() {
-      try {
-        // mStatusSinceId が未更新ならば、 Status を 1 つだけ取得する
-        int count = mIsUpdatedStatusSinceId ? HOME_TIMELINE_COUNT_MAX : HOME_TIMELINE_COUNT_MIN;
-        Paging paging = new Paging(1, count, mStatusSinceId);
-        ResponseList<Status> statusResponseList = mTwitter.getHomeTimeline(paging);
-
-        if (statusResponseList.isEmpty()) {
-          return;
-        }
-
-        // mStatusSinceId を、取得した最新の ID に更新する
-        mStatusSinceId = statusResponseList.get(0).getId();
-        mIsUpdatedStatusSinceId = true;
-
-        // Status が古い順になるよう、 statusResponseList を逆順に並び替える
-        Collections.reverse(statusResponseList);
-
-        for (Status status : statusResponseList) {
-          onStatus(status);
-        }
-
-      } catch (TwitterException e) {
-        e.printStackTrace();
-      }
-    }
-
-    private void onStatus(final Status status) {
-      if (!LANG_JA.equalsIgnoreCase(status.getLang())) {
-        return;
-      }
-
-      final StringBuffer buffer = new StringBuffer();
-
-      if (status.isRetweet()) {
-        Status retweetedStatus = status.getRetweetedStatus();
-        buffer.append(getUserNameWithoutContext(status.getUser().getName()) + "さんがリツイート。");
-        buffer.append(getUserNameWithoutContext(retweetedStatus.getUser().getName()) + "さんから、");
-        buffer.append(retweetedStatus.getText());
-      } else {
-        buffer.append(getUserNameWithoutContext(status.getUser().getName()) + "さんから、");
-        buffer.append(status.getText());
-      }
-
-      mTalker.talkAsync(UrlUtil.convURLEmpty(buffer).replaceAll("\n", "。"), mYukkuriVoice);
-
-      // 読み上げは、霊夢と魔理沙が交互に行なう
-      if (mYukkuriVoice == Talker.YukkuriVoice.REIMU) {
-        mYukkuriVoice = Talker.YukkuriVoice.MARISA;
-      } else {
-        mYukkuriVoice = Talker.YukkuriVoice.REIMU;
-      }
-    }
-  }
+  /* package */ void start();
 }
diff --git a/src/com/mizo0203/timeline/talker/TwitterTimelineTalker.java b/src/com/mizo0203/timeline/talker/TwitterTimelineTalker.java
new file mode 100644
index 0000000..67aaf07
--- /dev/null
+++ b/src/com/mizo0203/timeline/talker/TwitterTimelineTalker.java
@@ -0,0 +1,103 @@
+package com.mizo0203.timeline.talker;
+
+import com.mizo0203.timeline.talker.util.DisplayNameUtil;
+import com.mizo0203.timeline.talker.util.UrlUtil;
+import org.jetbrains.annotations.NotNull;
+import twitter4j.*;
+import twitter4j.conf.Configuration;
+
+import java.util.Collections;
+import java.util.Locale;
+import java.util.Timer;
+import java.util.TimerTask;
+import java.util.concurrent.TimeUnit;
+
+public class TwitterTimelineTalker implements TimelineTalker {
+
+  /** ISO 639 言語コード - 日本語 (ja) */
+  private static final String LANG_JA = Locale.JAPAN.getLanguage();
+
+  @NotNull private final RequestHomeTimelineTimerTask mRequestHomeTimelineTimerTask;
+
+  public TwitterTimelineTalker(@NotNull Configuration configuration, Talker talker) {
+    Twitter twitter = new TwitterFactory(configuration).getInstance();
+    mRequestHomeTimelineTimerTask = new RequestHomeTimelineTimerTask(twitter, talker);
+  }
+
+  @Override
+  public void start() {
+    new Timer().schedule(mRequestHomeTimelineTimerTask, 0L, TimeUnit.MINUTES.toMillis(1));
+  }
+
+  private static class RequestHomeTimelineTimerTask extends TimerTask {
+
+    private static final int HOME_TIMELINE_COUNT_MAX = 200;
+    private static final int HOME_TIMELINE_COUNT_MIN = 1;
+
+    private final Twitter mTwitter;
+    private final Talker mTalker;
+
+    /** mStatusSinceId より大きい（つまり、より新しい） ID を持つ HomeTimeline をリクエストする */
+    private long mStatusSinceId = 1L;
+
+    private boolean mIsUpdatedStatusSinceId = false;
+
+    private RequestHomeTimelineTimerTask(Twitter twitter, Talker talker) {
+      mTwitter = twitter;
+      mTalker = talker;
+    }
+
+    /** The action to be performed by this timer task. */
+    @Override
+    public void run() {
+      try {
+        // mStatusSinceId が未更新ならば、 Status を 1 つだけ取得する
+        int count = mIsUpdatedStatusSinceId ? HOME_TIMELINE_COUNT_MAX : HOME_TIMELINE_COUNT_MIN;
+        Paging paging = new Paging(1, count, mStatusSinceId);
+        ResponseList<Status> statusResponseList = mTwitter.getHomeTimeline(paging);
+
+        if (statusResponseList.isEmpty()) {
+          return;
+        }
+
+        // mStatusSinceId を、取得した最新の ID に更新する
+        mStatusSinceId = statusResponseList.get(0).getId();
+        mIsUpdatedStatusSinceId = true;
+
+        // Status が古い順になるよう、 statusResponseList を逆順に並び替える
+        Collections.reverse(statusResponseList);
+
+        for (Status status : statusResponseList) {
+          onStatus(status);
+        }
+
+      } catch (TwitterException e) {
+        e.printStackTrace();
+      }
+    }
+
+    private void onStatus(final Status status) {
+      if (!LANG_JA.equalsIgnoreCase(status.getLang())) {
+        return;
+      }
+
+      final StringBuffer buffer = new StringBuffer();
+
+      if (status.isRetweet()) {
+        Status retweetedStatus = status.getRetweetedStatus();
+        buffer
+            .append(DisplayNameUtil.removeContext(status.getUser().getName()))
+            .append("さんがリツイート。");
+        buffer
+            .append(DisplayNameUtil.removeContext(retweetedStatus.getUser().getName()))
+            .append("さんから、");
+        buffer.append(retweetedStatus.getText());
+      } else {
+        buffer.append(DisplayNameUtil.removeContext(status.getUser().getName())).append("さんから、");
+        buffer.append(status.getText());
+      }
+
+      mTalker.talkAlternatelyAsync(UrlUtil.convURLEmpty(buffer).replaceAll("\n", "。"));
+    }
+  }
+}
diff --git a/src/com/mizo0203/timeline/talker/UrlUtil.java b/src/com/mizo0203/timeline/talker/UrlUtil.java
deleted file mode 100644
index d082595..0000000
--- a/src/com/mizo0203/timeline/talker/UrlUtil.java
+++ /dev/null
@@ -1,24 +0,0 @@
-package com.mizo0203.timeline.talker;
-
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-/**
- * http://chat-messenger.net/blog-entry-40.html
- */
-public class UrlUtil {
-  /** URLを抽出するための正規表現パターン */
-  private static final Pattern convURLLinkPtn = Pattern.compile(
-      "(http://|https://){1}[\\w\\.\\-/:\\#\\?\\=\\&\\;\\%\\~\\+]+", Pattern.CASE_INSENSITIVE);
-
-  /**
-   * 指定された文字列内のURLを、正規表現を使用し、 空文字列に変換する。
-   * 
-   * @param str 指定の文字列。
-   * @return リンクに変換された文字列。
-   */
-  public static String convURLEmpty(CharSequence str) {
-    Matcher matcher = convURLLinkPtn.matcher(str);
-    return matcher.replaceAll("");
-  }
-}
diff --git a/src/com/mizo0203/timeline/talker/util/DisplayNameUtil.java b/src/com/mizo0203/timeline/talker/util/DisplayNameUtil.java
new file mode 100644
index 0000000..7c7bdce
--- /dev/null
+++ b/src/com/mizo0203/timeline/talker/util/DisplayNameUtil.java
@@ -0,0 +1,15 @@
+package com.mizo0203.timeline.talker.util;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class DisplayNameUtil {
+
+  public static String removeContext(@NotNull String name) {
+    Pattern p = Pattern.compile("([^@＠]+).+");
+    Matcher m = p.matcher(name);
+    return m.replaceFirst("$1");
+  }
+}
diff --git a/src/com/mizo0203/timeline/talker/util/HTMLParser.java b/src/com/mizo0203/timeline/talker/util/HTMLParser.java
new file mode 100644
index 0000000..125c9e3
--- /dev/null
+++ b/src/com/mizo0203/timeline/talker/util/HTMLParser.java
@@ -0,0 +1,44 @@
+package com.mizo0203.timeline.talker.util;
+
+import org.apache.commons.io.IOUtils;
+import org.jetbrains.annotations.NotNull;
+
+import javax.swing.text.html.HTMLEditorKit;
+import javax.swing.text.html.parser.ParserDelegator;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.charset.Charset;
+
+public class HTMLParser {
+
+  @NotNull
+  public String parse(@NotNull String html, @NotNull Charset encoding, boolean ignoreCharSet)
+      throws IOException {
+    try (InputStreamReader r =
+        new InputStreamReader(IOUtils.toInputStream(html, encoding), encoding)) {
+      HTMLParserCallback hp = new HTMLParserCallback();
+      ParserDelegator parser = new ParserDelegator();
+      parser.parse(r, hp, ignoreCharSet);
+      return hp.getText();
+    }
+  }
+
+  /**
+   * http://www.my-notebook.net/736a69e0-820c-423b-9047-a02b8a9eefb1.html
+   *
+   * <p>HTMLParser.java
+   */
+  private static class HTMLParserCallback extends HTMLEditorKit.ParserCallback {
+    private final StringBuffer sb = new StringBuffer();
+
+    private String getText() {
+      return sb.toString();
+    }
+
+    @Override
+    public void handleText(@NotNull char[] data, int pos) {
+      sb.append(new String(data));
+      sb.append(System.getProperty("line.separator"));
+    }
+  }
+}
diff --git a/src/com/mizo0203/timeline/talker/util/RuntimeUtil.java b/src/com/mizo0203/timeline/talker/util/RuntimeUtil.java
new file mode 100644
index 0000000..54cc0f6
--- /dev/null
+++ b/src/com/mizo0203/timeline/talker/util/RuntimeUtil.java
@@ -0,0 +1,18 @@
+package com.mizo0203.timeline.talker.util;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.io.IOException;
+
+public class RuntimeUtil {
+
+  public static void execute(String[] cmdarray) {
+    try {
+      Process process = Runtime.getRuntime().exec(cmdarray);
+      process.waitFor();
+      process.destroy();
+    } catch (@NotNull IOException | InterruptedException e) {
+      e.printStackTrace();
+    }
+  }
+}
diff --git a/src/com/mizo0203/timeline/talker/util/UrlUtil.java b/src/com/mizo0203/timeline/talker/util/UrlUtil.java
new file mode 100644
index 0000000..1dd9901
--- /dev/null
+++ b/src/com/mizo0203/timeline/talker/util/UrlUtil.java
@@ -0,0 +1,26 @@
+package com.mizo0203.timeline.talker.util;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/** http://chat-messenger.net/blog-entry-40.html */
+public class UrlUtil {
+  /** URLを抽出するための正規表現パターン */
+  @SuppressWarnings("Annotator")
+  private static final Pattern convURLLinkPtn =
+      Pattern.compile(
+          "(http://|https://){1}[\\w\\.\\-/:\\#\\?\\=\\&\\;\\%\\~\\+]+", Pattern.CASE_INSENSITIVE);
+
+  /**
+   * 指定された文字列内のURLを、正規表現を使用し、 空文字列に変換する。
+   *
+   * @param str 指定の文字列。
+   * @return リンクに変換された文字列。
+   */
+  public static String convURLEmpty(@NotNull CharSequence str) {
+    Matcher matcher = convURLLinkPtn.matcher(str);
+    return matcher.replaceAll("");
+  }
+}
