--- /dev/null
+// OpenTween - Client of Twitter
+// Copyright (c) 2016 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.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Threading.Tasks;
+using System.Web;
+using Moq;
+using Xunit;
+
+namespace OpenTween.Api
+{
+ public class MicrosoftTranslatorApiTest
+ {
+ [Fact]
+ public async Task TranslateAsync_Test()
+ {
+ using (var mockHandler = new HttpMessageHandlerMock())
+ using (var http = new HttpClient(mockHandler))
+ {
+ var mock = new Mock<MicrosoftTranslatorApi>(http);
+ mock.Setup(x => x.GetAccessTokenAsync())
+ .ReturnsAsync(Tuple.Create("1234abcd", TimeSpan.FromSeconds(1000)));
+
+ var translateApi = mock.Object;
+
+ mockHandler.Enqueue(x =>
+ {
+ Assert.Equal(HttpMethod.Get, x.Method);
+ Assert.Equal(MicrosoftTranslatorApi.TranslateEndpoint.AbsoluteUri,
+ x.RequestUri.GetLeftPart(UriPartial.Path));
+
+ var query = HttpUtility.ParseQueryString(x.RequestUri.Query);
+
+ Assert.Equal("hogehoge", query["text"]);
+ Assert.Equal("ja", query["to"]);
+ Assert.Equal("en", query["from"]);
+
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("ほげほげ"),
+ };
+ });
+
+ var result = await translateApi.TranslateAsync("hogehoge", langTo: "ja", langFrom: "en")
+ .ConfigureAwait(false);
+ Assert.Equal("ほげほげ", result);
+
+ mock.Verify(x => x.GetAccessTokenAsync(), Times.Once());
+ Assert.Equal(0, mockHandler.QueueCount);
+ }
+ }
+
+ [Fact]
+ public async Task UpdateAccessTokenIfExpired_FirstCallTest()
+ {
+ var mock = new Mock<MicrosoftTranslatorApi>();
+ mock.Setup(x => x.GetAccessTokenAsync())
+ .ReturnsAsync(Tuple.Create("1234abcd", TimeSpan.FromSeconds(1000)));
+
+ var translateApi = mock.Object;
+
+ await translateApi.UpdateAccessTokenIfExpired()
+ .ConfigureAwait(false);
+
+ Assert.Equal("1234abcd", translateApi.AccessToken);
+
+ // 期待値との差が 3 秒以内であるか
+ var expectedExpiresAt = DateTime.Now + TimeSpan.FromSeconds(1000 - 30);
+ Assert.True((translateApi.RefreshAccessTokenAt - expectedExpiresAt).Duration() < TimeSpan.FromSeconds(3));
+ }
+
+ [Fact]
+ public async Task UpdateAccessTokenIfExpired_NotExpiredTest()
+ {
+ var mock = new Mock<MicrosoftTranslatorApi>();
+
+ var translateApi = mock.Object;
+ translateApi.AccessToken = "1234abcd";
+ translateApi.RefreshAccessTokenAt = DateTime.Now + TimeSpan.FromMinutes(3);
+
+ await translateApi.UpdateAccessTokenIfExpired()
+ .ConfigureAwait(false);
+
+ // RefreshAccessTokenAt の時刻を過ぎるまでは GetAccessTokenAsync は呼ばれない
+ mock.Verify(x => x.GetAccessTokenAsync(), Times.Never());
+ }
+
+ [Fact]
+ public async Task UpdateAccessTokenIfExpired_ExpiredTest()
+ {
+ var mock = new Mock<MicrosoftTranslatorApi>();
+ mock.Setup(x => x.GetAccessTokenAsync())
+ .ReturnsAsync(Tuple.Create("5678efgh", TimeSpan.FromSeconds(1000)));
+
+ var translateApi = mock.Object;
+ translateApi.AccessToken = "1234abcd";
+ translateApi.RefreshAccessTokenAt = DateTime.Now - TimeSpan.FromMinutes(3);
+
+ await translateApi.UpdateAccessTokenIfExpired()
+ .ConfigureAwait(false);
+
+ Assert.Equal("5678efgh", translateApi.AccessToken);
+
+ // 期待値との差が 3 秒以内であるか
+ var expectedExpiresAt = DateTime.Now + TimeSpan.FromSeconds(1000 - 30);
+ Assert.True((translateApi.RefreshAccessTokenAt - expectedExpiresAt).Duration() < TimeSpan.FromSeconds(3));
+ }
+
+ [Fact]
+ public async Task GetAccessTokenAsync_Test()
+ {
+ using (var mockHandler = new HttpMessageHandlerMock())
+ using (var http = new HttpClient(mockHandler))
+ {
+ var translateApi = new MicrosoftTranslatorApi(http);
+
+ mockHandler.Enqueue(async x =>
+ {
+ Assert.Equal(HttpMethod.Post, x.Method);
+ Assert.Equal(MicrosoftTranslatorApi.OAuthEndpoint, x.RequestUri);
+
+ var body = await x.Content.ReadAsStringAsync()
+ .ConfigureAwait(false);
+ var query = HttpUtility.ParseQueryString(body);
+
+ Assert.Equal("client_credentials", query["grant_type"]);
+ Assert.Equal(ApplicationSettings.AzureClientId, query["client_id"]);
+ Assert.Equal(ApplicationSettings.AzureClientSecret, query["client_secret"]);
+ Assert.Equal("http://api.microsofttranslator.com", query["scope"]);
+
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(@"
+{
+ ""access_token"": ""12345acbde"",
+ ""token_type"": ""bearer"",
+ ""expires_in"": 3600
+}"),
+ };
+ });
+
+ var result = await translateApi.GetAccessTokenAsync()
+ .ConfigureAwait(false);
+
+ var expectedToken = Tuple.Create(@"12345acbde", TimeSpan.FromSeconds(3600));
+ Assert.Equal(expectedToken, result);
+
+ Assert.Equal(0, mockHandler.QueueCount);
+ }
+ }
+
+ [Fact]
+ public void ParseOAuthCredential_ValidResponseTest()
+ {
+ var jsonBytes = Encoding.UTF8.GetBytes(@"
+{
+ ""access_token"": ""12345acbde"",
+ ""token_type"": ""bearer"",
+ ""expires_in"": 3600
+}
+");
+ var expected = Tuple.Create(@"12345acbde", TimeSpan.FromSeconds(3600));
+ Assert.Equal(expected, MicrosoftTranslatorApi.ParseOAuthCredential(jsonBytes));
+ }
+
+ [Fact]
+ public void ParseOAuthCredential_OmittedExpiresInTest()
+ {
+ var jsonBytes = Encoding.UTF8.GetBytes(@"
+{
+ ""access_token"": ""12345acbde"",
+ ""token_type"": ""bearer""
+}
+");
+ var expected = Tuple.Create(@"12345acbde", TimeSpan.Zero);
+ Assert.Equal(expected, MicrosoftTranslatorApi.ParseOAuthCredential(jsonBytes));
+ }
+ }
+}
/// </summary>
public class BingTest
{
- [Fact]
- public async Task TranslateAsync_Test()
- {
- var handler = new HttpMessageHandlerMock();
- var bing = new Bing(new HttpClient(handler));
-
- handler.Enqueue(x =>
- {
- Assert.Equal(HttpMethod.Get, x.Method);
- Assert.Equal("https://api.datamarket.azure.com/Data.ashx/Bing/MicrosoftTranslator/v1/Translate",
- x.RequestUri.GetLeftPart(UriPartial.Path));
-
- var query = HttpUtility.ParseQueryString(x.RequestUri.Query);
-
- Assert.Equal("'hogehoge'", query["Text"]);
- Assert.Equal("'ja'", query["To"]);
- Assert.Equal("Raw", query["$format"]);
-
- return new HttpResponseMessage(HttpStatusCode.OK)
- {
- Content = new StringContent("<string>ほげほげ</string>"),
- };
- });
-
- var translatedText = await bing.TranslateAsync("hogehoge", langFrom: null, langTo: "ja");
- Assert.Equal("ほげほげ", translatedText);
-
- Assert.Equal(0, handler.QueueCount);
- }
-
- [Fact]
- public async Task TranslateAsync_HttpErrorTest()
- {
- var handler = new HttpMessageHandlerMock();
- var bing = new Bing(new HttpClient(handler));
-
- handler.Enqueue(x =>
- {
- return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable);
- });
-
- await Assert.ThrowsAsync<HttpRequestException>(async () =>
- await bing.TranslateAsync("hogehoge", langFrom: null, langTo: "ja"));
-
- Assert.Equal(0, handler.QueueCount);
- }
-
[Theory]
[InlineData("af", 0)]
[InlineData("sq", 1)]
{
Assert.Equal(expected, Bing.GetIndexFromLanguageEnum(lang));
}
-
- [Fact]
- public void CreateBasicAuthHeaderValue_Test()
- {
- var value = Bing.CreateBasicAuthHeaderValue("user", "pass");
-
- Assert.Equal("Basic", value.Scheme);
- Assert.Equal("user:pass", Encoding.UTF8.GetString(Convert.FromBase64String(value.Parameter)));
- }
}
}
<ItemGroup>
<Compile Include="AnyOrderComparer.cs" />
<Compile Include="Api\ApiLimitTest.cs" />
+ <Compile Include="Api\MicrosoftTranslatorApiTest.cs" />
<Compile Include="Api\TwitterApiStatusTest.cs" />
<Compile Include="Api\TwitterApiTest.cs" />
<Compile Include="BingTest.cs" />
--- /dev/null
+// OpenTween - Client of Twitter
+// Copyright (c) 2016 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.Linq;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Runtime.Serialization.Json;
+using System.Text;
+using System.Threading.Tasks;
+using System.Xml;
+using System.Xml.Linq;
+using OpenTween.Connection;
+
+namespace OpenTween.Api
+{
+ public class MicrosoftTranslatorApi
+ {
+ public static readonly Uri OAuthEndpoint = new Uri("https://datamarket.accesscontrol.windows.net/v2/OAuth2-13");
+ public static readonly Uri TranslateEndpoint = new Uri("https://api.microsofttranslator.com/v2/Http.svc/Translate");
+
+ public string AccessToken { get; internal set; }
+ public DateTime RefreshAccessTokenAt { get; internal set; }
+
+ private HttpClient Http => this.localHttpClient ?? Networking.Http;
+ private readonly HttpClient localHttpClient;
+
+ public MicrosoftTranslatorApi()
+ : this(null)
+ {
+ }
+
+ public MicrosoftTranslatorApi(HttpClient http)
+ {
+ this.localHttpClient = http;
+ }
+
+ public async Task<string> TranslateAsync(string text, string langTo, string langFrom = null)
+ {
+ await this.UpdateAccessTokenIfExpired()
+ .ConfigureAwait(false);
+
+ var param = new Dictionary<string, string>
+ {
+ ["text"] = text,
+ ["to"] = langTo,
+ };
+
+ if (langFrom != null)
+ param["from"] = langFrom;
+
+ var requestUri = new Uri(TranslateEndpoint, "?" + MyCommon.BuildQueryString(param));
+
+ using (var request = new HttpRequestMessage(HttpMethod.Get, requestUri))
+ {
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", this.AccessToken);
+
+ using (var response = await this.Http.SendAsync(request).ConfigureAwait(false))
+ {
+ return await response.Content.ReadAsStringAsync()
+ .ConfigureAwait(false);
+ }
+ }
+ }
+
+ public async Task UpdateAccessTokenIfExpired()
+ {
+ if (this.AccessToken != null && this.RefreshAccessTokenAt > DateTime.Now)
+ return;
+
+ var accessToken = await this.GetAccessTokenAsync()
+ .ConfigureAwait(false);
+
+ this.AccessToken = accessToken.Item1;
+
+ // expires_in の示す時刻より 30 秒早めに再発行する
+ this.RefreshAccessTokenAt = DateTime.Now + accessToken.Item2 - TimeSpan.FromSeconds(30);
+ }
+
+ internal virtual async Task<Tuple<string, TimeSpan>> GetAccessTokenAsync()
+ {
+ var param = new Dictionary<string, string>
+ {
+ ["grant_type"] = "client_credentials",
+ ["client_id"] = ApplicationSettings.AzureClientId,
+ ["client_secret"] = ApplicationSettings.AzureClientSecret,
+ ["scope"] = "http://api.microsofttranslator.com",
+ };
+
+ using (var request = new HttpRequestMessage(HttpMethod.Post, OAuthEndpoint))
+ using (var postContent = new FormUrlEncodedContent(param))
+ {
+ request.Content = postContent;
+
+ using (var response = await this.Http.SendAsync(request).ConfigureAwait(false))
+ {
+ var responseBytes = await response.Content.ReadAsByteArrayAsync()
+ .ConfigureAwait(false);
+
+ return ParseOAuthCredential(responseBytes);
+ }
+ }
+ }
+
+ internal static Tuple<string, TimeSpan> ParseOAuthCredential(byte[] responseBytes)
+ {
+ using (var jsonReader = JsonReaderWriterFactory.CreateJsonReader(responseBytes, XmlDictionaryReaderQuotas.Max))
+ {
+ var xElm = XElement.Load(jsonReader);
+
+ var tokenTypeElm = xElm.Element("token_type");
+ if (tokenTypeElm == null)
+ throw new WebApiException("Property `token_type` required");
+
+ var accessTokenElm = xElm.Element("access_token");
+ if (accessTokenElm == null)
+ throw new WebApiException("Property `access_token` required");
+
+ var expiresInElm = xElm.Element("expires_in");
+
+ int expiresInSeconds;
+ if (expiresInElm != null)
+ {
+ if (!int.TryParse(expiresInElm.Value, out expiresInSeconds))
+ throw new WebApiException("Invalid number: expires_in = " + expiresInElm.Value);
+ }
+ else
+ {
+ // expires_in が省略された場合は有効期間が不明なので、
+ // 次回のリクエスト時は経過時間に関わらずアクセストークンの再発行を行う
+ expiresInSeconds = 0;
+ }
+
+ return Tuple.Create(accessTokenElm.Value, TimeSpan.FromSeconds(expiresInSeconds));
+ }
+ }
+ }
+}
//=====================================================================
// Windows Azure Marketplace
- // https://datamarket.azure.com/account/keys から取得できます。
+ // https://datamarket.azure.com/developer/applications から取得できます。
/// <summary>
- /// Windows Azure Marketplace アカウントキー
+ /// Windows Azure Marketplace Client Id
/// </summary>
- public const string AzureMarketplaceKey = "UlOODyR2rVH0lfweya1VuY5KjE7L0ZjvQKQWlYgWsPw=";
+ public readonly static string AzureClientId = "OpenTween";
+
+ /// <summary>
+ /// Windows Azure Marketplace Client Secret
+ /// </summary>
+ public readonly static string AzureClientSecret = "UiTaBcOkGxCpjloU/2W3P0fTiHNz+FUeGuzgUz2rwZU=";
//=====================================================================
// Imgur
using System;
using System.Collections.Generic;
using System.Net.Http;
-using System.Net.Http.Headers;
using System.Text;
+using System.Threading;
using System.Threading.Tasks;
-using System.Web;
-using System.Xml.Linq;
+using OpenTween.Api;
using OpenTween.Connection;
namespace OpenTween
"zu",
};
- private static readonly string TranslateUri =
- "https://api.datamarket.azure.com/Data.ashx/Bing/MicrosoftTranslator/v1/Translate";
-
- protected HttpClient http
- {
- get { return this.localHttpClient ?? Networking.Http; }
- }
- private readonly HttpClient localHttpClient;
+ private readonly MicrosoftTranslatorApi translatorApi;
public Bing()
: this(null)
public Bing(HttpClient http)
{
- this.localHttpClient = http;
+ this.translatorApi = new MicrosoftTranslatorApi(http);
}
/// <summary>
/// <exception cref="HttpRequestException"/>
public async Task<string> TranslateAsync(string text, string langFrom, string langTo)
{
- var param = new Dictionary<string, string>
- {
- ["Text"] = "'" + text + "'",
- ["To"] = "'" + langTo + "'",
- ["$format"] = "Raw",
- };
-
- if (langFrom != null)
- param["From"] = "'" + langFrom + "'";
-
- var uri = new Uri(TranslateUri + "?" + MyCommon.BuildQueryString(param));
- var request = new HttpRequestMessage(HttpMethod.Get, uri);
- request.Headers.Authorization = CreateBasicAuthHeaderValue(ApplicationSettings.AzureMarketplaceKey, ApplicationSettings.AzureMarketplaceKey);
-
- using (var response = await this.http.SendAsync(request).ConfigureAwait(false))
- {
- response.EnsureSuccessStatusCode();
-
- var xmlStr = await response.Content.ReadAsStringAsync()
- .ConfigureAwait(false);
-
- var xdoc = XDocument.Parse(xmlStr);
-
- return xdoc.Root.Value;
- }
+ return await this.translatorApi.TranslateAsync(text, langTo, langFrom)
+ .ConfigureAwait(false);
}
public static string GetLanguageEnumFromIndex(int index)
{
return LanguageTable.IndexOf(lang);
}
-
- internal static AuthenticationHeaderValue CreateBasicAuthHeaderValue(string user, string pass)
- {
- var paramBytes = Encoding.UTF8.GetBytes(user + ":" + pass);
- return new AuthenticationHeaderValue("Basic", Convert.ToBase64String(paramBytes));
- }
}
}
<Compile Include="Api\DataModel\TwitterUploadMediaResult.cs" />
<Compile Include="Api\DataModel\TwitterUser.cs" />
<Compile Include="Api\DataModel\TwitterApiAccessLevel.cs" />
+ <Compile Include="Api\MicrosoftTranslatorApi.cs" />
<Compile Include="Api\TwitterApi.cs" />
<Compile Include="Api\TwitterApiException.cs" />
<Compile Include="Api\TwitterApiStatus.cs" />
[assembly: AssemblyVersion("0.1.0.0")]
[assembly: AssemblyFileVersion("1.3.5.1")]
-[assembly: InternalsVisibleTo("OpenTween.Tests")]
\ No newline at end of file
+[assembly: InternalsVisibleTo("OpenTween.Tests")]
+[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // for Moq
==== Ver 1.3.6-dev(2016/xx/xx)
* FIX: 発言詳細部のプロフィール画像に誤ったユーザーの画像が表示されることがある不具合を修正
* FIX: ツイートURLのコピー時に余分な改行文字が末尾に付く不具合を修正
+ * FIX: Bing翻訳機能が使用できなくなっていた問題を修正
==== Ver 1.3.5(2016/10/01)
* NEW: 140文字を越えるツイートの表示に対応しました