ニコレポから動画情報を取得してSlackに投げる

[ナンデモアリなAdventCalendar2018](https://qiita.com/advent-calendar/2018/nandemo)の24日目の記事です。
もう25日ですが1日は誤差だと思いますまる。
なお、コード自体は[github](https://github.com/Natsuume/SlackBotClient)にあります。

# はじめに
ニコニコ動画のニコレポから動画URLを自動でSlackに投稿するbotの話です。
実はまだ完成してませんが、あとは定期的に実行するようにしたり想定外入力への対応とかで本筋じゃないし良いよねということで……。
残りの部分は修論終わったら少しずつやります。

# やりたいこと
ニコレポ、Twitterで投稿者をフォローしてるとかでなければ好きなシリーズの新着動画探すのに必須。
その割に正直ノイズが多くて鬱陶しい。
<img width="400" alt="ニコレポ" src="https://qiita-image-store.s3.amazonaws.com/0/34789/f96bfb90-6851-89ab-e3f4-fb9dd7e07493.png">
新着動画の情報がほしいだけなのに動画によっては「投稿しました」「マイリストに登録しました」「ニコニ広告しました」「n再生されました」「n位にランクインしました」とかでニコレポを埋め尽くしたりする。
ミュート機能は一応あるけど特定のユーザの特定の通知をミュートする機能なので消したい内容を消したいようには消せない。

気分としては動画情報が1度載ったら同じ動画はしばらくニコレポから排除したいというお気持ち。
なのでニコレポから動画情報だけを取得して、重複したものを削除した後にSlackに投げるbotを作りたい。

わかりにくいけど、こんな感じで重複無しで動画のURLを貼っていくbotになる。
<img width="400" alt="結果" src="https://qiita-image-store.s3.amazonaws.com/0/34789/29932a0d-08b8-2c4e-f4c3-783e8cbd9cd0.png">

# 実装
正直Hello Worldに毛が生えた程度なので書くことがない。

## HttpClient
実のところやりたいことは半分おまけで主な目的はJava11で追加されたHttpClientまわりのクラスを使ってみることだった。
JavaでHttpRequestを送る場合ちょっと前まではHttpURLConnectionをこねくり回すかApache Commonsとかのライブラリを使う感じだったけど、Java11からHttpClientが追加された。

微妙に省略してるけど適当にbuilderにheaderの値放り込んでbuildして送ればOK。
HttpURLConnectionみたいになんかopenしてキャストしてほげほげみたいなのと比べると非常にわかりやすい。
Java11使ってるならライブラリ使わなくても問題なく書ける気がする。

``` HttpClient.java
	private Map<String, String> headers;
	private HttpClient httpClient;

	public HttpResponse<String> sendPost(String messages) throws IOException, InterruptedException {
		Builder builder = HttpRequest.newBuilder(URI.create(uri));
		headers.entrySet().stream()
				.forEach(entry -> builder.header(entry.getKey(), entry.getValue()));
		builder.POST(BodyPublishers.ofString(messages));
		return httpClient.send(builder.build(), BodyHandlers.ofString());
	}
```

## Slack RTM API

Slack側が予め用意しているconnect用のurlにpost投げて返ってきたwssにWebSocketつなぐだけ。
定期的にPing送らないと接続切れるらしいのでとりあえず雑に1秒ごとにping投げる。

``` SlackClient.java
public class SlackClient{
	
	private ConnectionInfo connectionInfo;
	private SlackListener listener = new SlackListener();
	private ResponseProcessor processor = new ResponseProcessor();
	private SlackSpeaker speaker;
	private String token;

	public SlackClient(String token) {
		this.token = token;
	}

	public static void main(String[] args) {
		if(args.length != 1)
			return;
		SlackClient client = new SlackClient(args[0]);
		try {
			client.start();
		} catch (IOException | InterruptedException | ExecutionException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
	}
	
	private boolean connect() throws IOException, InterruptedException {
		Map<String, String> headers =  Map.of("Content-type", "application/x-www-form-urlencoded");
		Map<String, String> postMessages = Map.of("token", token);
		SimpleHttpClient httpClient = new SimpleHttpClient(SlackAPI.CONNECT.getURIText(), headers, postMessages);
		
		HttpResponse<String> response = httpClient.sendPost();
		
		Gson gson = new Gson();
		ConnectionInfo connectionInfo = 
				gson.fromJson(response.body(), ConnectionInfo.class);
		this.connectionInfo = connectionInfo;
		System.out.println(gson.toJson(connectionInfo));
		
		return connectionInfo.isSucceed();
	}
	
	private WebSocket createWebSocket() throws InterruptedException, ExecutionException {
		HttpClient client = HttpClient.newHttpClient();
		CompletableFuture<WebSocket> future = client
				.newWebSocketBuilder()
				.buildAsync(URI.create(connectionInfo.getURI()), listener);
		return future.get();
	}
	
	public void start() throws IOException, InterruptedException, ExecutionException {
		connect();
		
		speaker = new SlackSpeaker(createWebSocket());
		listener.subscribe(processor);
		processor.subscribe(speaker);
		
		while(true) {
			speaker.sendPing();
			Thread.sleep(1000);;
		}
	}
}
```

slackからのメッセージの受信、メッセージの内容の処理、メッセージの送信はクラスを分けたくなったので分けて実装。

この辺の実装にはJava9から導入されたjava.util.concurrent.Flowを使用。
Publisher, Processor, Subscriberは正直良くわからずに雰囲気で使っている節があるけど、雑に実装しても非同期でPublisher-Subscriberパターン的に処理できるので便利。

WebSocketのListenerインターフェースも雰囲気的には同じ感じだったので取っつきやすかった。

``` SlackListener.java
public class SlackListener extends SubmissionPublisher<String> implements Listener{
	private List<CharSequence> messageParts = new ArrayList<>();
	private CompletableFuture<?> accumulatedMessage = new CompletableFuture<>();
	
	@Override
	public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last){
		messageParts.add(data);
		webSocket.request(1);
		if(last) {
			submit(String.join("", messageParts));
			messageParts = new ArrayList<>();
			accumulatedMessage.complete(null);
			CompletionStage<?> cf = accumulatedMessage;
			accumulatedMessage = new CompletableFuture<>();
			return cf;
		}
		return accumulatedMessage;
	}
	
	@Override
	public void onError(WebSocket webSocket, Throwable error) {
		error.printStackTrace();
	}
}

```

将来的にはニコレポ投げる機能以外にも色々追加したいよねというお気持ちでメッセージの処理クラス。
botが投稿するchannelの取得もここでやっている。

channelの取得はconversations.listにget投げる方法もある。
今回はslackからの送られてくるメッセージのJsonに普通にchannelIdが含まれてたので「そっちから引っ張ったほうが楽では？」となったのでユーザが特定のCommandを入力したChannelへbotが投稿するように実装。

ニコニコのログインに必要なメールアドレスとパスワードもなんとなくSlackのメッセージ経由で渡すことに。
複垢とか別のログイン必要なやつを繋げたりするときにこっちのほうが楽そうだったので。

``` ResponseProcessor.java
public class ResponseProcessor extends SubmissionPublisher<TransmissionMessage> 
		implements Processor<String, TransmissionMessage> {
	private Subscription subscription;
	private List<String> activeChannels = Collections.synchronizedList(new ArrayList<>());
	private NiconicoClient niconicoClient = new NiconicoClient(this::sendMessage);
	private Gson gson = new Gson();
	
	@Override
	public void onSubscribe(Subscription subscription) {
		this.subscription = subscription;
		subscription.request(1);
	}

	@Override
	public void onNext(String message) {
		System.out.println("onMessage : " + message);
		MessageType type = convertType(message);
		
		switch(type) {
		case MESSAGE:
			processMessage(message);
			break;
		case LOG:
			processLog(message);
			break;
		}
		subscription.request(1);
	}
	
	private void sendMessage(String message) {
		activeChannels.parallelStream()
				.map(channel -> new TalkMessage(message, channel))
				.forEach(this::submit);
	}
	
	private void processLog(String message) {
		LogMessage log = gson.fromJson(message, LogMessage.class);
		if(log.isOk())
			return;
		System.err.println(log.getError());
	}
	
	private void processMessage(String message) {
		ResponseMessage response = gson.fromJson(message, ResponseMessage.class);
		String text = response.getText();
		if(text != null && text.startsWith("command:")) {
			processCommand(text.split("(^command): *")[1], response.getChannel());
			return;
		}
	}
	
	private void processCommand(String command, String channel) {
		String[] array = command.split(" ");
		switch(array[0]) {
		case "activate":
			switch(array[1]) {
			case "bot":
				activeChannels.add(channel);
				sendMessage("このchannelへの投稿を行います。");
				break;
			case "nicorepo":
				if(activeChannels.isEmpty())
					break;
				try {
					String email = array[2].split("\\|")[0].split(":")[1];
					niconicoClient.login(email, array[3]);
					niconicoClient.getMyRepoData();
				} catch (IOException | InterruptedException e) {
					// TODO Auto-generated catch block
					e.printStackTrace();
				}
				break;
			}
			break;
		}
	}
	
	private MessageType convertType(String message) {
		try {
			InputStream inputStream = new ByteArrayInputStream(message.getBytes("utf-8"));
			JsonReader reader = new JsonReader(new InputStreamReader(inputStream,"utf-8"));
			reader.beginObject();
			while(reader.hasNext()) {
				switch(reader.nextName()) {
				case "type":
					return MessageType.toMessageType(reader.nextString());
				case "ok":
					return MessageType.LOG;
				}
				break;
			}
			inputStream.close();
			reader.close();
		} catch (IOException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
		return MessageType.OTHER;
	}

	@Override
	public void onError(Throwable throwable) {
		// TODO Auto-generated method stub
		throwable.printStackTrace();
	}

	@Override
	public void onComplete() {
		// TODO Auto-generated method stub
		
	}
}
```

メッセージの送信クラスに関しては特筆して言うことがないのでカット。

## ニコニコ動画API

基本はSlackのconnectと同じ感じ。
ニコニコ動画のログインはログイン処理後自動でリダイレクトしてログインした時のセッション情報が消し飛ぶので、ログイン処理ではRedirect.NEVERに設定してリダイレクトせずにCookieを取得する必要がある。
マイレポのエンドポイントはググっても出てこなかったのでブラウザのデベロッパーツール開きながらマイページ開いたりして[それっぽいエンドポイント](http://www.nicovideo.jp/api/nicorepo/timeline/my/all?client_app=pc_myrepo)を探した。

``` NiconicoClient.java
public class NiconicoClient {
	
	private LoginInfo loginInfo;
	private HttpCookie cookie;
	private Deque<NiconicoReport> reports = new ArrayDeque<>();
	private long latestReportId = 0;
	private Consumer<String> sendMessage;
	
	public NiconicoClient(Consumer<String> sendMessage) { 
		this.sendMessage = sendMessage;
	}
	
	public boolean login(String mail, String password) throws IOException, InterruptedException {
		loginInfo = new LoginInfo(mail, password);
		Map<String, String> headers = Map.of("Content-type", "application/x-www-form-urlencoded");
		Map<String, String> postMessages = Map.of("next_url", "",
				"mail", loginInfo.getMail(), 
				"password", loginInfo.getPassword());
		SimpleHttpClient httpClient = 
				new SimpleHttpClient(NiconicoAPI.LOGIN.getURIText(), Redirect.NEVER, headers, postMessages);
		HttpResponse<String> response = httpClient.sendPost();
		
		if(!httpClient.isPresentCookieHandler())
			return false;
		CookieStore store = ((CookieManager)httpClient.getCookieHandler()).getCookieStore();
		cookie = store.getCookies().stream()
				.filter(cookie -> cookie.getName().equals("user_session") &&
						!cookie.getValue().equals("deleted"))
				.findAny().orElse(null);
		loginInfo.setSession(cookie.toString());
		return loginInfo.isLogin();
	}
	
	public void getMyRepoData() throws IOException, InterruptedException {
		Map<String, String> headers = Map.of("Cookie", loginInfo.getCookie());
		SimpleHttpClient httpClient = 
				new SimpleHttpClient(NiconicoAPI.MYREPO.getURIText(), Redirect.ALWAYS, headers, Map.of());
		HttpResponse<String> response = httpClient.sendGet();

		MyRepoData data = new Gson().fromJson(response.body(), MyRepoData.class);
		List<NiconicoReport> list = data.getReports().stream()
				.filter(report -> report.getLongId() > latestReportId)
				.sorted(Comparator.comparingLong(NiconicoReport::getLongId))
				.collect(Collectors.toList());
		latestReportId = list.get(list.size()-1).getLongId();
		String watchUri = NiconicoAPI.WATCH_PAGE.getURIText();
		list.stream()
				.filter(report -> report.getVideo() != null)
				.map(report -> report.getVideo().getWatchId())
				.distinct()
				.forEach(id -> sendMessage.accept(watchUri + id));
		
	}
}
```

でマイレポから取得したものですでに投稿されているかどうかのチェックと重複チェックをしたあとにURLを投げると最初に上げた画像のようにSlackにマイレポの動画URLを重複無しで投稿できる。

## 実装終わってないところ
処理の大部分を昨日から一晩で書いたが色々抜けがあったり実装終わってなかったりする。

今は1回しか取得してないが、当然のことながら1度有効化したら定期的に実行して自動で投げるようにしたい。
あとは基本的に自分だけの使用を前提にしているので例外処理は適当（ちょっと変なCommand投げたらたぶん止まる）。
将来的にはcommandも自然言語でやり取りできるようにしたいところ。

とりあえず最後以外は気が向いたときにチョイチョイで終わる範囲なので修論終わったら手を付けたい。

# 個人的ハマりポイント
SlackのAPI、基本的に不正なメッセージ送るとちゃんとエラーが帰ってくる(channel IDが違うとか)が、そもそも送るメッセージが形式に則ってないと(Json形式ですら無いとかだと)エラーメッセージすら返ってこない。
connectまで実装したし空文字でも送ってエラーがちゃんと返ってくるか試そう、とかやってしばらく引っかかった。
WebSocketでちゃんとやり取りできているかの確認はおとなしくwscatとか最小構成で試そう。

NiconicoのAPIで情報を取得してとりあえずSystem.out.printlnで出力してちゃんと取れてるか確認、と思ったら何故か文面が表示されないことがあった。
実際のところ内容自体は普通に取得できてたので全部Eclipseのクソ長文投げると表示がバグるコンソールが悪い。