Group
Extension

Cmd-Dwarf/share/app/lib/Dwarf/Module/SocialMedia/Twitter.pm

package Dwarf::Module::SocialMedia::Twitter;
use Dwarf::Pragma;
use parent 'Dwarf::Module';
use Dwarf::HTTP::Async;
use Data::Dumper;
use DateTime;
use DateTime::Format::HTTP;
use Digest::SHA qw//;
use Encode qw/encode_utf8/;
use HTTP::Request::Common;
use HTTP::Response;
use JSON;
use LWP::UserAgent;
use Net::OAuth;

use Dwarf::Accessor qw/
	ua ua_async urls
	key secret
	request_token request_token_secret
	access_token access_token_secret
	user_id screen_name name profile_image
	on_error
/;

$Net::OAuth::PROTOCOL_VERSION = Net::OAuth::PROTOCOL_VERSION_1_0A;

sub init {
	my $self = shift;
	my $c = $self->c;

	$self->{ua} ||= LWP::UserAgent->new(
		timeout => 120
	);

	$self->{ua_async} ||= Dwarf::HTTP::Async->new;

	$self->{urls} ||= {
		api            => 'https://api.twitter.com/1.1',
		request_token  => 'https://api.twitter.com/oauth/request_token',
		authentication => 'https://api.twitter.com/oauth/authenticate',
 		authorization  => 'https://api.twitter.com/oauth/authorize',
		access_token   => 'https://api.twitter.com/oauth/access_token',
	};

	$self->{on_error} ||= sub { die @_ };
}

sub _build_user_id {
	my $self = shift;
	$self->init_user unless defined $self->{user_id};
	return $self->{user_id};
}

sub _build_screen_name {
	my $self = shift;
	$self->init_user unless defined $self->{screen_name};
	return $self->{screen_name};
}

sub _build_name {
	my $self = shift;
	$self->init_user unless defined $self->{name};
	return $self->{name};
}

sub _build_profile_image {
	my $self = shift;
	$self->init_user unless defined $self->{profile_image};
	return $self->{profile_image};
}

sub init_user {
	my $self = shift;
	my $user = $self->show_user;
	$self->{user_id}       = $user->{id};
	$self->{screen_name}   = $user->{screen_name};
	$self->{name}          = encode_utf8($user->{name});
	$self->{profile_image} = encode_utf8($user->{profile_image_url});
}

sub authorized {
	my ($self, $will_die) = @_;
	$will_die ||= 1;
	my $authorized = defined $self->access_token && defined $self->access_token_secret;
	if ($will_die && !$authorized) {
		$self->on_error("Unauthorized");
	}
	return $authorized;
}

sub is_login {
	my ($self, $check_connection) = @_;

	return 0 unless $self->authorized;
	return 1 unless $check_connection;

	my $data;
	eval {
		$data = $self->show_user;
	};
	if ($@) {
		warn $@;
	}

	my $is_login = 0;
	if (ref $data eq 'HASH') {
		$is_login = 1;
		$self->{user_id}       = $data->{id};
		$self->{screen_name}   = $data->{screen_name};
		$self->{name}          = encode_utf8($data->{name});
		$self->{profile_image} = encode_utf8($data->{profile_image_url});
	}

	return $is_login;
}

sub show_user {
	my ($self, $id) = @_;
	$id ||= $self->{user_id};

	my $data;
	unless ($self->{user_id}) {
		$data = $self->call(
			'account/verify_credentials',
			'GET'
		);
	} else {
		# accout/verify_credentials を節約するために
		# users/lookup で代替出来るケースでは代替する
		$data = $self->call('users/lookup', 'POST', { user_id => $id });
		if (ref $data eq 'ARRAY') {
			$data = $data->[0];
		}
	}

	return $data;
}

sub publish {
	my ($self, $message) = @_;
	$self->call('statuses/update', 'POST', { status => $message });
}

sub reply {
	my ($self, $in_reply_to_status_id, $message, $screen_name) = @_;
	$message = "@" . $screen_name . " " . $message if defined $screen_name;
	$self->call('statuses/update', 'POST', {
		status                => $message,
		in_reply_to_status_id => $in_reply_to_status_id,
	});
}

sub upload {
	my ($self, $src, $message) = @_;

	my $url = $self->urls->{api} . '/statuses/update_with_media.json';

	my $oauth = Net::OAuth->request('protected resource')->new(
		version          => '1.0',
		request_url      => $url,
		request_method   => 'POST',
		token            => $self->access_token,
		token_secret     => $self->access_token_secret,
		consumer_key     => $self->key,
		consumer_secret  => $self->secret,
		signature_method => 'HMAC-SHA1',
		timestamp        => time,
		nonce            => Digest::SHA::sha1_base64(time . $$ . rand),
	);
	$oauth->sign;

	my $req = POST($url,
		Content_type  => 'multipart/form-data',
		Authorization => $oauth->to_authorization_header,
		Content       => [
			status    => $message,
			'media[]' => [ $src ]
		],
	);
	my $res = $self->ua->request($req);

	return $self->validate($res);
}

sub send_dm {
	my ($self, $id, $text) = @_;
	$self->call('direct_messages/new', 'POST', {
		user_id => $id,
		text    => $text,
	});
}

sub follow {
	my ($self, $target_screen_name) = @_;
	return $self->call('friendships/create', 'POST', {
		screen_name => $target_screen_name
	});
}

sub is_following {
	my ($self, $target_screen_name) = @_;
	my $data = $self->call('friendships/show', 'GET', {
		source_id          => $self->user_id,
		target_screen_name => $target_screen_name,
	});
	return $data->{relationship}->{source}->{following} ? 1 : 0;
}

sub get_rate_limit_status {
	my ($self) = @_;
	return $self->call('account/rate_limit_status', 'GET');
}

sub get_timeline {
	my ($self, $id, $data) = @_;
	$id ||= $self->user_id;
	$data ||= {};
	$data->{uid} = $id;
	return $self->call('statuses/user_timeline', 'GET', $data);
}

sub get_mentions {
	my ($self, $id, $data) = @_;
	$id ||= $self->user_id;
	$data ||= {};
	$data->{uid} = $id;
	my $res = $self->call('statuses/mentions', 'GET', $data);
	return $res;
}

sub get_sent_messages {
	my ($self) = @_;
	return $self->call('direct_messages/sent', 'GET');
}

sub get_friends_ids {
	my ($self, $id) = @_;
	$id ||= $self->user_id;

	my $cursor = -1;
	my @ids = ();

	while ($cursor != 0) {
		my $result = $self->call('friends/ids', 'GET', {
			user_id => $id,
			cursor  => $cursor,
		});

		$cursor = $result->{next_cursor_str};
		push @ids, @{ $result->{ids} };
	}

	return \@ids;
}

sub get_followers_ids {
	my ($self, $id) = @_;
	$id ||= $self->user_id;

	my $cursor = -1;
	my @ids;

	while ($cursor != 0) {
		my $result = $self->call('followers/ids', 'GET', {
			user_id => $id,
			cursor  => $cursor,
		});

		$cursor = $result->{next_cursor_str};
		push @ids, @{ $result->{ids} };
	}

	return @ids;
}

sub lookup_users {
	my ($self, $ids, $rows, $offset) = @_;
	$offset ||= 0;

	my @ids = @$ids;
	@ids = grep { defined $_ } @ids[$offset .. $offset + $rows - 1];
	return () if @ids == 0;

	my $rpp = 100;
	my $len = int(@ids / $rpp);

	my $users;
	my @requests;

	for my $i (0 .. $len) {
		my @a = @ids;
		@a = grep { defined $_ } @a[$i * $rpp .. ($i + 1) * $rpp - 1];
		next if @a == 0;

		push @requests, [
			'users/lookup',
			'POST',
			{ user_id => join ',', @a }
		];
	}

	my @contents = $self->call_async(@requests);
	for my $content (@contents) {
		for my $user (@$content) {
			$users->{ $user->{id} } = $user;
		}
	}
	return map { $users->{$_} } grep { exists $users->{$_} } @ids;
}

sub make_oauth_request {
	my ($self, $type, %params) = @_;

	die 'key must be specified.' unless defined $self->key;
	die 'secret must be specified.' unless defined $self->secret;

	local $Net::OAuth::SKIP_UTF8_DOUBLE_ENCODE_CHECK = 1;

	my $req = Net::OAuth->request($type)->new(
		version          => '1.0',
		consumer_key     => $self->key,
		consumer_secret  => $self->secret,
		signature_method => 'HMAC-SHA1',
		timestamp        => time,
		nonce            => Digest::SHA::sha1_base64(time . $$ . rand),
		%params,
	);
	$req->sign;

	if ($req->request_method eq 'POST') {
		return POST $req->normalized_request_url, $req->to_hash;
	}

	return GET $req->to_url;
}

sub get_authorization_url {
	my ($self, %params) = @_;

	die "callback must be specified." unless defined $params{callback};

	$params{request_url}    ||= $self->urls->{request_token};
	$params{request_method} ||= 'GET';

	my $req = $self->make_oauth_request('request token', %params);
	my $res = $self->ua->request($req);

	# Twitter が落ちている
	if ($res->code =~ /^5/) {
		$self->on_error->('Twitter OAuth Error: Could not get authorization url.');
		return;
	}

	my $uri = URI->new;
	$uri->query($res->content);
	my %res_param = $uri->query_form;

	$self->request_token($res_param{oauth_token});
	$self->request_token_secret($res_param{oauth_token_secret});

	$uri = URI->new($self->urls->{authentication});
	$uri->query_form(oauth_token => $self->request_token);

	return $uri;
}

sub request_access_token {
	my ($self, %params) = @_;

	die "verifier must be specified." unless defined $params{verifier};

	$params{request_url}    ||= $self->urls->{access_token};
	$params{request_method} ||= 'GET';
	$params{token}          ||= $self->request_token;
	$params{token_secret}   ||= $self->request_token_secret;

	my $req = $self->make_oauth_request('access token', %params);
	my $res = $self->ua->request($req);

	# Twitter が落ちている
	if ($res->code !~ /^2/) {
		$self->on_error->('Twitter OAuth Error: Could not get access token.');
		return;
	}

	delete $self->{request_token};
	delete $self->{request_token_secret};

	my $uri = URI->new;
	$uri->query($res->content);
	my %res_param = $uri->query_form;

	$self->user_id($res_param{user_id});
	$self->screen_name($res_param{screen_name});
	$self->access_token($res_param{oauth_token});
	$self->access_token_secret($res_param{oauth_token_secret});
}

sub _make_request {
	my ($self, $command, $method, $params) = @_;
	my $req = $self->make_oauth_request(
		'protected resource',
		request_url    => $self->urls->{api} . '/' . $command . '.json',
		request_method => $method,
		extra_params   => $params,
		token          => $self->access_token,
		token_secret   => $self->access_token_secret
	);

	return $req;
}

sub call {
	my ($self, $command, $method, $params) = @_;
	$self->authorized;
	my $req = $self->_make_request($command, $method, $params);
	my $res = $self->ua->request($req);
	return $self->validate($res);
}

sub call_async {
	my $self = shift;
	return if @_ == 0;

	$self->authorized;

	my @requests;
	for my $row (@_) {
		push @requests, $self->_make_request(@{ $row });
	}

	my @responses = $self->ua_async->request_in_parallel(@requests);

	my @contents;
	for my $res (@responses) {
		push @contents, $self->validate($res);
	}

	return @contents;
}

sub validate {
	my ($self, $res) = @_;
	my $c = $self->c;

	my $content = eval { decode_json($res->content) };
	if ($@) {
		warn "Couldn't decode JSON: $@";
		warn $res->content;
		$content = $res->content;
	}

	my $hdr = $res->headers;
	my $code = $res->code;

	if ($c->config_name ne 'production' and defined $hdr->{"x-ratelimit-remaining"}) {
		warn "Ratelimit: " . $hdr->{"x-ratelimit-remaining"} . "/" . $hdr->{"x-ratelimit-limit"};
	}

	unless ($code =~ /^2/) {
		# 400 系
		if ($code =~ /^4/) {
			unless (ref $content) {
				warn Dumper $res;
				$content ||= $res->code;
				$self->on_error->('Twitter API Error: ' . $content);
				return;
			}

			my $error_code = $content->{errors}->[0]->{code} // '';
			#  89 = トークン切れ
			if ($error_code eq '89') {
				$self->on_error->('Twitter API Error: ' . $content->{errors}->[0]->{message});
				return;
			}
			#  64 = アカウント凍結
			elsif ($error_code eq '64') {
				$self->on_error->('Twitter API Error: ' . $content->{errors}->[0]->{message});
				return;
			}
			# 187 = 二重投稿
			elsif ($error_code eq '187') {
				warn "Twitter API Error: ", $content->{errors}->[0]->{message};
				return $content;
			}
			#  88 = Rate Limit オーバー
			elsif ($error_code eq '88') {
				$self->on_error->('Twitter API Error: ' . $content->{errors}->[0]->{message});
				return;
			}
		}
		# 500 系
		else {
			# LWP::UserAgent 内部エラー
			if ($hdr->{'client-warning'}) {
				# タイムアウト
				if ($content =~ /timeout/) {
					$self->on_error->('Twitter API Internal Error: Request Timeout.');
					return;
				}
			}
			else {
				my $error_code = $content->{errors}->[0]->{code} // '';
				#  130 = Over Capacity
				if ($error_code eq '130') {
					$self->on_error->('Twitter API Internal Error: ' . $content->{errors}->[0]->{message});
					return;
				}
				# 131 = Internal Error
				elsif ($error_code eq '131') {
					$self->on_error->('Twitter API Internal Error: ' . $content->{errors}->[0]->{message});
					return;
				}
			}

			use Data::Dumper;
			warn Dumper $res;

			$self->on_error->('Twitter API Unknown Error: ' . $res->content);
		}
	}

	return $content;
}

sub parse_date {
	my ($self, $value) = @_;
	$value =~ s/\+\d{4} //;
	return DateTime::Format::HTTP
		->parse_datetime($value)
		->add(hours => 9)
		->set_time_zone('Asia/Tokyo');
}

1;


Powered by Groonga
Maintained by Kenichi Ishigaki <ishigaki@cpan.org>. If you find anything, submit it on GitHub.