OSDN Git Service

CreateTweetRequestを実装
authorKimura Youichi <kim.upsilon@bucyou.net>
Sun, 16 Jul 2023 11:44:13 +0000 (20:44 +0900)
committerKimura Youichi <kim.upsilon@bucyou.net>
Sun, 16 Jul 2023 11:55:20 +0000 (20:55 +0900)
OpenTween.Tests/Api/GraphQL/CreateTweetRequestTest.cs [new file with mode: 0644]
OpenTween.Tests/OpenTween.Tests.csproj
OpenTween.Tests/Resources/Responses/CreateTweet_CircleTweet.json [new file with mode: 0644]
OpenTween/Api/GraphQL/CreateTweetRequest.cs [new file with mode: 0644]
OpenTween/Api/GraphQL/TimelineTweet.cs
OpenTween/Api/JsonUtils.cs

diff --git a/OpenTween.Tests/Api/GraphQL/CreateTweetRequestTest.cs b/OpenTween.Tests/Api/GraphQL/CreateTweetRequestTest.cs
new file mode 100644 (file)
index 0000000..0565e47
--- /dev/null
@@ -0,0 +1,114 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2023 kim_upsilon (@kim_upsilon) <https://upsilo.net/~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 <http://www.gnu.org/licenses/>, 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<IApiConnection>();
+            mock.Setup(x =>
+                    x.PostJsonAsync(It.IsAny<Uri>(), It.IsAny<string>())
+                )
+                .Callback<Uri, string>((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<IApiConnection>();
+            mock.Setup(x =>
+                    x.PostJsonAsync(It.IsAny<Uri>(), It.IsAny<string>())
+                )
+                .Callback<Uri, string>((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<IApiConnection>();
+            mock.Setup(x =>
+                    x.PostJsonAsync(It.IsAny<Uri>(), It.IsAny<string>())
+                )
+                .Callback<Uri, string>((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();
+        }
+    }
+}
index 6338e3c..66f14cb 100644 (file)
@@ -52,6 +52,9 @@
     <None Update="Resources\re1.png">
       <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
     </None>
+    <None Update="Resources\Responses\CreateTweet_CircleTweet.json">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </None>
     <None Update="Resources\Responses\TimelineTweet_RetweetedTweet.json">
       <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
     </None>
diff --git a/OpenTween.Tests/Resources/Responses/CreateTweet_CircleTweet.json b/OpenTween.Tests/Resources/Responses/CreateTweet_CircleTweet.json
new file mode 100644 (file)
index 0000000..3d49e48
--- /dev/null
@@ -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": "<a href=\"https://mobile.twitter.com\" rel=\"nofollow\">Twitter Web App</a>",
+          "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 (file)
index 0000000..05a5063
--- /dev/null
@@ -0,0 +1,169 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2023 kim_upsilon (@kim_upsilon) <https://upsilo.net/~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 <http://www.gnu.org/licenses/>, 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<string>();
+
+        public string[] MediaIds { get; set; } = Array.Empty<string>();
+
+        public string? AttachmentUrl { get; set; }
+
+        [DataContract]
+        private record RequestBody(
+            [property: DataMember(Name = "variables")]
+            Variables Variables,
+            [property: DataMember(Name = "features")]
+            Dictionary<string, bool> 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<string>()
+                                ))
+                                .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<TwitterStatus> 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");
+    }
+}
index f8bb804..77dc068 100644 (file)
@@ -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,
             };
         }
 
index 8f61027..2342fce 100644 (file)
@@ -73,5 +73,17 @@ namespace OpenTween.Api
             }
             return Encoding.UTF8.GetString(stream.ToArray());
         }
+
+        public static string SerializeJsonByDataContract<T>(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());
+        }
     }
 }