--- /dev/null
+// 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();
+ }
+ }
+}
<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>
--- /dev/null
+{
+ "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": {}
+ }
+ }
+ }
+ }
+}
--- /dev/null
+// 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");
+ }
+}
try
{
var resultElm = this.Element.Element("tweet_results")?.Element("result") ?? throw CreateParseError();
- return this.ParseTweetUnion(resultElm);
+ return TimelineTweet.ParseTweetUnion(resultElm);
}
catch (WebApiException ex)
{
}
}
- private TwitterStatus ParseTweetUnion(XElement tweetUnionElm)
+ public static TwitterStatus ParseTweetUnion(XElement tweetUnionElm)
{
var tweetElm = tweetUnionElm.Element("__typename")?.Value switch
{
_ => 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();
ScreenName = GetText(userLegacyElm, "screen_name"),
Protected = GetTextOrNull(userLegacyElm, "protected") == "true",
},
- RetweetedStatus = retweetedTweetElm != null ? this.ParseTweetUnion(retweetedTweetElm) : null,
+ RetweetedStatus = retweetedTweetElm != null ? TimelineTweet.ParseTweetUnion(retweetedTweetElm) : null,
};
}
}
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());
+ }
}
}