From 70f2cfaf77c6de2f4ff6f1b3ec5f3bb82a68a205 Mon Sep 17 00:00:00 2001 From: Gary Ewan Park Date: Thu, 6 Jul 2023 12:56:47 +0100 Subject: [PATCH] (#128) Re-enable posting of Tweets Since the introduction of the new Twitter v2 API, sending Tweets via the Cake.Twitter hasn't been working. There was no error emitted, but the Tweet simply was never sent. The changes in this commit switch to using the new Twitter v2 API, and tweets can now be correctly send again. --- Source/Cake.Twitter/TwitterProvider.cs | 180 ++++++++++++++----------- 1 file changed, 98 insertions(+), 82 deletions(-) diff --git a/Source/Cake.Twitter/TwitterProvider.cs b/Source/Cake.Twitter/TwitterProvider.cs index eb33f54..c6e2021 100644 --- a/Source/Cake.Twitter/TwitterProvider.cs +++ b/Source/Cake.Twitter/TwitterProvider.cs @@ -1,29 +1,31 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Net; using System.Net.Http; -using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; -using Cake.Core; namespace Cake.Twitter { - // The code within this TwitterProvider has been based almost exclusively on the work that was done by Danny Tuppeny - // based on this blog post: - // https://blog.dantup.com/2016/07/simplest-csharp-code-to-post-a-tweet-using-oauth/ + // The code within this TwitterProvider has been based almost exclusively on the work that was done by + // Jamie Maguire in this repository + // https://github.com/jamiemaguiredotnet/SocialOpinion-Public /// /// Contains functionality related to Twitter API /// public sealed class TwitterProvider { - const string TwitterApiBaseUrl = "https://api.twitter.com/1.1/"; - readonly string consumerKey, consumerKeySecret, accessToken, accessTokenSecret; - readonly HMACSHA1 sigHasher; - readonly DateTime epochUtc = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + private const string Version = "1.0"; + private const string SignatureMethod = "HMAC-SHA1"; + private const string TwitterApiBaseUrl = "https://api.twitter.com/2/tweets"; + + private readonly string _consumerKey; + private readonly string _consumerKeySecret; + private readonly string _accessToken; + private readonly string _accessTokenSecret; + private readonly IDictionary _customParameters; /// /// Creates an object for sending tweets to Twitter using Single-user OAuth. @@ -34,12 +36,11 @@ public sealed class TwitterProvider /// public TwitterProvider(string consumerKey, string consumerKeySecret, string accessToken, string accessTokenSecret) { - this.consumerKey = consumerKey; - this.consumerKeySecret = consumerKeySecret; - this.accessToken = accessToken; - this.accessTokenSecret = accessTokenSecret; - - sigHasher = new HMACSHA1(new ASCIIEncoding().GetBytes(string.Format("{0}&{1}", consumerKeySecret, accessTokenSecret))); + _consumerKey = consumerKey; + _consumerKeySecret = consumerKeySecret; + _accessToken = accessToken; + _accessTokenSecret = accessTokenSecret; + _customParameters = new Dictionary(); } /// @@ -47,92 +48,107 @@ public TwitterProvider(string consumerKey, string consumerKeySecret, string acce /// public Task Tweet(string text) { - var data = new Dictionary { - { "status", text }, - { "trim_user", "1" } - }; - - return SendRequest("statuses/update.json", data); + return SendRequest(text); } - Task SendRequest(string url, Dictionary data) + private Task SendRequest(string tweet) { - var fullUrl = TwitterApiBaseUrl + url; + var timespan = GetTimestamp(); + var nonce = CreateNonce(); - // Timestamps are in seconds since 1/1/1970. - var timestamp = (int)((DateTime.UtcNow - epochUtc).TotalSeconds); + var parameters = new Dictionary(_customParameters); + AddOAuthParameters(parameters, timespan, nonce); - // Add all the OAuth headers we'll need to use when constructing the hash. - data.Add("oauth_consumer_key", consumerKey); - data.Add("oauth_signature_method", "HMAC-SHA1"); - data.Add("oauth_timestamp", timestamp.ToString()); - data.Add("oauth_nonce", "a"); // Required, but Twitter doesn't appear to use it, so "a" will do. - data.Add("oauth_token", accessToken); - data.Add("oauth_version", "1.0"); + var signature = GenerateSignature(parameters); + var headerValue = GenerateAuthorizationHeaderValue(parameters, signature); - // Generate the OAuth signature and add it to our payload. - data.Add("oauth_signature", GenerateSignature(fullUrl, data)); + var tweetContent = string.Format("{{\r\n \"text\": \"{0}\"\r\n}}", tweet.Replace(Environment.NewLine, "\\r\\n")); + var httpContent = new StringContent(tweetContent, Encoding.UTF8, "application/json"); - // Build the OAuth HTTP Header from the data. - string oAuthHeader = GenerateOAuthHeader(data); + return SendRequest(headerValue, httpContent); + } - // Build the form data (exclude OAuth stuff that's already in the header). - var formData = new FormUrlEncodedContent(data.Where(kvp => !kvp.Key.StartsWith("oauth_"))); + private async Task SendRequest(string oAuthHeader, HttpContent httpContent) + { + using (var http = new HttpClient()) + { + http.DefaultRequestHeaders.Add("Authorization", oAuthHeader); + var httpResp = await http.PostAsync(TwitterApiBaseUrl, httpContent); + var respBody = await httpResp.Content.ReadAsStringAsync(); + return respBody; + } + } - return SendRequest(fullUrl, oAuthHeader, formData); + private string GenerateSignature(IEnumerable> parameters) + { + var dataToSign = new StringBuilder() + .Append("POST") + .Append("&") + .Append(TwitterApiBaseUrl.EncodeDataString()) + .Append("&") + .Append(parameters + .OrderBy(x => x.Key) + .Select(x => string.Format("{0}={1}", x.Key, x.Value)) + .Join("&") + .EncodeDataString()); + + var signatureKey = string.Format("{0}&{1}", _consumerKeySecret.EncodeDataString(), _accessTokenSecret.EncodeDataString()); + var sha1 = new HMACSHA1(Encoding.ASCII.GetBytes(signatureKey)); + + var signatureBytes = sha1.ComputeHash(Encoding.ASCII.GetBytes(dataToSign.ToString())); + return Convert.ToBase64String(signatureBytes); } - /// - /// Generate an OAuth signature from OAuth header values. - /// - string GenerateSignature(string url, Dictionary data) + private string GenerateAuthorizationHeaderValue(IEnumerable> parameters, string signature) { - var sigString = string.Join( - "&", - data - .Union(data) - .Select(kvp => string.Format("{0}={1}", Uri.EscapeDataString(kvp.Key), Uri.EscapeDataString(kvp.Value))) - .OrderBy(s => s) - ); - - var fullSigData = string.Format( - "{0}&{1}&{2}", - "POST", - Uri.EscapeDataString(url), - Uri.EscapeDataString(sigString.ToString()) - ); - - return Convert.ToBase64String(sigHasher.ComputeHash(new ASCIIEncoding().GetBytes(fullSigData.ToString()))); + return new StringBuilder("OAuth ") + .Append(parameters.Concat(new KeyValuePair("oauth_signature", signature)) + .Where(x => x.Key.StartsWith("oauth_")) + .Select(x => string.Format("{0}=\"{1}\"", x.Key, x.Value.EncodeDataString())) + .Join(",")) + .ToString(); } - /// - /// Generate the raw OAuth HTML header from the values (including signature). - /// - string GenerateOAuthHeader(Dictionary data) + private void AddOAuthParameters(IDictionary parameters, string timestamp, string nonce) { - return "OAuth " + string.Join( - ", ", - data - .Where(kvp => kvp.Key.StartsWith("oauth_")) - .Select(kvp => string.Format("{0}=\"{1}\"", Uri.EscapeDataString(kvp.Key), Uri.EscapeDataString(kvp.Value))) - .OrderBy(s => s) - ); + parameters.Add("oauth_version", Version); + parameters.Add("oauth_consumer_key", _consumerKey); + parameters.Add("oauth_nonce", nonce); + parameters.Add("oauth_signature_method", SignatureMethod); + parameters.Add("oauth_timestamp", timestamp); + parameters.Add("oauth_token", _accessToken); } - /// - /// Send HTTP Request and return the response. - /// - async Task SendRequest(string fullUrl, string oAuthHeader, FormUrlEncodedContent formData) + private static string GetTimestamp() { - using (var http = new HttpClient()) - { - http.DefaultRequestHeaders.Add("Authorization", oAuthHeader); + // Timestamps are in seconds since 1/1/1970. + return ((int)(DateTime.UtcNow - new DateTime(1970, 1, 1)).TotalSeconds).ToString(); + } - var httpResp = await http.PostAsync(fullUrl, formData); - var respBody = await httpResp.Content.ReadAsStringAsync(); + private static string CreateNonce() + { + return new Random().Next(0x0000000, 0x7fffffff).ToString("X8"); + } + } - return respBody; - } + public static class TwitterProviderExtensions + { + public static string Join(this IEnumerable items, string separator) + { + return string.Join(separator, items.ToArray()); + } + + public static IEnumerable Concat(this IEnumerable items, T value) + { + return items.Concat(new[] { value }); + } + + public static string EncodeDataString(this string value) + { + if (string.IsNullOrEmpty(value)) + return string.Empty; + + return Uri.EscapeDataString(value); } } }