ルモーリン

Mojolicious用ツイートモジュール

投稿:2022-05-17

ホームページに記事を追加した際にツイッターで告知するのを自動化したい。

  • モジュールが使うpathはランダム生成したものです。
  • 初期設定は1度きりでtwitter.yamlに保存します。
  • トークンは随時更新し、その都度twitter.yamlに保存します。
  • 操作内容がログに残りますから情報漏れにご注意ください。
  • 初期設定を変更する場合はtwitter.yamlを削除してmojoliciousを再起動してください。
  • 機能の性格上、ツイッターへアクセスします。
  • 開発中に全くありませんでしたがバグにより過多アクセスを行う可能性を覚悟してご利用ください。
  • セキュリティが心配な人はコードが何やってるか上から下までよく読みましょう(単一ファイルで600行程度)。
  • ん、私の情報は残ってないよな💦

下記のモジュールをダウンロード、Mojoliciousでプラグインとしてロードしてください。 ログにパスが表示されますから、ブラウザで開くと設定画面が表示されます。 あとは説明を読んでください(手抜き)。

# Myapp::startupの中で
$self->plugin("TwitterTweetAPIv2");
# ログに生成したパスが表示されます
[2022-05-16 23:20:52.43703] [9990] [info] TwitterTweetAPIv2 '<サイトのURL>/On6a8Fcu6voGHx9YusQW4vy8eKGpAmHT'で設定画面が開きます
package TwitterTweetAPIv2;
use Mojo::Base "Mojolicious::Plugin";

use Digest::SHA qw/ sha256_base64 /;
use Mojo::JSON qw/ encode_json decode_json /;
use Mojo::URL;
use Mojo::UserAgent;
use Mojo::Util qw/ b64_encode /;
use YAML qw/ LoadFile DumpFile /;

use constant YAML_FILE => "twitter.yaml";

my @path_dispatch = (
	[ get => "" => \&index, ],
	[ get => certification => \&certification, ],
	[ get => redirect => \&redirect, ],
	[ post => refresh_token => \&refresh_token, ],
	[ post => tweet => \&tweet, ],
	[ post => preferences => \&preferences, ],
	[ post => remove_preferences => \&remove_preferences, ],
);

sub register {
	my ($self, $app, $conf) = @_;

	my $yaml = inside_load($app->log, YAML_FILE);
	if (!$yaml || !exists $yaml->{path_base}) {
		$yaml->{path_base} = "/" . inside_random_string($self);
		$yaml->{update} = 1;
		$app->log->info("@{[__PACKAGE__]} '<サイトのURL>$yaml->{path_base}'で設定画面が開きます");
	}

	my $r = $app->routes;
	my $base = $r->under($yaml->{path_base} => sub {
		my $c = shift;
		my $yaml = inside_load($c->app->log, YAML_FILE);
		return 1 if $yaml;

		$c->reply->not_found;
		return undef;
	});

	for (@path_dispatch) {
		if ("get" eq $_->[0]) {
			$base->get($_->[1])->to(cb => $_->[2]);
		} elsif ("post" eq $_->[0]) {
			$base->post($_->[1])->to(cb => $_->[2]);
		}
	}

	$app->helper(TwitterTweet => \&twittertweet);

	inside_save($app->log, $yaml, YAML_FILE);
}

sub twittertweet {
	my $self = shift;
	my ($text) = @_;

	my $yaml = inside_load($self->app->log, YAML_FILE);
	return 0 if !$yaml || !exists $yaml->{access_token};

	# ツイートする
	my $tweet_id = inside_tweet($self, $yaml, $text);
	# ツイート失敗した場合は英文メッセージが戻る
	if ($tweet_id !~ /^\d+$/) {
		# リフレッシュトークンとアクセストークンのリフレッシュが成功
		if (inside_refresh_token($self, $yaml)) {
			# もう一度ツイート
			$tweet_id = inside_tweet($self, $yaml, $text)
		}
	}

	return $tweet_id;
}

sub inside_random_string {
	my $self = shift;

	use constant BASE62_CHAR => "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890";
	my @base62_char = split //, BASE62_CHAR;

	my $rand_str;
	srand(time);
	$rand_str .= $base62_char[rand @base62_char] for 1 .. 32;

	return $rand_str;
}

sub index {
	my $self = shift;

	my $explaination = <<EOF;
<h2>
	説明
</h2>
<div>
<ul>
	<li>このpathはランダム生成したものです。</li>
	<li>初期設定は1度きりで@{[YAML_FILE]}に保存します。</li>
	<li>トークンは随時更新し、その都度@{[YAML_FILE]}に保存します。</li>
	<li>操作内容がログに残りますから情報漏れにご注意ください。</li>
	<li>初期設定を変更する場合は@{[YAML_FILE]}を削除してmojoliciousを再起動してください。</li>
	<li>機能の性格上、ツイッターへアクセスします。</li>
	<li>開発中に全くありませんでしたがバグにより過多アクセスを行う可能性を覚悟してご利用ください。</li>
	<li>セキュリティが心配な人はコードが何やってるか上から下までよく読みましょう(単一ファイルで600行程度)。</li>
	<li>ん、私の情報は残ってないよな&#x1f4a6;</li>
</ul>
</div>
EOF
	my $yaml = inside_load($self->app->log, YAML_FILE);

	my $html;
	if (!exists $yaml->{client_id} || !exists $yaml->{client_secret}) {
		$html = inside_body($self, "ツイッター初期設定", <<EOF);
$explaination
<h2>
	ツイッターのダッシュボード
</h2>
<div>
<p>
<ul>
	<li>ツイッターアカウントの取得、開発者登録、アプリケーション登録が済んでいることが前提です。</li>
	<li>別のタブで<a href="https://developer.twitter.com/en/portal/dashboard" target="_blank">Twitter Developers</a>を開き、ダッシュボード→アプリケーション→Settings→User authentication settingsに以下を設定してください。</li>
	<li>OAuth2.0を「on」にする。</li>
	<li>Type of Appを「Automated App or bot」にする。</li>
	<li>Callback URI / Redirect URLに「<p id="site_url_1" style="display: inline;"></p>」を設定する。<input type="button" value="URLをコピー" onClick="copy_url('site_url_1');"></li>
	<li>Website URLに&lt;ツイートを見た他のアカウントがツイッタークライアントを辿って閲覧するURL&gt;を設定する。</li>
</ul>
</p>
</div>
<h2>
	このサイト
</h2>
<div>
<p>
下記に設定した項目を@{[YAML_FILE]}に保存します。
外部に漏らさないでください。
</p>
<form method="post" action="$yaml->{path_base}/preferences">
<pre>
ダッシュボードの設定をこのサイトに設定します。
Settings
	User authentication settings
		general authentication settings
			Callback URI / Redirect URLのホスト(このサイトなので変更不可です)
				<input type="text" name="redirect_uri" id="site_url_2" value="" size="50" readonly required>

Keys and tokens
	OAuth 2.0 Client ID and Client Secret
		Client ID
			<input type="text" name="client_id" value="" placeholder="client_id" size="50" required>

		Client Secret(クライアント作成時のみ表示。コピーがなければGenerateで再作成してください)
			<input type="text" name="client_secret" value="" placeholder="client_secret" size="60" required>

<input type="submit" value="設定">
</pre><!-- 配布サイトでのフォーマット調整用コメント -->
</form>
</div>
<script>
document.getElementById("site_url_1").innerHTML = location.href + "/redirect";
document.getElementById("site_url_2").value = location.protocol + "//" + location.host;

function copy_url(id) {
	var t = document.createElement('textarea');
	t.innerHTML = document.getElementById(id).innerHTML;
	document.body.appendChild(t);
	t.select();
	document.execCommand('copy');
	document.body.removeChild(t);
	alert("URLをコピーしました");
}
</script>
EOF
	} else {
		my $certification = exists $yaml->{refresh_token} ? "&#x2b55;" : "&#x274c;";

		use constant {
			CHECKBOX_CLEAR => "&#x274c;",
			CHECKBOX_CHECKED => "&#x2705;",
		};
		my $state;
		for (
			{ key => "path_base", name => "このページのパス", },
			{ key => "redirect_uri", name => "このページのホスト", },
			{ key => "client_id", name => "クライアント ID", },
			{ key => "client_secret", name => "クライアント シークレット", },
			{ key => "refresh_token", name => "リフレッシュトークン", },
			{ key => "access_token", name => "アクセストークン", },
		) {
			my $check_box = exists $yaml->{$_->{key}} ? CHECKBOX_CHECKED : CHECKBOX_CLEAR;
			$state .= "$check_box $_->{name}<br />\n";
		}

		$html = inside_body($self, "ツイッターのテスト", <<EOF);
$explaination
<h2>
	状態
</h2>
<div>
<p>
@{[YAML_FILE]}に保存する項目です(@{[CHECKBOX_CHECKED]}保存済/@{[CHECKBOX_CLEAR]}未保存)。<br />
$state
</p>
</div>
<h2>
	認証設定
</h2>
<div>
<ul>
	<li>アカウントにサインアップした状態で下のボタンをクリックしてください。</li>
	<li>ツイッターの画面でアプリケーションにツイート操作を承認するかどうかの認証画面が表示されます。</li>
	<li>「アプリにアクセスを許可」をクリック後にこのサイトへリダイレクトされます。</li>
	<li>認証した場合はリフレッシュトークンとアクセストークンを生成し、@{[YAML_FILE]}に保存します。</li>
	<li>キャンセルした場合は保存済のリフレッシュトークンとアクセストークンを削除します。</li>
</ul>
<p>
$certification 認証
</p>
<form method="get" action="$yaml->{path_base}/certification">
	<input type="submit" value="認証設定">
</form>
</div>
<h2>
	ツイート
</h2>
<div>
<ul>
	<li>サイトのサーバーからヘルパー関数TwitterTweetでツイートします。</li>
	<li>同じツイートを続けるとリジェクトされます(同一ツイートの禁止)。</li>
	<li>ツイートはアクセストークンを利用します。</li>
	<li>下端の結果にツイートIDを表示します。</li>
</ul>
<input type="text" id="tweet" name="text" value="たこルカは俺の嫁、ルカ姐さんも俺の嫁" size="50" placeholder="ツイート" required>
<input type="button" value="ツイート" onClick="go_tweet();"><br />
</div>
<h2>
	トークンをリフレッシュ
</h2>
<div>
<ul>
	<li>ツイート時にアクセストークンが失効していれば、自動的にリフレッシュトークンとアクセストークンをリフレッシュしています。</li>
	<li>このボタンはツイートなしに2つのトークンをリフレッシュし、@{[YAML_FILE]}に保存します。</li>
	<li>下端の結果にリフレッシュ結果を表示します。</li>
</ul>
<input type="button" value="トークンをリフレッシュ" onClick="com_post('$yaml->{path_base}/refresh_token');"><br />
</div>
<h2>
	設定ファイルを削除(初期化)
</h2>
<div>
<p>
twitter.yamlを削除して最初からやり直します。
</p>
<form method="post" action="$yaml->{path_base}/remove_preferences" onSubmit="return confirm('設定を削除します。');">
<input type="submit" value="設定を削除">
</form>
</div>
<h2>
	結果
</h2>
<div>
<p id="result"></p>
</div>
<script>
function set_result(msg) {
	document.getElementById("result").innerHTML = msg;
}

function com_post(url, fd = new FormData()) {
	set_result("送信中...");

	let xhr = new XMLHttpRequest();
	xhr.open("POST", url, true);
	xhr.responseType = "text";
	xhr.addEventListener("load", function(ev) {
		if (200 == ev.target.status) {
			set_result(xhr.response);
		} else {
			set_result("送信が失敗しました(" + ev.target.status + ")");
		}
		delete xhr;
	});

	xhr.send(fd);
}

function go_tweet() {
	let text = document.getElementById("tweet").value;
	let fd = new FormData();
	fd.append("text", text);
	com_post("$yaml->{path_base}/tweet", fd);
}
</script>
EOF
	}

	inside_save($self->app->log, $yaml, YAML_FILE);

	return $self->render(text => $html);
}

# 設定
sub preferences {
	my $self = shift;

	my $param = $self->req->params->to_hash;
	my $html;
	my $yaml = inside_load($self->app->log, YAML_FILE);
	if (!exists $param->{redirect_uri} || !exists $param->{client_id} || !exists $param->{client_secret}) {
		$html = inside_html($self, $yaml, "クライアント情報", "設定できません");
	} else {
		$yaml->{$_} = $param->{$_} for qw/ redirect_uri client_id client_secret /;
		$yaml->{update} = 1;

		$html = inside_html($self, $yaml, "クライアント情報", "設定しました");
	}

	inside_save($self->app->log, $yaml, YAML_FILE);

	return $self->render(text => $html);
}

# 設定ファイルを削除
sub remove_preferences {
	my $self = shift;

	my $yaml = inside_load($self->app->log, YAML_FILE);

	unlink YAML_FILE;

	if ($yaml) {
		my $r = $self->app->routes;
		my $path_base = $yaml->{path_base} =~ s#/##gr;
		my $base = $r->find($path_base);
		for ("", qw/ certification redirect refresh_token tweet preferences remove_preferences /) {
			$base->find($_)->remove;
		}
		$base->remove;
	}

	my $html = inside_body($self, "設定を削除", <<EOF);
<h2>
	処理結果
</h2>
<div>
<ul>
	<li>@{[YAML_FILE]}を削除しました。</li>
	<li>このページが表示されているパスは無効になりました。</li>
</ul>
</div>
<h2>
	このあとは
</h2>
<div>
<ul>
	<li>Mojoliciousを再起動するとパスを再作成します。</li>
	<li>ログに表示されるパスから初期設定を開いてください。</li>
</ul>
</div>
EOF
	return $self->render(text => $html);
}

# ツイッター社の認証ページへ飛ぶ
sub certification {
	my $self = shift;

	my $yaml = inside_load($self->app->log, YAML_FILE);

	$yaml->{state} = $self->random_string(length => 32);
	$yaml->{update} = 1;
	$yaml->{verifier} = $self->random_string(length => 64);
	$yaml->{update} = 1;

	my $challenge = sha256_base64 $yaml->{verifier};
	$challenge =~ tr#+/#-_#;
	$challenge =~ s/=+$//;
	$yaml->{update} = 1;

	my $url = Mojo::URL->new;
	$url->scheme("https");
	$url->host("twitter.com");
	$url->path("i/oauth2/authorize");
	$url->query(
		response_type => "code",
		client_id => $yaml->{client_id},
		redirect_uri => "$yaml->{redirect_uri}$yaml->{path_base}/redirect",
		scope => "users.read tweet.read tweet.write offline.access",
		state => $yaml->{state},
		code_challenge => $challenge,
		code_challenge_method => "S256",
	);

	inside_save($self->app->log, $yaml, YAML_FILE);

	# ツイッター社の認証ページで認証/拒否するとredirect_uriのページにリダイレクトされる
	return $self->redirect_to($url);
}

# ツイッター社の認証ページからリダイレクト
sub redirect {
	my $self = shift;

	my $param = $self->req->params->to_hash;

	my $yaml = inside_load($self->app->log, YAML_FILE);

	# 不正クエリ
	return $self->reply->not_found if $yaml->{state} ne $param->{state};
	delete $yaml->{state};

	my $result;
	if (!exists $param->{code}) {
		# アカウントが拒否した場合

		# 保存済のトークンを削除
		delete $yaml->{refresh_token};
		delete $yaml->{access_token};
		$yaml->{update} = 1;

		my $message = <<EOF;
アカウントが認証を拒否しました。<br />
<br />
結果:$param->{error}
EOF
		$result = inside_html($self, $yaml, "認証拒否", $message);
	} elsif ($result = inside_generate_token($self, $yaml, $param->{code})) {
		my $message = <<EOF;
トークン取得でエラーが発生しました。<br />
結果<br />
<hr />
$result
<hr />
EOF
		$result = inside_html($self, $yaml, "トークン生成エラー", $message);
	} else {
		$result = inside_html($self, $yaml, "トークン生成", "リフレッシュトークンとアクセストークンを生成しました");
	}

	inside_save($self->app->log, $yaml, YAML_FILE);

	return $self->render(text => $result);
}

sub refresh_token {
	my $self = shift;

	my $yaml = inside_load($self->app->log, YAML_FILE);

	return $self->render(text => "リフレッシュトークンがありません。認証してください") if !exists $yaml->{refresh_token};

	my $result;
	if (inside_refresh_token($self, $yaml)) {
		$result = "リフレッシュ成功";
	} else {
		$result = "リフレッシュ失敗";
	}

	inside_save($self->app->log, $yaml, YAML_FILE);

	return $self->render(text => $result);
}

sub tweet {
	my $self = shift;

	my $yaml = inside_load($self->app->log, YAML_FILE);
	return $self->render(text => "アクセストークンがありません") if !exists $yaml->{access_token};

	my $text = $self->param("text");
	my $tweet_id = $self->TwitterTweet($text);

	my $result;
	if ($tweet_id =~ /^\d+$/) {
		$result = "ツイート成功(id: $tweet_id)";
	} else {
		$result = "ツイート失敗($tweet_id)";
	}

	inside_save($self->app->log, $yaml, YAML_FILE);

	return $self->render(text => $result);
}

sub inside_body {
	my $self = shift;
	my ($title, $body) = @_;

	return <<EOF;
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>$title</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type="text/css">
h1 {
	margin: 0px;
	padding: 5px;
	color: lightskyblue;
	background-color: lavenderblush;
}

h2 {
	margin: 0px 0px 0px 10px;
	padding: 5px;
	color: green;
	background-color: lavenderblush;
}

p {
	margin: 0px;
}

ul, ol {
	margin: 0px;
	padding: 0px 0px 5px 15px;
}

h2+div {
	margin: 0px 10px 10px 20px;
	padding: 5px 0px 10px 10px;
	background-color: lightyellow;
}
</style>
</head>
<body>
<h1>
	$title
</h1>
<div>
$body
</div>
</body>
</html>
EOF
}

sub inside_html {
	my $self = shift;
	my ($yaml, $title, $message) = @_;

	my $path_base = "";
	$path_base = $yaml->{path_base} if $yaml && exists $yaml->{path_base};

	return inside_body($self, $title, <<EOF);
<h2>
$message
</h2>
<div>
下のリンクから元のページに戻ってください。<br />
<br />
<a href="$path_base">ツイッターのテスト</a><br />
</div>
EOF
}

sub inside_generate_token {
	my $self = shift;
	my ($yaml, $code) = @_;

	my $confidential_client_auth_header = inside_confidential_client_auth_header($self, $yaml);

	my $verifier = $yaml->{verifier};
	delete $yaml->{verifier};
	$yaml->{update} = 1;

	my $ua = Mojo::UserAgent->new;
	my $res = $ua->post(
		"https://api.twitter.com/2/oauth2/token",
		{
			Authorization => "Basic $confidential_client_auth_header",
		},
		form => {
			code => $code,
			grant_type => "authorization_code",
			redirect_uri => "$yaml->{redirect_uri}$yaml->{path_base}/redirect",
			code_verifier => $verifier,
		},
	)->result;

	my $result = "";
	if ($res->is_success) {
		my $json = $res->json;
		$yaml->{refresh_token} = $json->{refresh_token} if exists $json->{refresh_token};
		$yaml->{access_token} = $json->{access_token} if exists $json->{access_token};
		$yaml->{update} = 1;
	} else {
		$result = $res->text;
		$result = $res->message if $result =~ /<!doctype html>/i;
	}

	return $result;
}

sub inside_confidential_client_auth_header {
	my $self = shift;
	my ($yaml) = @_;

	my $confidential_client_auth_header = b64_encode "$yaml->{client_id}:$yaml->{client_secret}", "";
	chomp $confidential_client_auth_header;

	return $confidential_client_auth_header;
}

sub inside_refresh_token {
	my $self = shift;
	my ($yaml) = @_;

	my $confidential_client_auth_header = inside_confidential_client_auth_header($self, $yaml);

	my $ua = Mojo::UserAgent->new;
	my $res = $ua->post(
		"https://api.twitter.com/2/oauth2/token",
		{
			Authorization => "Basic $confidential_client_auth_header",
		},
		form => {
			refresh_token => $yaml->{refresh_token},
			grant_type => "refresh_token",
		},
	)->result;

	if ($res->is_success) {
		my $json = $res->json;
		$yaml->{refresh_token} = $json->{refresh_token} if exists $json->{refresh_token};
		$yaml->{access_token} = $json->{access_token} if exists $json->{access_token};
		$yaml->{update} = 1;

		return 1;
	}

	return 0;
}

sub inside_tweet {
	my $self = shift;
	my ($yaml, $text) = @_;

	my $ua = Mojo::UserAgent->new;
	my $res = $ua->post(
		"https://api.twitter.com/2/tweets",
		{
			Authorization => "Bearer $yaml->{access_token}",
		},
		json => {
			text => $text,
		},
	)->result;

	my $json = $res->json;
	return $json->{data}->{id} if $res->is_success;
	return $json->{detail};
}

sub inside_load {
	my $log = shift;
	my ($filename) = @_;

	my $yaml;
	$yaml = LoadFile($filename) if -e $filename;

	return $yaml;
}

sub inside_save {
	my $log = shift;
	my ($yaml, $filename) = @_;

	if (defined $yaml && $yaml->{update}) {
		delete $yaml->{update};
		DumpFile($filename, $yaml);
	}
}

1;