From: Kimura Youichi Date: Sun, 16 Jul 2023 11:44:13 +0000 (+0900) Subject: CreateTweetRequestを実装 X-Git-Tag: OpenTween_v3.7.0^2~9^2~1 X-Git-Url: http://git.osdn.net/view?a=commitdiff_plain;h=8156bba429600804ae8d0f8c2418c8e5f701bfb9;p=opentween%2Fopen-tween.git CreateTweetRequestを実装 --- diff --git a/OpenTween.Tests/Api/GraphQL/CreateTweetRequestTest.cs b/OpenTween.Tests/Api/GraphQL/CreateTweetRequestTest.cs new file mode 100644 index 00000000..0565e479 --- /dev/null +++ b/OpenTween.Tests/Api/GraphQL/CreateTweetRequestTest.cs @@ -0,0 +1,114 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2023 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Moq; +using OpenTween.Connection; +using Xunit; + +namespace OpenTween.Api.GraphQL +{ + public class CreateTweetRequestTest + { + [Fact] + public async Task Send_Test() + { + var responseText = File.ReadAllText("Resources/Responses/CreateTweet_CircleTweet.json"); + + var mock = new Mock(); + mock.Setup(x => + x.PostJsonAsync(It.IsAny(), It.IsAny()) + ) + .Callback((url, json) => + { + Assert.Equal(new("https://twitter.com/i/api/graphql/tTsjMKyhajZvK4q76mpIBg/CreateTweet"), url); + Assert.Contains(@"""tweet_text"":""tetete""", json); + Assert.DoesNotContain(@"""reply"":", json); + Assert.DoesNotContain(@"""media"":", json); + }) + .ReturnsAsync(responseText); + + var request = new CreateTweetRequest + { + TweetText = "tetete", + }; + + var status = await request.Send(mock.Object).ConfigureAwait(false); + Assert.Equal("1680534146492317696", status.IdStr); + + mock.VerifyAll(); + } + + [Fact] + public async Task Send_ReplyTest() + { + var responseText = File.ReadAllText("Resources/Responses/CreateTweet_CircleTweet.json"); + + var mock = new Mock(); + mock.Setup(x => + x.PostJsonAsync(It.IsAny(), It.IsAny()) + ) + .Callback((url, json) => + { + Assert.Contains(@"""reply"":{""exclude_reply_user_ids"":[""11111"",""22222""],""in_reply_to_tweet_id"":""12345""}", json); + }) + .ReturnsAsync(responseText); + + var request = new CreateTweetRequest + { + TweetText = "tetete", + InReplyToTweetId = new("12345"), + ExcludeReplyUserIds = new[] { "11111", "22222" }, + }; + await request.Send(mock.Object).ConfigureAwait(false); + mock.VerifyAll(); + } + + [Fact] + public async Task Send_MediaTest() + { + var responseText = File.ReadAllText("Resources/Responses/CreateTweet_CircleTweet.json"); + + var mock = new Mock(); + mock.Setup(x => + x.PostJsonAsync(It.IsAny(), It.IsAny()) + ) + .Callback((url, json) => + { + Assert.Contains(@"""media"":{""media_entities"":[{""media_id"":""11111"",""tagged_users"":[]},{""media_id"":""22222"",""tagged_users"":[]}],""possibly_sensitive"":false}", json); + }) + .ReturnsAsync(responseText); + + var request = new CreateTweetRequest + { + TweetText = "tetete", + MediaIds = new[] { "11111", "22222" }, + }; + await request.Send(mock.Object).ConfigureAwait(false); + mock.VerifyAll(); + } + } +} diff --git a/OpenTween.Tests/OpenTween.Tests.csproj b/OpenTween.Tests/OpenTween.Tests.csproj index 6338e3c6..66f14cb2 100644 --- a/OpenTween.Tests/OpenTween.Tests.csproj +++ b/OpenTween.Tests/OpenTween.Tests.csproj @@ -52,6 +52,9 @@ PreserveNewest + + PreserveNewest + PreserveNewest diff --git a/OpenTween.Tests/Resources/Responses/CreateTweet_CircleTweet.json b/OpenTween.Tests/Resources/Responses/CreateTweet_CircleTweet.json new file mode 100644 index 00000000..3d49e48f --- /dev/null +++ b/OpenTween.Tests/Resources/Responses/CreateTweet_CircleTweet.json @@ -0,0 +1,131 @@ +{ + "data": { + "create_tweet": { + "tweet_results": { + "result": { + "rest_id": "1680534146492317696", + "core": { + "user_results": { + "result": { + "__typename": "User", + "id": "VXNlcjo0MDQ4MDY2NA==", + "rest_id": "40480664", + "affiliates_highlighted_label": {}, + "has_graduated_access": true, + "is_blue_verified": false, + "profile_image_shape": "Circle", + "legacy": { + "can_dm": true, + "can_media_tag": true, + "created_at": "Sat May 16 15:20:01 +0000 2009", + "default_profile": false, + "default_profile_image": false, + "description": "OpenTween Project 言い出しっぺ", + "entities": { + "description": { + "urls": [] + }, + "url": { + "urls": [ + { + "display_url": "m.upsilo.net/@upsilon", + "expanded_url": "https://m.upsilo.net/@upsilon", + "url": "https://t.co/vNMmyHHOQD", + "indices": [ + 0, + 23 + ] + } + ] + } + }, + "fast_followers_count": 0, + "favourites_count": 216204, + "followers_count": 1303, + "friends_count": 917, + "has_custom_timelines": false, + "is_translator": false, + "listed_count": 92, + "location": "Funabashi, Chiba, Japan", + "media_count": 1040, + "name": "upsilon", + "needs_phone_verification": false, + "normal_followers_count": 1303, + "pinned_tweet_ids_str": [], + "possibly_sensitive": false, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/40480664/1349188016", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/719076434/____normal.png", + "profile_interstitial_type": "", + "screen_name": "kim_upsilon", + "statuses_count": 10088, + "translator_type": "regular", + "url": "https://t.co/vNMmyHHOQD", + "verified": false, + "want_retweets": false, + "withheld_in_countries": [] + } + } + } + }, + "edit_control": { + "edit_tweet_ids": [ + "1680534146492317696" + ], + "editable_until_msecs": "1689509137000", + "is_edit_eligible": true, + "edits_remaining": "5" + }, + "edit_perspective": { + "favorited": false, + "retweeted": false + }, + "is_translatable": false, + "views": { + "state": "Disabled" + }, + "source": "Twitter Web App", + "legacy": { + "bookmark_count": 0, + "bookmarked": false, + "created_at": "Sun Jul 16 11:05:37 +0000 2023", + "conversation_id_str": "1680534146492317696", + "display_text_range": [ + 0, + 4 + ], + "entities": { + "user_mentions": [], + "urls": [], + "hashtags": [], + "symbols": [] + }, + "favorite_count": 0, + "favorited": false, + "full_text": "test", + "is_quote_status": false, + "lang": "en", + "quote_count": 0, + "reply_count": 0, + "retweet_count": 0, + "retweeted": false, + "user_id_str": "40480664", + "id_str": "1680534146492317696" + }, + "trusted_friends_info_result": { + "__typename": "ApiTrustedFriendsInfo", + "owner_results": { + "result": { + "__typename": "User", + "legacy": { + "screen_name": "kim_upsilon", + "name": "upsilon" + } + } + } + }, + "unmention_info": {} + } + } + } + } +} diff --git a/OpenTween/Api/GraphQL/CreateTweetRequest.cs b/OpenTween/Api/GraphQL/CreateTweetRequest.cs new file mode 100644 index 00000000..05a50635 --- /dev/null +++ b/OpenTween/Api/GraphQL/CreateTweetRequest.cs @@ -0,0 +1,169 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2023 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.Serialization; +using System.Runtime.Serialization.Json; +using System.Text; +using System.Threading.Tasks; +using System.Xml; +using System.Xml.Linq; +using System.Xml.XPath; +using OpenTween.Api.DataModel; +using OpenTween.Connection; +using OpenTween.Models; + +namespace OpenTween.Api.GraphQL +{ + public class CreateTweetRequest + { + private static readonly Uri EndpointUri = new("https://twitter.com/i/api/graphql/tTsjMKyhajZvK4q76mpIBg/CreateTweet"); + + required public string TweetText { get; set; } + + public TwitterStatusId? InReplyToTweetId { get; set; } + + public string[] ExcludeReplyUserIds { get; set; } = Array.Empty(); + + public string[] MediaIds { get; set; } = Array.Empty(); + + public string? AttachmentUrl { get; set; } + + [DataContract] + private record RequestBody( + [property: DataMember(Name = "variables")] + Variables Variables, + [property: DataMember(Name = "features")] + Dictionary Features, + [property: DataMember(Name = "queryId")] + string QueryId + ); + + [DataContract] + private record Variables( + [property: DataMember(Name = "tweet_text")] + string TweetText, + [property: DataMember(Name = "reply", EmitDefaultValue = false)] + VariableReply? Reply, + [property: DataMember(Name = "media", EmitDefaultValue = false)] + VariableMedia? Media, + [property: DataMember(Name = "attachment_url", EmitDefaultValue = false)] + string? AttachmentUrl + ); + + [DataContract] + private record VariableReply( + [property: DataMember(Name = "in_reply_to_tweet_id")] + string InReplyToTweetId, + [property : DataMember(Name = "exclude_reply_user_ids")] + string[] ExcludeReplyUserIds + ); + + [DataContract] + private record VariableMedia( + [property : DataMember(Name = "media_entities")] + VariableMediaEntity[] MediaEntities, + [property: DataMember(Name = "possibly_sensitive")] + bool PossiblySensitive + ); + + [DataContract] + private record VariableMediaEntity( + [property: DataMember(Name = "media_id")] + string MediaId, + [property : DataMember(Name = "tagged_users")] + string[] TaggedUsers + ); + + public string CreateRequestBody() + { +#pragma warning disable SA1118 + var body = new RequestBody( + Variables: new( + TweetText: this.TweetText, + Reply: this.InReplyToTweetId != null + ? new( + InReplyToTweetId: this.InReplyToTweetId.Id, + ExcludeReplyUserIds: this.ExcludeReplyUserIds + ) + : null, + Media: this.MediaIds.Length > 0 + ? new( + MediaEntities: this.MediaIds + .Select(x => new VariableMediaEntity( + MediaId: x, + TaggedUsers: Array.Empty() + )) + .ToArray(), + PossiblySensitive: false + ) + : null, + AttachmentUrl: this.AttachmentUrl + ), + Features: new() + { + ["tweetypie_unmention_optimization_enabled"] = true, + ["responsive_web_edit_tweet_api_enabled"] = true, + ["graphql_is_translatable_rweb_tweet_is_translatable_enabled"] = true, + ["view_counts_everywhere_api_enabled"] = true, + ["longform_notetweets_consumption_enabled"] = true, + ["responsive_web_twitter_article_tweet_consumption_enabled"] = false, + ["tweet_awards_web_tipping_enabled"] = false, + ["longform_notetweets_rich_text_read_enabled"] = true, + ["longform_notetweets_inline_media_enabled"] = true, + ["responsive_web_graphql_exclude_directive_enabled"] = true, + ["verified_phone_label_enabled"] = false, + ["freedom_of_speech_not_reach_fetch_enabled"] = true, + ["standardized_nudges_misinfo"] = true, + ["tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled"] = true, + ["responsive_web_media_download_video_enabled"] = false, + ["responsive_web_graphql_skip_user_profile_image_extensions_enabled"] = false, + ["responsive_web_graphql_timeline_navigation_enabled"] = true, + ["responsive_web_enhance_cards_enabled"] = true, + }, + QueryId: "tTsjMKyhajZvK4q76mpIBg" + ); +#pragma warning restore SA1118 + return JsonUtils.SerializeJsonByDataContract(body); + } + + public async Task Send(IApiConnection apiConnection) + { + var json = this.CreateRequestBody(); + var response = await apiConnection.PostJsonAsync(EndpointUri, json); + var responseBytes = Encoding.UTF8.GetBytes(response); + using var jsonReader = JsonReaderWriterFactory.CreateJsonReader(responseBytes, XmlDictionaryReaderQuotas.Max); + + var rootElm = XElement.Load(jsonReader); + var tweetElm = rootElm.XPathSelectElement("/data/create_tweet/tweet_results/result") ?? throw CreateParseError(); + + return TimelineTweet.ParseTweet(tweetElm); + } + + private static Exception CreateParseError() + => throw new WebApiException($"Parse error on CreateTweet"); + } +} diff --git a/OpenTween/Api/GraphQL/TimelineTweet.cs b/OpenTween/Api/GraphQL/TimelineTweet.cs index f8bb804d..77dc0680 100644 --- a/OpenTween/Api/GraphQL/TimelineTweet.cs +++ b/OpenTween/Api/GraphQL/TimelineTweet.cs @@ -52,7 +52,7 @@ namespace OpenTween.Api.GraphQL try { var resultElm = this.Element.Element("tweet_results")?.Element("result") ?? throw CreateParseError(); - return this.ParseTweetUnion(resultElm); + return TimelineTweet.ParseTweetUnion(resultElm); } catch (WebApiException ex) { @@ -62,7 +62,7 @@ namespace OpenTween.Api.GraphQL } } - private TwitterStatus ParseTweetUnion(XElement tweetUnionElm) + public static TwitterStatus ParseTweetUnion(XElement tweetUnionElm) { var tweetElm = tweetUnionElm.Element("__typename")?.Value switch { @@ -71,10 +71,10 @@ namespace OpenTween.Api.GraphQL _ => throw CreateParseError(), }; - return this.ParseTweet(tweetElm); + return TimelineTweet.ParseTweet(tweetElm); } - private TwitterStatus ParseTweet(XElement tweetElm) + public static TwitterStatus ParseTweet(XElement tweetElm) { var tweetLegacyElm = tweetElm.Element("legacy") ?? throw CreateParseError(); var userElm = tweetElm.Element("core")?.Element("user_results")?.Element("result") ?? throw CreateParseError(); @@ -147,7 +147,7 @@ namespace OpenTween.Api.GraphQL ScreenName = GetText(userLegacyElm, "screen_name"), Protected = GetTextOrNull(userLegacyElm, "protected") == "true", }, - RetweetedStatus = retweetedTweetElm != null ? this.ParseTweetUnion(retweetedTweetElm) : null, + RetweetedStatus = retweetedTweetElm != null ? TimelineTweet.ParseTweetUnion(retweetedTweetElm) : null, }; } diff --git a/OpenTween/Api/JsonUtils.cs b/OpenTween/Api/JsonUtils.cs index 8f610271..2342fce7 100644 --- a/OpenTween/Api/JsonUtils.cs +++ b/OpenTween/Api/JsonUtils.cs @@ -73,5 +73,17 @@ namespace OpenTween.Api } return Encoding.UTF8.GetString(stream.ToArray()); } + + public static string SerializeJsonByDataContract(T content) + { + using var stream = new MemoryStream(); + var settings = new DataContractJsonSerializerSettings + { + UseSimpleDictionaryFormat = true, + }; + var serializer = new DataContractJsonSerializer(typeof(T), settings); + serializer.WriteObject(stream, content); + return Encoding.UTF8.GetString(stream.ToArray()); + } } }