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); } } }