OSDN Git Service

f0217b70086f6c1cd3a4490faa9ee38d897f76a6
[opentween/open-tween.git] / OpenTween / Twitter.cs
1 // OpenTween - Client of Twitter
2 // Copyright (c) 2007-2011 kiri_feather (@kiri_feather) <kiri.feather@gmail.com>
3 //           (c) 2008-2011 Moz (@syo68k)
4 //           (c) 2008-2011 takeshik (@takeshik) <http://www.takeshik.org/>
5 //           (c) 2010-2011 anis774 (@anis774) <http://d.hatena.ne.jp/anis774/>
6 //           (c) 2010-2011 fantasticswallow (@f_swallow) <http://twitter.com/f_swallow>
7 //           (c) 2011      Egtra (@egtra) <http://dev.activebasic.com/egtra/>
8 //           (c) 2013      kim_upsilon (@kim_upsilon) <https://upsilo.net/~upsilon/>
9 // All rights reserved.
10 //
11 // This file is part of OpenTween.
12 //
13 // This program is free software; you can redistribute it and/or modify it
14 // under the terms of the GNU General Public License as published by the Free
15 // Software Foundation; either version 3 of the License, or (at your option)
16 // any later version.
17 //
18 // This program is distributed in the hope that it will be useful, but
19 // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
20 // or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
21 // for more details.
22 //
23 // You should have received a copy of the GNU General Public License along
24 // with this program. If not, see <http://www.gnu.org/licenses/>, or write to
25 // the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
26 // Boston, MA 02110-1301, USA.
27
28 using System.Diagnostics;
29 using System.IO;
30 using System.Linq;
31 using System.Net;
32 using System.Net.Http;
33 using System.Runtime.CompilerServices;
34 using System.Text;
35 using System.Text.RegularExpressions;
36 using System.Threading;
37 using System.Threading.Tasks;
38 using System;
39 using System.Reflection;
40 using System.Collections.Generic;
41 using System.Windows.Forms;
42 using OpenTween.Api;
43 using OpenTween.Api.DataModel;
44 using OpenTween.Connection;
45 using OpenTween.Models;
46 using OpenTween.Setting;
47 using System.Globalization;
48
49 namespace OpenTween
50 {
51     public class Twitter : IDisposable
52     {
53         #region Regexp from twitter-text-js
54
55         // The code in this region code block incorporates works covered by
56         // the following copyright and permission notices:
57         //
58         //   Copyright 2011 Twitter, Inc.
59         //
60         //   Licensed under the Apache License, Version 2.0 (the "License"); you
61         //   may not use this work except in compliance with the License. You
62         //   may obtain a copy of the License in the LICENSE file, or at:
63         //
64         //   http://www.apache.org/licenses/LICENSE-2.0
65         //
66         //   Unless required by applicable law or agreed to in writing, software
67         //   distributed under the License is distributed on an "AS IS" BASIS,
68         //   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
69         //   implied. See the License for the specific language governing
70         //   permissions and limitations under the License.
71
72         //Hashtag用正規表現
73         private const string LATIN_ACCENTS = @"\u00c0-\u00d6\u00d8-\u00f6\u00f8-\u00ff\u0100-\u024f\u0253\u0254\u0256\u0257\u0259\u025b\u0263\u0268\u026f\u0272\u0289\u028b\u02bb\u1e00-\u1eff";
74         private const string NON_LATIN_HASHTAG_CHARS = @"\u0400-\u04ff\u0500-\u0527\u1100-\u11ff\u3130-\u3185\uA960-\uA97F\uAC00-\uD7AF\uD7B0-\uD7FF";
75         //private const string CJ_HASHTAG_CHARACTERS = @"\u30A1-\u30FA\uFF66-\uFF9F\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\u3041-\u3096\u3400-\u4DBF\u4E00-\u9FFF\u20000-\u2A6DF\u2A700-\u2B73F\u2B740-\u2B81F\u2F800-\u2FA1F";
76         private const string CJ_HASHTAG_CHARACTERS = @"\u30A1-\u30FA\u30FC\u3005\uFF66-\uFF9F\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\u3041-\u309A\u3400-\u4DBF\p{IsCJKUnifiedIdeographs}";
77         private const string HASHTAG_BOUNDARY = @"^|$|\s|「|」|。|\.|!";
78         private const string HASHTAG_ALPHA = "[a-z_" + LATIN_ACCENTS + NON_LATIN_HASHTAG_CHARS + CJ_HASHTAG_CHARACTERS + "]";
79         private const string HASHTAG_ALPHANUMERIC = "[a-z0-9_" + LATIN_ACCENTS + NON_LATIN_HASHTAG_CHARS + CJ_HASHTAG_CHARACTERS + "]";
80         private const string HASHTAG_TERMINATOR = "[^a-z0-9_" + LATIN_ACCENTS + NON_LATIN_HASHTAG_CHARS + CJ_HASHTAG_CHARACTERS + "]";
81         public const string HASHTAG = "(" + HASHTAG_BOUNDARY + ")(#|#)(" + HASHTAG_ALPHANUMERIC + "*" + HASHTAG_ALPHA + HASHTAG_ALPHANUMERIC + "*)(?=" + HASHTAG_TERMINATOR + "|" + HASHTAG_BOUNDARY + ")";
82         //URL正規表現
83         private const string url_valid_preceding_chars = @"(?:[^A-Za-z0-9@@$##\ufffe\ufeff\uffff\u202a-\u202e]|^)";
84         public const string url_invalid_without_protocol_preceding_chars = @"[-_./]$";
85         private const string url_invalid_domain_chars = @"\!'#%&'\(\)*\+,\\\-\.\/:;<=>\?@\[\]\^_{|}~\$\u2000-\u200a\u0009-\u000d\u0020\u0085\u00a0\u1680\u180e\u2028\u2029\u202f\u205f\u3000\ufffe\ufeff\uffff\u202a-\u202e";
86         private const string url_valid_domain_chars = @"[^" + url_invalid_domain_chars + "]";
87         private const string url_valid_subdomain = @"(?:(?:" + url_valid_domain_chars + @"(?:[_-]|" + url_valid_domain_chars + @")*)?" + url_valid_domain_chars + @"\.)";
88         private const string url_valid_domain_name = @"(?:(?:" + url_valid_domain_chars + @"(?:-|" + url_valid_domain_chars + @")*)?" + url_valid_domain_chars + @"\.)";
89         private const string url_valid_GTLD = @"(?:(?:aero|asia|biz|cat|com|coop|edu|gov|info|int|jobs|mil|mobi|museum|name|net|org|pro|tel|travel|xxx)(?=[^0-9a-zA-Z]|$))";
90         private const string url_valid_CCTLD = @"(?:(?:ac|ad|ae|af|ag|ai|al|am|an|ao|aq|ar|as|at|au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|bi|bj|bm|bn|bo|br|bs|bt|bv|bw|by|bz|ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|co|cr|cs|cu|cv|cx|cy|cz|dd|de|dj|dk|dm|do|dz|ec|ee|eg|eh|er|es|et|eu|fi|fj|fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|in|io|iq|ir|is|it|je|jm|jo|jp|ke|kg|kh|ki|km|kn|kp|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|me|mg|mh|mk|ml|mm|mn|mo|mp|mq|mr|ms|mt|mu|mv|mw|mx|my|mz|na|nc|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|pa|pe|pf|pg|ph|pk|pl|pm|pn|pr|ps|pt|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|sk|sl|sm|sn|so|sr|ss|st|su|sv|sy|sz|tc|td|tf|tg|th|tj|tk|tl|tm|tn|to|tp|tr|tt|tv|tw|tz|ua|ug|uk|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|ye|yt|za|zm|zw)(?=[^0-9a-zA-Z]|$))";
91         private const string url_valid_punycode = @"(?:xn--[0-9a-z]+)";
92         private const string url_valid_domain = @"(?<domain>" + url_valid_subdomain + "*" + url_valid_domain_name + "(?:" + url_valid_GTLD + "|" + url_valid_CCTLD + ")|" + url_valid_punycode + ")";
93         public const string url_valid_ascii_domain = @"(?:(?:[a-z0-9" + LATIN_ACCENTS + @"]+)\.)+(?:" + url_valid_GTLD + "|" + url_valid_CCTLD + "|" + url_valid_punycode + ")";
94         public const string url_invalid_short_domain = "^" + url_valid_domain_name + url_valid_CCTLD + "$";
95         private const string url_valid_port_number = @"[0-9]+";
96
97         private const string url_valid_general_path_chars = @"[a-z0-9!*';:=+,.$/%#\[\]\-_~|&" + LATIN_ACCENTS + "]";
98         private const string url_balance_parens = @"(?:\(" + url_valid_general_path_chars + @"+\))";
99         private const string url_valid_path_ending_chars = @"(?:[+\-a-z0-9=_#/" + LATIN_ACCENTS + "]|" + url_balance_parens + ")";
100         private const string pth = "(?:" +
101             "(?:" +
102                 url_valid_general_path_chars + "*" +
103                 "(?:" + url_balance_parens + url_valid_general_path_chars + "*)*" +
104                 url_valid_path_ending_chars +
105                 ")|(?:@" + url_valid_general_path_chars + "+/)" +
106             ")";
107         private const string qry = @"(?<query>\?[a-z0-9!?*'();:&=+$/%#\[\]\-_.,~|]*[a-z0-9_&=#/])?";
108         public const string rgUrl = @"(?<before>" + url_valid_preceding_chars + ")" +
109                                     "(?<url>(?<protocol>https?://)?" +
110                                     "(?<domain>" + url_valid_domain + ")" +
111                                     "(?::" + url_valid_port_number + ")?" +
112                                     "(?<path>/" + pth + "*)?" +
113                                     qry +
114                                     ")";
115
116         #endregion
117
118         /// <summary>
119         /// Twitter API のステータスページのURL
120         /// </summary>
121         public const string ServiceAvailabilityStatusUrl = "https://status.io.watchmouse.com/7617";
122
123         /// <summary>
124         /// ツイートへのパーマリンクURLを判定する正規表現
125         /// </summary>
126         public static readonly Regex StatusUrlRegex = new Regex(@"https?://([^.]+\.)?twitter\.com/(#!/)?(?<ScreenName>[a-zA-Z0-9_]+)/status(es)?/(?<StatusId>[0-9]+)(/photo)?", RegexOptions.IgnoreCase);
127
128         /// <summary>
129         /// attachment_url に指定可能な URL を判定する正規表現
130         /// </summary>
131         public static readonly Regex AttachmentUrlRegex = new Regex(@"https?://(
132    twitter\.com/[0-9A-Za-z_]+/status/[0-9]+
133  | mobile\.twitter\.com/[0-9A-Za-z_]+/status/[0-9]+
134  | twitter\.com/messages/compose\?recipient_id=[0-9]+(&.+)?
135 )$", RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace);
136
137         /// <summary>
138         /// FavstarやaclogなどTwitter関連サービスのパーマリンクURLからステータスIDを抽出する正規表現
139         /// </summary>
140         public static readonly Regex ThirdPartyStatusUrlRegex = new Regex(@"https?://(?:[^.]+\.)?(?:
141   favstar\.fm/users/[a-zA-Z0-9_]+/status/       # Favstar
142 | favstar\.fm/t/                                # Favstar (short)
143 | aclog\.koba789\.com/i/                        # aclog
144 | frtrt\.net/solo_status\.php\?status=          # RtRT
145 )(?<StatusId>[0-9]+)", RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace);
146
147         /// <summary>
148         /// DM送信かどうかを判定する正規表現
149         /// </summary>
150         public static readonly Regex DMSendTextRegex = new Regex(@"^DM? +(?<id>[a-zA-Z0-9_]+) +(?<body>.*)", RegexOptions.IgnoreCase | RegexOptions.Singleline);
151
152         public TwitterApi Api { get; }
153         public TwitterConfiguration Configuration { get; private set; }
154         public TwitterTextConfiguration TextConfiguration { get; private set; }
155
156         public bool GetFollowersSuccess { get; private set; } = false;
157         public bool GetNoRetweetSuccess { get; private set; } = false;
158
159         delegate void GetIconImageDelegate(PostClass post);
160         private readonly object LockObj = new object();
161         private ISet<long> followerId = new HashSet<long>();
162         private long[] noRTId = Array.Empty<long>();
163
164         //プロパティからアクセスされる共通情報
165         private List<string> _hashList = new List<string>();
166
167         private string nextCursorDirectMessage = null;
168
169         private long previousStatusId = -1L;
170
171         //private FavoriteQueue favQueue;
172
173         //private List<PostClass> _deletemessages = new List<PostClass>();
174
175         public Twitter() : this(new TwitterApi())
176         {
177         }
178
179         public Twitter(TwitterApi api)
180         {
181             this.Api = api;
182             this.Configuration = TwitterConfiguration.DefaultConfiguration();
183             this.TextConfiguration = TwitterTextConfiguration.DefaultConfiguration();
184         }
185
186         public TwitterApiAccessLevel AccessLevel
187             => MyCommon.TwitterApiInfo.AccessLevel;
188
189         protected void ResetApiStatus()
190             => MyCommon.TwitterApiInfo.Reset();
191
192         public void ClearAuthInfo()
193         {
194             Twitter.AccountState = MyCommon.ACCOUNT_STATE.Invalid;
195             this.ResetApiStatus();
196         }
197
198         [Obsolete]
199         public void VerifyCredentials()
200         {
201             try
202             {
203                 this.VerifyCredentialsAsync().Wait();
204             }
205             catch (AggregateException ex) when (ex.InnerException is WebApiException)
206             {
207                 throw new WebApiException(ex.InnerException.Message, ex);
208             }
209         }
210
211         public async Task VerifyCredentialsAsync()
212         {
213             var user = await this.Api.AccountVerifyCredentials()
214                 .ConfigureAwait(false);
215
216             this.UpdateUserStats(user);
217         }
218
219         public void Initialize(string token, string tokenSecret, string username, long userId)
220         {
221             //OAuth認証
222             if (string.IsNullOrEmpty(token) || string.IsNullOrEmpty(tokenSecret) || string.IsNullOrEmpty(username))
223             {
224                 Twitter.AccountState = MyCommon.ACCOUNT_STATE.Invalid;
225             }
226             this.ResetApiStatus();
227             this.Api.Initialize(token, tokenSecret, userId, username);
228             if (SettingManager.Common.UserstreamStartup) this.ReconnectUserStream();
229         }
230
231         internal static string PreProcessUrl(string orgData)
232         {
233             int posl1;
234             var posl2 = 0;
235             //var IDNConveter = new IdnMapping();
236             var href = "<a href=\"";
237
238             while (true)
239             {
240                 if (orgData.IndexOf(href, posl2, StringComparison.Ordinal) > -1)
241                 {
242                     var urlStr = "";
243                     // IDN展開
244                     posl1 = orgData.IndexOf(href, posl2, StringComparison.Ordinal);
245                     posl1 += href.Length;
246                     posl2 = orgData.IndexOf("\"", posl1, StringComparison.Ordinal);
247                     urlStr = orgData.Substring(posl1, posl2 - posl1);
248
249                     if (!urlStr.StartsWith("http://", StringComparison.Ordinal)
250                         && !urlStr.StartsWith("https://", StringComparison.Ordinal)
251                         && !urlStr.StartsWith("ftp://", StringComparison.Ordinal))
252                     {
253                         continue;
254                     }
255
256                     var replacedUrl = MyCommon.IDNEncode(urlStr);
257                     if (replacedUrl == null) continue;
258                     if (replacedUrl == urlStr) continue;
259
260                     orgData = orgData.Replace("<a href=\"" + urlStr, "<a href=\"" + replacedUrl);
261                     posl2 = 0;
262                 }
263                 else
264                 {
265                     break;
266                 }
267             }
268             return orgData;
269         }
270
271         public async Task<PostClass> PostStatus(PostStatusParams param)
272         {
273             this.CheckAccountState();
274
275             if (Twitter.DMSendTextRegex.IsMatch(param.Text))
276             {
277                 var mediaId = param.MediaIds != null && param.MediaIds.Any() ? param.MediaIds[0] : (long?)null;
278
279                 await this.SendDirectMessage(param.Text, mediaId)
280                     .ConfigureAwait(false);
281                 return null;
282             }
283
284             var response = await this.Api.StatusesUpdate(param.Text, param.InReplyToStatusId, param.MediaIds,
285                     param.AutoPopulateReplyMetadata, param.ExcludeReplyUserIds, param.AttachmentUrl)
286                 .ConfigureAwait(false);
287
288             var status = await response.LoadJsonAsync()
289                 .ConfigureAwait(false);
290
291             this.UpdateUserStats(status.User);
292
293             if (status.Id == this.previousStatusId)
294                 throw new WebApiException("OK:Delaying?");
295
296             this.previousStatusId = status.Id;
297
298             //投稿したものを返す
299             var post = CreatePostsFromStatusData(status);
300             if (this.ReadOwnPost) post.IsRead = true;
301             return post;
302         }
303
304         public async Task<long> UploadMedia(IMediaItem item, string mediaCategory = null)
305         {
306             this.CheckAccountState();
307
308             string mediaType;
309
310             switch (item.Extension)
311             {
312                 case ".png":
313                     mediaType = "image/png";
314                     break;
315                 case ".jpg":
316                 case ".jpeg":
317                     mediaType = "image/jpeg";
318                     break;
319                 case ".gif":
320                     mediaType = "image/gif";
321                     break;
322                 default:
323                     mediaType = "application/octet-stream";
324                     break;
325             }
326
327             var initResponse = await this.Api.MediaUploadInit(item.Size, mediaType, mediaCategory)
328                 .ConfigureAwait(false);
329
330             var initMedia = await initResponse.LoadJsonAsync()
331                 .ConfigureAwait(false);
332
333             var mediaId = initMedia.MediaId;
334
335             await this.Api.MediaUploadAppend(mediaId, 0, item)
336                 .ConfigureAwait(false);
337
338             var response = await this.Api.MediaUploadFinalize(mediaId)
339                 .ConfigureAwait(false);
340
341             var media = await response.LoadJsonAsync()
342                 .ConfigureAwait(false);
343
344             while (media.ProcessingInfo is TwitterUploadMediaResult.MediaProcessingInfo processingInfo)
345             {
346                 switch (processingInfo.State)
347                 {
348                     case "pending":
349                         break;
350                     case "in_progress":
351                         break;
352                     case "succeeded":
353                         goto succeeded;
354                     case "failed":
355                         throw new WebApiException($"Err:Upload failed ({processingInfo.Error?.Name})");
356                     default:
357                         throw new WebApiException($"Err:Invalid state ({processingInfo.State})");
358                 }
359
360                 await Task.Delay(TimeSpan.FromSeconds(processingInfo.CheckAfterSecs ?? 5))
361                     .ConfigureAwait(false);
362
363                 media = await this.Api.MediaUploadStatus(mediaId)
364                     .ConfigureAwait(false);
365             }
366
367             succeeded:
368             return media.MediaId;
369         }
370
371         public async Task SendDirectMessage(string postStr, long? mediaId = null)
372         {
373             this.CheckAccountState();
374             this.CheckAccessLevel(TwitterApiAccessLevel.ReadWriteAndDirectMessage);
375
376             var mc = Twitter.DMSendTextRegex.Match(postStr);
377
378             var body = mc.Groups["body"].Value;
379             var recipientName = mc.Groups["id"].Value;
380
381             var recipient = await this.Api.UsersShow(recipientName)
382                 .ConfigureAwait(false);
383
384             var response = await this.Api.DirectMessagesEventsNew(recipient.Id, body, mediaId)
385                 .ConfigureAwait(false);
386
387             var messageEventSingle = await response.LoadJsonAsync()
388                 .ConfigureAwait(false);
389
390             await this.CreateDirectMessagesEventFromJson(messageEventSingle, read: true)
391                 .ConfigureAwait(false);
392         }
393
394         public async Task<PostClass> PostRetweet(long id, bool read)
395         {
396             this.CheckAccountState();
397
398             //データ部分の生成
399             var post = TabInformations.GetInstance()[id];
400             if (post == null)
401                 throw new WebApiException("Err:Target isn't found.");
402
403             var target = post.RetweetedId ?? id;  //再RTの場合は元発言をRT
404
405             var response = await this.Api.StatusesRetweet(target)
406                 .ConfigureAwait(false);
407
408             var status = await response.LoadJsonAsync()
409                 .ConfigureAwait(false);
410
411             //二重取得回避
412             lock (LockObj)
413             {
414                 if (TabInformations.GetInstance().ContainsKey(status.Id))
415                     return null;
416             }
417
418             //Retweet判定
419             if (status.RetweetedStatus == null)
420                 throw new WebApiException("Invalid Json!");
421
422             //Retweetしたものを返す
423             post = CreatePostsFromStatusData(status);
424
425             //ユーザー情報
426             post.IsMe = true;
427
428             post.IsRead = read;
429             post.IsOwl = false;
430             if (this.ReadOwnPost) post.IsRead = true;
431             post.IsDm = false;
432
433             return post;
434         }
435
436         public string Username
437             => this.Api.CurrentScreenName;
438
439         public long UserId
440             => this.Api.CurrentUserId;
441
442         public static MyCommon.ACCOUNT_STATE AccountState { get; set; } = MyCommon.ACCOUNT_STATE.Valid;
443         public bool RestrictFavCheck { get; set; }
444         public bool ReadOwnPost { get; set; }
445
446         public int FollowersCount { get; private set; }
447         public int FriendsCount { get; private set; }
448         public int StatusesCount { get; private set; }
449         public string Location { get; private set; } = "";
450         public string Bio { get; private set; } = "";
451
452         /// <summary>ユーザーのフォロワー数などの情報を更新します</summary>
453         private void UpdateUserStats(TwitterUser self)
454         {
455             this.FollowersCount = self.FollowersCount;
456             this.FriendsCount = self.FriendsCount;
457             this.StatusesCount = self.StatusesCount;
458             this.Location = self.Location;
459             this.Bio = self.Description;
460         }
461
462         /// <summary>
463         /// 渡された取得件数がWORKERTYPEに応じた取得可能範囲に収まっているか検証する
464         /// </summary>
465         public static bool VerifyApiResultCount(MyCommon.WORKERTYPE type, int count)
466             => count >= 20 && count <= GetMaxApiResultCount(type);
467
468         /// <summary>
469         /// 渡された取得件数が更新時の取得可能範囲に収まっているか検証する
470         /// </summary>
471         public static bool VerifyMoreApiResultCount(int count)
472             => count >= 20 && count <= 200;
473
474         /// <summary>
475         /// 渡された取得件数が起動時の取得可能範囲に収まっているか検証する
476         /// </summary>
477         public static bool VerifyFirstApiResultCount(int count)
478             => count >= 20 && count <= 200;
479
480         /// <summary>
481         /// WORKERTYPEに応じた取得可能な最大件数を取得する
482         /// </summary>
483         public static int GetMaxApiResultCount(MyCommon.WORKERTYPE type)
484         {
485             // 参照: REST APIs - 各endpointのcountパラメータ
486             // https://dev.twitter.com/rest/public
487             switch (type)
488             {
489                 case MyCommon.WORKERTYPE.Timeline:
490                 case MyCommon.WORKERTYPE.Reply:
491                 case MyCommon.WORKERTYPE.UserTimeline:
492                 case MyCommon.WORKERTYPE.Favorites:
493                 case MyCommon.WORKERTYPE.List:  // 不明
494                     return 200;
495
496                 case MyCommon.WORKERTYPE.PublicSearch:
497                     return 100;
498
499                 default:
500                     throw new InvalidOperationException("Invalid type: " + type);
501             }
502         }
503
504         /// <summary>
505         /// WORKERTYPEに応じた取得件数を取得する
506         /// </summary>
507         public static int GetApiResultCount(MyCommon.WORKERTYPE type, bool more, bool startup)
508         {
509             if (SettingManager.Common.UseAdditionalCount)
510             {
511                 switch (type)
512                 {
513                     case MyCommon.WORKERTYPE.Favorites:
514                         if (SettingManager.Common.FavoritesCountApi != 0)
515                             return SettingManager.Common.FavoritesCountApi;
516                         break;
517                     case MyCommon.WORKERTYPE.List:
518                         if (SettingManager.Common.ListCountApi != 0)
519                             return SettingManager.Common.ListCountApi;
520                         break;
521                     case MyCommon.WORKERTYPE.PublicSearch:
522                         if (SettingManager.Common.SearchCountApi != 0)
523                             return SettingManager.Common.SearchCountApi;
524                         break;
525                     case MyCommon.WORKERTYPE.UserTimeline:
526                         if (SettingManager.Common.UserTimelineCountApi != 0)
527                             return SettingManager.Common.UserTimelineCountApi;
528                         break;
529                 }
530                 if (more && SettingManager.Common.MoreCountApi != 0)
531                 {
532                     return Math.Min(SettingManager.Common.MoreCountApi, GetMaxApiResultCount(type));
533                 }
534                 if (startup && SettingManager.Common.FirstCountApi != 0 && type != MyCommon.WORKERTYPE.Reply)
535                 {
536                     return Math.Min(SettingManager.Common.FirstCountApi, GetMaxApiResultCount(type));
537                 }
538             }
539
540             // 上記に当てはまらない場合の共通処理
541             var count = SettingManager.Common.CountApi;
542
543             if (type == MyCommon.WORKERTYPE.Reply)
544                 count = SettingManager.Common.CountApiReply;
545
546             return Math.Min(count, GetMaxApiResultCount(type));
547         }
548
549         public async Task GetHomeTimelineApi(bool read, HomeTabModel tab, bool more, bool startup)
550         {
551             this.CheckAccountState();
552
553             var count = GetApiResultCount(MyCommon.WORKERTYPE.Timeline, more, startup);
554
555             TwitterStatus[] statuses;
556             if (more)
557             {
558                 statuses = await this.Api.StatusesHomeTimeline(count, maxId: tab.OldestId)
559                     .ConfigureAwait(false);
560             }
561             else
562             {
563                 statuses = await this.Api.StatusesHomeTimeline(count)
564                     .ConfigureAwait(false);
565             }
566
567             var minimumId = this.CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.Timeline, tab, read);
568             if (minimumId != null)
569                 tab.OldestId = minimumId.Value;
570         }
571
572         public async Task GetMentionsTimelineApi(bool read, MentionsTabModel tab, bool more, bool startup)
573         {
574             this.CheckAccountState();
575
576             var count = GetApiResultCount(MyCommon.WORKERTYPE.Reply, more, startup);
577
578             TwitterStatus[] statuses;
579             if (more)
580             {
581                 statuses = await this.Api.StatusesMentionsTimeline(count, maxId: tab.OldestId)
582                     .ConfigureAwait(false);
583             }
584             else
585             {
586                 statuses = await this.Api.StatusesMentionsTimeline(count)
587                     .ConfigureAwait(false);
588             }
589
590             var minimumId = this.CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.Reply, tab, read);
591             if (minimumId != null)
592                 tab.OldestId = minimumId.Value;
593         }
594
595         public async Task GetUserTimelineApi(bool read, string userName, UserTimelineTabModel tab, bool more)
596         {
597             this.CheckAccountState();
598
599             var count = GetApiResultCount(MyCommon.WORKERTYPE.UserTimeline, more, false);
600
601             TwitterStatus[] statuses;
602             if (string.IsNullOrEmpty(userName))
603             {
604                 var target = tab.ScreenName;
605                 if (string.IsNullOrEmpty(target)) return;
606                 userName = target;
607                 statuses = await this.Api.StatusesUserTimeline(userName, count)
608                     .ConfigureAwait(false);
609             }
610             else
611             {
612                 if (more)
613                 {
614                     statuses = await this.Api.StatusesUserTimeline(userName, count, maxId: tab.OldestId)
615                         .ConfigureAwait(false);
616                 }
617                 else
618                 {
619                     statuses = await this.Api.StatusesUserTimeline(userName, count)
620                         .ConfigureAwait(false);
621                 }
622             }
623
624             var minimumId = CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.UserTimeline, tab, read);
625
626             if (minimumId != null)
627                 tab.OldestId = minimumId.Value;
628         }
629
630         public async Task<PostClass> GetStatusApi(bool read, long id)
631         {
632             this.CheckAccountState();
633
634             var status = await this.Api.StatusesShow(id)
635                 .ConfigureAwait(false);
636
637             var item = CreatePostsFromStatusData(status);
638
639             item.IsRead = read;
640             if (item.IsMe && !read && this.ReadOwnPost) item.IsRead = true;
641
642             return item;
643         }
644
645         public async Task GetStatusApi(bool read, long id, TabModel tab)
646         {
647             var post = await this.GetStatusApi(read, id)
648                 .ConfigureAwait(false);
649
650             //非同期アイコン取得&StatusDictionaryに追加
651             if (tab != null && tab.IsInnerStorageTabType)
652                 tab.AddPostQueue(post);
653             else
654                 TabInformations.GetInstance().AddPost(post);
655         }
656
657         private PostClass CreatePostsFromStatusData(TwitterStatus status)
658             => this.CreatePostsFromStatusData(status, false);
659
660         private PostClass CreatePostsFromStatusData(TwitterStatus status, bool favTweet)
661         {
662             var post = new PostClass();
663             TwitterEntities entities;
664             string sourceHtml;
665
666             post.StatusId = status.Id;
667             if (status.RetweetedStatus != null)
668             {
669                 var retweeted = status.RetweetedStatus;
670
671                 post.CreatedAt = MyCommon.DateTimeParse(retweeted.CreatedAt);
672
673                 //Id
674                 post.RetweetedId = retweeted.Id;
675                 //本文
676                 post.TextFromApi = retweeted.FullText;
677                 entities = retweeted.MergedEntities;
678                 sourceHtml = retweeted.Source;
679                 //Reply先
680                 post.InReplyToStatusId = retweeted.InReplyToStatusId;
681                 post.InReplyToUser = retweeted.InReplyToScreenName;
682                 post.InReplyToUserId = status.InReplyToUserId;
683
684                 if (favTweet)
685                 {
686                     post.IsFav = true;
687                 }
688                 else
689                 {
690                     //幻覚fav対策
691                     var tc = TabInformations.GetInstance().GetTabByType(MyCommon.TabUsageType.Favorites);
692                     post.IsFav = tc.Contains(retweeted.Id);
693                 }
694
695                 if (retweeted.Coordinates != null)
696                     post.PostGeo = new PostClass.StatusGeo(retweeted.Coordinates.Coordinates[0], retweeted.Coordinates.Coordinates[1]);
697
698                 //以下、ユーザー情報
699                 var user = retweeted.User;
700                 if (user != null)
701                 {
702                     post.UserId = user.Id;
703                     post.ScreenName = user.ScreenName;
704                     post.Nickname = user.Name.Trim();
705                     post.ImageUrl = user.ProfileImageUrlHttps;
706                     post.IsProtect = user.Protected;
707                 }
708                 else
709                 {
710                     post.UserId = 0L;
711                     post.ScreenName = "?????";
712                     post.Nickname = "Unknown User";
713                 }
714
715                 //Retweetした人
716                 if (status.User != null)
717                 {
718                     post.RetweetedBy = status.User.ScreenName;
719                     post.RetweetedByUserId = status.User.Id;
720                     post.IsMe = post.RetweetedByUserId == this.UserId;
721                 }
722                 else
723                 {
724                     post.RetweetedBy = "?????";
725                     post.RetweetedByUserId = 0L;
726                 }
727             }
728             else
729             {
730                 post.CreatedAt = MyCommon.DateTimeParse(status.CreatedAt);
731                 //本文
732                 post.TextFromApi = status.FullText;
733                 entities = status.MergedEntities;
734                 sourceHtml = status.Source;
735                 post.InReplyToStatusId = status.InReplyToStatusId;
736                 post.InReplyToUser = status.InReplyToScreenName;
737                 post.InReplyToUserId = status.InReplyToUserId;
738
739                 if (favTweet)
740                 {
741                     post.IsFav = true;
742                 }
743                 else
744                 {
745                     //幻覚fav対策
746                     var tc = TabInformations.GetInstance().GetTabByType(MyCommon.TabUsageType.Favorites);
747                     post.IsFav = tc.Contains(post.StatusId) && TabInformations.GetInstance()[post.StatusId].IsFav;
748                 }
749
750                 if (status.Coordinates != null)
751                     post.PostGeo = new PostClass.StatusGeo(status.Coordinates.Coordinates[0], status.Coordinates.Coordinates[1]);
752
753                 //以下、ユーザー情報
754                 var user = status.User;
755                 if (user != null)
756                 {
757                     post.UserId = user.Id;
758                     post.ScreenName = user.ScreenName;
759                     post.Nickname = user.Name.Trim();
760                     post.ImageUrl = user.ProfileImageUrlHttps;
761                     post.IsProtect = user.Protected;
762                     post.IsMe = post.UserId == this.UserId;
763                 }
764                 else
765                 {
766                     post.UserId = 0L;
767                     post.ScreenName = "?????";
768                     post.Nickname = "Unknown User";
769                 }
770             }
771             //HTMLに整形
772             string textFromApi = post.TextFromApi;
773
774             var quotedStatusLink = (status.RetweetedStatus ?? status).QuotedStatusPermalink;
775
776             if (quotedStatusLink != null && entities.Urls.Any(x => x.ExpandedUrl == quotedStatusLink.Expanded))
777                 quotedStatusLink = null; // 移行期は entities.urls と quoted_status_permalink の両方に含まれる場合がある
778
779             post.Text = CreateHtmlAnchor(textFromApi, entities, quotedStatusLink);
780             post.TextFromApi = textFromApi;
781             post.TextFromApi = this.ReplaceTextFromApi(post.TextFromApi, entities, quotedStatusLink);
782             post.TextFromApi = WebUtility.HtmlDecode(post.TextFromApi);
783             post.TextFromApi = post.TextFromApi.Replace("<3", "\u2661");
784             post.AccessibleText = CreateAccessibleText(textFromApi, entities, (status.RetweetedStatus ?? status).QuotedStatus, quotedStatusLink);
785             post.AccessibleText = WebUtility.HtmlDecode(post.AccessibleText);
786             post.AccessibleText = post.AccessibleText.Replace("<3", "\u2661");
787
788             this.ExtractEntities(entities, post.ReplyToList, post.Media);
789
790             post.QuoteStatusIds = GetQuoteTweetStatusIds(entities, quotedStatusLink)
791                 .Where(x => x != post.StatusId && x != post.RetweetedId)
792                 .Distinct().ToArray();
793
794             post.ExpandedUrls = entities.OfType<TwitterEntityUrl>()
795                 .Select(x => new PostClass.ExpandedUrlInfo(x.Url, x.ExpandedUrl))
796                 .ToArray();
797
798             // メモリ使用量削減 (同一のテキストであれば同一の string インスタンスを参照させる)
799             if (post.Text == post.TextFromApi)
800                 post.Text = post.TextFromApi;
801             if (post.AccessibleText == post.TextFromApi)
802                 post.AccessibleText = post.TextFromApi;
803
804             // 他の発言と重複しやすい (共通化できる) 文字列は string.Intern を通す
805             post.ScreenName = string.Intern(post.ScreenName);
806             post.Nickname = string.Intern(post.Nickname);
807             post.ImageUrl = string.Intern(post.ImageUrl);
808             post.RetweetedBy = post.RetweetedBy != null ? string.Intern(post.RetweetedBy) : null;
809
810             //Source整形
811             var (sourceText, sourceUri) = ParseSource(sourceHtml);
812             post.Source = string.Intern(sourceText);
813             post.SourceUri = sourceUri;
814
815             post.IsReply = post.RetweetedId == null && post.ReplyToList.Any(x => x.UserId == this.UserId);
816             post.IsExcludeReply = false;
817
818             if (post.IsMe)
819             {
820                 post.IsOwl = false;
821             }
822             else
823             {
824                 if (followerId.Count > 0) post.IsOwl = !followerId.Contains(post.UserId);
825             }
826
827             post.IsDm = false;
828             return post;
829         }
830
831         /// <summary>
832         /// ツイートに含まれる引用ツイートのURLからステータスIDを抽出
833         /// </summary>
834         public static IEnumerable<long> GetQuoteTweetStatusIds(IEnumerable<TwitterEntity> entities, TwitterQuotedStatusPermalink quotedStatusLink)
835         {
836             var urls = entities.OfType<TwitterEntityUrl>().Select(x => x.ExpandedUrl);
837
838             if (quotedStatusLink != null)
839                 urls = urls.Append(quotedStatusLink.Expanded);
840
841             return GetQuoteTweetStatusIds(urls);
842         }
843
844         public static IEnumerable<long> GetQuoteTweetStatusIds(IEnumerable<string> urls)
845         {
846             foreach (var url in urls)
847             {
848                 var match = Twitter.StatusUrlRegex.Match(url);
849                 if (match.Success)
850                 {
851                     if (long.TryParse(match.Groups["StatusId"].Value, out var statusId))
852                         yield return statusId;
853                 }
854             }
855         }
856
857         private long? CreatePostsFromJson(TwitterStatus[] items, MyCommon.WORKERTYPE gType, TabModel tab, bool read)
858         {
859             long? minimumId = null;
860
861             foreach (var status in items)
862             {
863                 if (minimumId == null || minimumId.Value > status.Id)
864                     minimumId = status.Id;
865
866                 //二重取得回避
867                 lock (LockObj)
868                 {
869                     if (tab == null)
870                     {
871                         if (TabInformations.GetInstance().ContainsKey(status.Id)) continue;
872                     }
873                     else
874                     {
875                         if (tab.Contains(status.Id)) continue;
876                     }
877                 }
878
879                 //RT禁止ユーザーによるもの
880                 if (gType != MyCommon.WORKERTYPE.UserTimeline &&
881                     status.RetweetedStatus != null && this.noRTId.Contains(status.User.Id)) continue;
882
883                 var post = CreatePostsFromStatusData(status);
884
885                 post.IsRead = read;
886                 if (post.IsMe && !read && this.ReadOwnPost) post.IsRead = true;
887
888                 if (tab != null && tab.IsInnerStorageTabType)
889                     tab.AddPostQueue(post);
890                 else
891                     TabInformations.GetInstance().AddPost(post);
892             }
893
894             return minimumId;
895         }
896
897         private long? CreatePostsFromSearchJson(TwitterSearchResult items, PublicSearchTabModel tab, bool read, bool more)
898         {
899             long? minimumId = null;
900
901             foreach (var status in items.Statuses)
902             {
903                 if (minimumId == null || minimumId.Value > status.Id)
904                     minimumId = status.Id;
905
906                 if (!more && status.Id > tab.SinceId) tab.SinceId = status.Id;
907                 //二重取得回避
908                 lock (LockObj)
909                 {
910                     if (tab.Contains(status.Id)) continue;
911                 }
912
913                 var post = CreatePostsFromStatusData(status);
914
915                 post.IsRead = read;
916                 if ((post.IsMe && !read) && this.ReadOwnPost) post.IsRead = true;
917
918                 tab.AddPostQueue(post);
919             }
920
921             return minimumId;
922         }
923
924         private long? CreateFavoritePostsFromJson(TwitterStatus[] items, bool read)
925         {
926             var favTab = TabInformations.GetInstance().GetTabByType(MyCommon.TabUsageType.Favorites);
927             long? minimumId = null;
928
929             foreach (var status in items)
930             {
931                 if (minimumId == null || minimumId.Value > status.Id)
932                     minimumId = status.Id;
933
934                 //二重取得回避
935                 lock (LockObj)
936                 {
937                     if (favTab.Contains(status.Id)) continue;
938                 }
939
940                 var post = CreatePostsFromStatusData(status, true);
941
942                 post.IsRead = read;
943
944                 TabInformations.GetInstance().AddPost(post);
945             }
946
947             return minimumId;
948         }
949
950         public async Task GetListStatus(bool read, ListTimelineTabModel tab, bool more, bool startup)
951         {
952             var count = GetApiResultCount(MyCommon.WORKERTYPE.List, more, startup);
953
954             TwitterStatus[] statuses;
955             if (more)
956             {
957                 statuses = await this.Api.ListsStatuses(tab.ListInfo.Id, count, maxId: tab.OldestId, includeRTs: SettingManager.Common.IsListsIncludeRts)
958                     .ConfigureAwait(false);
959             }
960             else
961             {
962                 statuses = await this.Api.ListsStatuses(tab.ListInfo.Id, count, includeRTs: SettingManager.Common.IsListsIncludeRts)
963                     .ConfigureAwait(false);
964             }
965
966             var minimumId = CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.List, tab, read);
967
968             if (minimumId != null)
969                 tab.OldestId = minimumId.Value;
970         }
971
972         /// <summary>
973         /// startStatusId からリプライ先の発言を辿る。発言は posts 以外からは検索しない。
974         /// </summary>
975         /// <returns>posts の中から検索されたリプライチェインの末端</returns>
976         internal static PostClass FindTopOfReplyChain(IDictionary<Int64, PostClass> posts, Int64 startStatusId)
977         {
978             if (!posts.ContainsKey(startStatusId))
979                 throw new ArgumentException("startStatusId (" + startStatusId + ") が posts の中から見つかりませんでした。", nameof(startStatusId));
980
981             var nextPost = posts[startStatusId];
982             while (nextPost.InReplyToStatusId != null)
983             {
984                 if (!posts.ContainsKey(nextPost.InReplyToStatusId.Value))
985                     break;
986                 nextPost = posts[nextPost.InReplyToStatusId.Value];
987             }
988
989             return nextPost;
990         }
991
992         public async Task GetRelatedResult(bool read, RelatedPostsTabModel tab)
993         {
994             var targetPost = tab.TargetPost;
995             var relPosts = new Dictionary<Int64, PostClass>();
996             if (targetPost.TextFromApi.Contains("@") && targetPost.InReplyToStatusId == null)
997             {
998                 //検索結果対応
999                 var p = TabInformations.GetInstance()[targetPost.StatusId];
1000                 if (p != null && p.InReplyToStatusId != null)
1001                 {
1002                     targetPost = p;
1003                 }
1004                 else
1005                 {
1006                     p = await this.GetStatusApi(read, targetPost.StatusId)
1007                         .ConfigureAwait(false);
1008                     targetPost = p;
1009                 }
1010             }
1011             relPosts.Add(targetPost.StatusId, targetPost);
1012
1013             Exception lastException = null;
1014
1015             // in_reply_to_status_id を使用してリプライチェインを辿る
1016             var nextPost = FindTopOfReplyChain(relPosts, targetPost.StatusId);
1017             var loopCount = 1;
1018             while (nextPost.InReplyToStatusId != null && loopCount++ <= 20)
1019             {
1020                 var inReplyToId = nextPost.InReplyToStatusId.Value;
1021
1022                 var inReplyToPost = TabInformations.GetInstance()[inReplyToId];
1023                 if (inReplyToPost == null)
1024                 {
1025                     try
1026                     {
1027                         inReplyToPost = await this.GetStatusApi(read, inReplyToId)
1028                             .ConfigureAwait(false);
1029                     }
1030                     catch (WebApiException ex)
1031                     {
1032                         lastException = ex;
1033                         break;
1034                     }
1035                 }
1036
1037                 relPosts.Add(inReplyToPost.StatusId, inReplyToPost);
1038
1039                 nextPost = FindTopOfReplyChain(relPosts, nextPost.StatusId);
1040             }
1041
1042             //MRTとかに対応のためツイート内にあるツイートを指すURLを取り込む
1043             var text = targetPost.Text;
1044             var ma = Twitter.StatusUrlRegex.Matches(text).Cast<Match>()
1045                 .Concat(Twitter.ThirdPartyStatusUrlRegex.Matches(text).Cast<Match>());
1046             foreach (var _match in ma)
1047             {
1048                 if (Int64.TryParse(_match.Groups["StatusId"].Value, out var _statusId))
1049                 {
1050                     if (relPosts.ContainsKey(_statusId))
1051                         continue;
1052
1053                     var p = TabInformations.GetInstance()[_statusId];
1054                     if (p == null)
1055                     {
1056                         try
1057                         {
1058                             p = await this.GetStatusApi(read, _statusId)
1059                                 .ConfigureAwait(false);
1060                         }
1061                         catch (WebApiException ex)
1062                         {
1063                             lastException = ex;
1064                             break;
1065                         }
1066                     }
1067
1068                     if (p != null)
1069                         relPosts.Add(p.StatusId, p);
1070                 }
1071             }
1072
1073             relPosts.Values.ToList().ForEach(p =>
1074             {
1075                 if (p.IsMe && !read && this.ReadOwnPost)
1076                     p.IsRead = true;
1077                 else
1078                     p.IsRead = read;
1079
1080                 tab.AddPostQueue(p);
1081             });
1082
1083             if (lastException != null)
1084                 throw new WebApiException(lastException.Message, lastException);
1085         }
1086
1087         public async Task GetSearch(bool read, PublicSearchTabModel tab, bool more)
1088         {
1089             var count = GetApiResultCount(MyCommon.WORKERTYPE.PublicSearch, more, false);
1090
1091             long? maxId = null;
1092             long? sinceId = null;
1093             if (more)
1094             {
1095                 maxId = tab.OldestId - 1;
1096             }
1097             else
1098             {
1099                 sinceId = tab.SinceId;
1100             }
1101
1102             var searchResult = await this.Api.SearchTweets(tab.SearchWords, tab.SearchLang, count, maxId, sinceId)
1103                 .ConfigureAwait(false);
1104
1105             if (!TabInformations.GetInstance().ContainsTab(tab))
1106                 return;
1107
1108             var minimumId = this.CreatePostsFromSearchJson(searchResult, tab, read, more);
1109
1110             if (minimumId != null)
1111                 tab.OldestId = minimumId.Value;
1112         }
1113
1114         private void CreateDirectMessagesFromJson(TwitterDirectMessage[] item, MyCommon.WORKERTYPE gType, bool read)
1115         {
1116             foreach (var message in item)
1117             {
1118                 var post = new PostClass();
1119                 try
1120                 {
1121                     post.StatusId = message.Id;
1122
1123                     //二重取得回避
1124                     lock (LockObj)
1125                     {
1126                         if (TabInformations.GetInstance().GetTabByType(MyCommon.TabUsageType.DirectMessage).Contains(post.StatusId)) continue;
1127                     }
1128                     //sender_id
1129                     //recipient_id
1130                     post.CreatedAt = MyCommon.DateTimeParse(message.CreatedAt);
1131                     //本文
1132                     var textFromApi = message.Text;
1133                     //HTMLに整形
1134                     post.Text = CreateHtmlAnchor(textFromApi, message.Entities, quotedStatusLink: null);
1135                     post.TextFromApi = this.ReplaceTextFromApi(textFromApi, message.Entities, quotedStatusLink: null);
1136                     post.TextFromApi = WebUtility.HtmlDecode(post.TextFromApi);
1137                     post.TextFromApi = post.TextFromApi.Replace("<3", "\u2661");
1138                     post.AccessibleText = CreateAccessibleText(textFromApi, message.Entities, quotedStatus: null, quotedStatusLink: null);
1139                     post.AccessibleText = WebUtility.HtmlDecode(post.AccessibleText);
1140                     post.AccessibleText = post.AccessibleText.Replace("<3", "\u2661");
1141                     post.IsFav = false;
1142
1143                     this.ExtractEntities(message.Entities, post.ReplyToList, post.Media);
1144
1145                     post.QuoteStatusIds = GetQuoteTweetStatusIds(message.Entities, quotedStatusLink: null)
1146                         .Distinct().ToArray();
1147
1148                     post.ExpandedUrls = message.Entities.OfType<TwitterEntityUrl>()
1149                         .Select(x => new PostClass.ExpandedUrlInfo(x.Url, x.ExpandedUrl))
1150                         .ToArray();
1151
1152                     //以下、ユーザー情報
1153                     TwitterUser user;
1154                     if (gType == MyCommon.WORKERTYPE.UserStream)
1155                     {
1156                         if (this.Api.CurrentUserId == message.Recipient.Id)
1157                         {
1158                             user = message.Sender;
1159                             post.IsMe = false;
1160                             post.IsOwl = true;
1161                         }
1162                         else
1163                         {
1164                             user = message.Recipient;
1165                             post.IsMe = true;
1166                             post.IsOwl = false;
1167                         }
1168                     }
1169                     else
1170                     {
1171                         if (gType == MyCommon.WORKERTYPE.DirectMessegeRcv)
1172                         {
1173                             user = message.Sender;
1174                             post.IsMe = false;
1175                             post.IsOwl = true;
1176                         }
1177                         else
1178                         {
1179                             user = message.Recipient;
1180                             post.IsMe = true;
1181                             post.IsOwl = false;
1182                         }
1183                     }
1184
1185                     post.UserId = user.Id;
1186                     post.ScreenName = user.ScreenName;
1187                     post.Nickname = user.Name.Trim();
1188                     post.ImageUrl = user.ProfileImageUrlHttps;
1189                     post.IsProtect = user.Protected;
1190
1191                     // メモリ使用量削減 (同一のテキストであれば同一の string インスタンスを参照させる)
1192                     if (post.Text == post.TextFromApi)
1193                         post.Text = post.TextFromApi;
1194                     if (post.AccessibleText == post.TextFromApi)
1195                         post.AccessibleText = post.TextFromApi;
1196
1197                     // 他の発言と重複しやすい (共通化できる) 文字列は string.Intern を通す
1198                     post.ScreenName = string.Intern(post.ScreenName);
1199                     post.Nickname = string.Intern(post.Nickname);
1200                     post.ImageUrl = string.Intern(post.ImageUrl);
1201                 }
1202                 catch(Exception ex)
1203                 {
1204                     MyCommon.TraceOut(ex, MethodBase.GetCurrentMethod().Name);
1205                     MessageBox.Show("Parse Error(CreateDirectMessagesFromJson)");
1206                     continue;
1207                 }
1208
1209                 post.IsRead = read;
1210                 if (post.IsMe && !read && this.ReadOwnPost) post.IsRead = true;
1211                 post.IsReply = false;
1212                 post.IsExcludeReply = false;
1213                 post.IsDm = true;
1214
1215                 var dmTab = TabInformations.GetInstance().GetTabByType(MyCommon.TabUsageType.DirectMessage);
1216                 dmTab.AddPostQueue(post);
1217             }
1218         }
1219
1220         public async Task GetDirectMessageEvents(bool read, bool backward)
1221         {
1222             this.CheckAccountState();
1223             this.CheckAccessLevel(TwitterApiAccessLevel.ReadWriteAndDirectMessage);
1224
1225             var count = 50;
1226
1227             TwitterMessageEventList eventList;
1228             if (backward)
1229             {
1230                 eventList = await this.Api.DirectMessagesEventsList(count, this.nextCursorDirectMessage)
1231                     .ConfigureAwait(false);
1232             }
1233             else
1234             {
1235                 eventList = await this.Api.DirectMessagesEventsList(count)
1236                     .ConfigureAwait(false);
1237             }
1238
1239             this.nextCursorDirectMessage = eventList.NextCursor;
1240
1241             await this.CreateDirectMessagesEventFromJson(eventList, read)
1242                 .ConfigureAwait(false);
1243         }
1244
1245         private async Task CreateDirectMessagesEventFromJson(TwitterMessageEventSingle eventSingle, bool read)
1246         {
1247             var eventList = new TwitterMessageEventList
1248             {
1249                 Apps = new Dictionary<string, TwitterMessageEventList.App>(),
1250                 Events = new[] { eventSingle.Event },
1251             };
1252
1253             await this.CreateDirectMessagesEventFromJson(eventList, read)
1254                 .ConfigureAwait(false);
1255         }
1256
1257         private async Task CreateDirectMessagesEventFromJson(TwitterMessageEventList eventList, bool read)
1258         {
1259             var events = eventList.Events
1260                 .Where(x => x.Type == "message_create")
1261                 .ToArray();
1262
1263             if (events.Length == 0)
1264                 return;
1265
1266             var userIds = Enumerable.Concat(
1267                 events.Select(x => x.MessageCreate.SenderId),
1268                 events.Select(x => x.MessageCreate.Target.RecipientId)
1269             ).Distinct().ToArray();
1270
1271             var users = (await this.Api.UsersLookup(userIds).ConfigureAwait(false))
1272                 .ToDictionary(x => x.IdStr);
1273
1274             var apps = eventList.Apps ?? new Dictionary<string, TwitterMessageEventList.App>();
1275
1276             this.CreateDirectMessagesEventFromJson(events, users, apps, read);
1277         }
1278
1279         private void CreateDirectMessagesEventFromJson(IEnumerable<TwitterMessageEvent> events, IReadOnlyDictionary<string, TwitterUser> users,
1280             IReadOnlyDictionary<string, TwitterMessageEventList.App> apps, bool read)
1281         {
1282             foreach (var eventItem in events)
1283             {
1284                 var post = new PostClass();
1285                 post.StatusId = long.Parse(eventItem.Id);
1286
1287                 var timestamp = long.Parse(eventItem.CreatedTimestamp);
1288                 post.CreatedAt = DateTimeUtc.UnixEpoch + TimeSpan.FromTicks(timestamp * TimeSpan.TicksPerMillisecond);
1289                 //本文
1290                 var textFromApi = eventItem.MessageCreate.MessageData.Text;
1291
1292                 var entities = eventItem.MessageCreate.MessageData.Entities;
1293                 var mediaEntity = eventItem.MessageCreate.MessageData.Attachment?.Media;
1294
1295                 if (mediaEntity != null)
1296                     entities.Media = new[] { mediaEntity };
1297
1298                 //HTMLに整形
1299                 post.Text = CreateHtmlAnchor(textFromApi, entities, quotedStatusLink: null);
1300                 post.TextFromApi = this.ReplaceTextFromApi(textFromApi, entities, quotedStatusLink: null);
1301                 post.TextFromApi = WebUtility.HtmlDecode(post.TextFromApi);
1302                 post.TextFromApi = post.TextFromApi.Replace("<3", "\u2661");
1303                 post.AccessibleText = CreateAccessibleText(textFromApi, entities, quotedStatus: null, quotedStatusLink: null);
1304                 post.AccessibleText = WebUtility.HtmlDecode(post.AccessibleText);
1305                 post.AccessibleText = post.AccessibleText.Replace("<3", "\u2661");
1306                 post.IsFav = false;
1307
1308                 this.ExtractEntities(entities, post.ReplyToList, post.Media);
1309
1310                 post.QuoteStatusIds = GetQuoteTweetStatusIds(entities, quotedStatusLink: null)
1311                     .Distinct().ToArray();
1312
1313                 post.ExpandedUrls = entities.OfType<TwitterEntityUrl>()
1314                     .Select(x => new PostClass.ExpandedUrlInfo(x.Url, x.ExpandedUrl))
1315                     .ToArray();
1316
1317                 //以下、ユーザー情報
1318                 string userId;
1319                 if (eventItem.MessageCreate.SenderId != this.Api.CurrentUserId.ToString(CultureInfo.InvariantCulture))
1320                 {
1321                     userId = eventItem.MessageCreate.SenderId;
1322                     post.IsMe = false;
1323                     post.IsOwl = true;
1324                 }
1325                 else
1326                 {
1327                     userId = eventItem.MessageCreate.Target.RecipientId;
1328                     post.IsMe = true;
1329                     post.IsOwl = false;
1330                 }
1331
1332                 if (!users.TryGetValue(userId, out var user))
1333                     continue;
1334
1335                 post.UserId = user.Id;
1336                 post.ScreenName = user.ScreenName;
1337                 post.Nickname = user.Name.Trim();
1338                 post.ImageUrl = user.ProfileImageUrlHttps;
1339                 post.IsProtect = user.Protected;
1340
1341                 // メモリ使用量削減 (同一のテキストであれば同一の string インスタンスを参照させる)
1342                 if (post.Text == post.TextFromApi)
1343                     post.Text = post.TextFromApi;
1344                 if (post.AccessibleText == post.TextFromApi)
1345                     post.AccessibleText = post.TextFromApi;
1346
1347                 // 他の発言と重複しやすい (共通化できる) 文字列は string.Intern を通す
1348                 post.ScreenName = string.Intern(post.ScreenName);
1349                 post.Nickname = string.Intern(post.Nickname);
1350                 post.ImageUrl = string.Intern(post.ImageUrl);
1351
1352                 var appId = eventItem.MessageCreate.SourceAppId;
1353                 if (appId != null && apps.TryGetValue(appId, out var app))
1354                 {
1355                     post.Source = string.Intern(app.Name);
1356
1357                     try
1358                     {
1359                         post.SourceUri = new Uri(SourceUriBase, app.Url);
1360                     }
1361                     catch (UriFormatException) { }
1362                 }
1363
1364                 post.IsRead = read;
1365                 if (post.IsMe && !read && this.ReadOwnPost)
1366                     post.IsRead = true;
1367                 post.IsReply = false;
1368                 post.IsExcludeReply = false;
1369                 post.IsDm = true;
1370
1371                 var dmTab = TabInformations.GetInstance().GetTabByType<DirectMessagesTabModel>();
1372                 dmTab.AddPostQueue(post);
1373             }
1374         }
1375
1376         public async Task GetFavoritesApi(bool read, FavoritesTabModel tab, bool backward)
1377         {
1378             this.CheckAccountState();
1379
1380             var count = GetApiResultCount(MyCommon.WORKERTYPE.Favorites, backward, false);
1381
1382             TwitterStatus[] statuses;
1383             if (backward)
1384             {
1385                 statuses = await this.Api.FavoritesList(count, maxId: tab.OldestId)
1386                     .ConfigureAwait(false);
1387             }
1388             else
1389             {
1390                 statuses = await this.Api.FavoritesList(count)
1391                     .ConfigureAwait(false);
1392             }
1393
1394             var minimumId = this.CreateFavoritePostsFromJson(statuses, read);
1395
1396             if (minimumId != null)
1397                 tab.OldestId = minimumId.Value;
1398         }
1399
1400         private string ReplaceTextFromApi(string text, TwitterEntities entities, TwitterQuotedStatusPermalink quotedStatusLink)
1401         {
1402             if (entities != null)
1403             {
1404                 if (entities.Urls != null)
1405                 {
1406                     foreach (var m in entities.Urls)
1407                     {
1408                         if (!string.IsNullOrEmpty(m.DisplayUrl)) text = text.Replace(m.Url, m.DisplayUrl);
1409                     }
1410                 }
1411                 if (entities.Media != null)
1412                 {
1413                     foreach (var m in entities.Media)
1414                     {
1415                         if (!string.IsNullOrEmpty(m.DisplayUrl)) text = text.Replace(m.Url, m.DisplayUrl);
1416                     }
1417                 }
1418             }
1419
1420             if (quotedStatusLink != null)
1421                 text += " " + quotedStatusLink.Display;
1422
1423             return text;
1424         }
1425
1426         internal static string CreateAccessibleText(string text, TwitterEntities entities, TwitterStatus quotedStatus, TwitterQuotedStatusPermalink quotedStatusLink)
1427         {
1428             if (entities == null)
1429                 return text;
1430
1431             if (entities.Urls != null)
1432             {
1433                 foreach (var entity in entities.Urls)
1434                 {
1435                     if (quotedStatus != null)
1436                     {
1437                         var matchStatusUrl = Twitter.StatusUrlRegex.Match(entity.ExpandedUrl);
1438                         if (matchStatusUrl.Success && matchStatusUrl.Groups["StatusId"].Value == quotedStatus.IdStr)
1439                         {
1440                             var quotedText = CreateAccessibleText(quotedStatus.FullText, quotedStatus.MergedEntities, quotedStatus: null, quotedStatusLink: null);
1441                             text = text.Replace(entity.Url, string.Format(Properties.Resources.QuoteStatus_AccessibleText, quotedStatus.User.ScreenName, quotedText));
1442                             continue;
1443                         }
1444                     }
1445
1446                     if (!string.IsNullOrEmpty(entity.DisplayUrl))
1447                         text = text.Replace(entity.Url, entity.DisplayUrl);
1448                 }
1449             }
1450
1451             if (entities.Media != null)
1452             {
1453                 foreach (var entity in entities.Media)
1454                 {
1455                     if (!string.IsNullOrEmpty(entity.AltText))
1456                     {
1457                         text = text.Replace(entity.Url, string.Format(Properties.Resources.ImageAltText, entity.AltText));
1458                     }
1459                     else if (!string.IsNullOrEmpty(entity.DisplayUrl))
1460                     {
1461                         text = text.Replace(entity.Url, entity.DisplayUrl);
1462                     }
1463                 }
1464             }
1465
1466             if (quotedStatusLink != null)
1467             {
1468                 var quoteText = CreateAccessibleText(quotedStatus.FullText, quotedStatus.MergedEntities, quotedStatus: null, quotedStatusLink: null);
1469                 text += " " + string.Format(Properties.Resources.QuoteStatus_AccessibleText, quotedStatus.User.ScreenName, quoteText);
1470             }
1471
1472             return text;
1473         }
1474
1475         /// <summary>
1476         /// フォロワーIDを更新します
1477         /// </summary>
1478         /// <exception cref="WebApiException"/>
1479         public async Task RefreshFollowerIds()
1480         {
1481             if (MyCommon._endingFlag) return;
1482
1483             var cursor = -1L;
1484             var newFollowerIds = Enumerable.Empty<long>();
1485             do
1486             {
1487                 var ret = await this.Api.FollowersIds(cursor)
1488                     .ConfigureAwait(false);
1489
1490                 if (ret.Ids == null)
1491                     throw new WebApiException("ret.ids == null");
1492
1493                 newFollowerIds = newFollowerIds.Concat(ret.Ids);
1494                 cursor = ret.NextCursor;
1495             } while (cursor != 0);
1496
1497             this.followerId = newFollowerIds.ToHashSet();
1498             TabInformations.GetInstance().RefreshOwl(this.followerId);
1499
1500             this.GetFollowersSuccess = true;
1501         }
1502
1503         /// <summary>
1504         /// RT 非表示ユーザーを更新します
1505         /// </summary>
1506         /// <exception cref="WebApiException"/>
1507         public async Task RefreshNoRetweetIds()
1508         {
1509             if (MyCommon._endingFlag) return;
1510
1511             this.noRTId = await this.Api.NoRetweetIds()
1512                 .ConfigureAwait(false);
1513
1514             this.GetNoRetweetSuccess = true;
1515         }
1516
1517         /// <summary>
1518         /// t.co の文字列長などの設定情報を更新します
1519         /// </summary>
1520         /// <exception cref="WebApiException"/>
1521         public async Task RefreshConfiguration()
1522         {
1523             this.Configuration = await this.Api.Configuration()
1524                 .ConfigureAwait(false);
1525
1526             // TextConfiguration 相当の JSON を得る API が存在しないため、TransformedURLLength のみ help/configuration.json に合わせて更新する
1527             this.TextConfiguration.TransformedURLLength = this.Configuration.ShortUrlLengthHttps;
1528         }
1529
1530         public async Task GetListsApi()
1531         {
1532             this.CheckAccountState();
1533
1534             var ownedLists = await TwitterLists.GetAllItemsAsync(x =>
1535                 this.Api.ListsOwnerships(this.Username, cursor: x, count: 1000))
1536                     .ConfigureAwait(false);
1537
1538             var subscribedLists = await TwitterLists.GetAllItemsAsync(x =>
1539                 this.Api.ListsSubscriptions(this.Username, cursor: x, count: 1000))
1540                     .ConfigureAwait(false);
1541
1542             TabInformations.GetInstance().SubscribableLists = Enumerable.Concat(ownedLists, subscribedLists)
1543                 .Select(x => new ListElement(x, this))
1544                 .ToList();
1545         }
1546
1547         public async Task DeleteList(long listId)
1548         {
1549             await this.Api.ListsDestroy(listId)
1550                 .IgnoreResponse()
1551                 .ConfigureAwait(false);
1552
1553             var tabinfo = TabInformations.GetInstance();
1554
1555             tabinfo.SubscribableLists = tabinfo.SubscribableLists
1556                 .Where(x => x.Id != listId)
1557                 .ToList();
1558         }
1559
1560         public async Task<ListElement> EditList(long listId, string new_name, bool isPrivate, string description)
1561         {
1562             var response = await this.Api.ListsUpdate(listId, new_name, description, isPrivate)
1563                 .ConfigureAwait(false);
1564
1565             var list = await response.LoadJsonAsync()
1566                 .ConfigureAwait(false);
1567
1568             return new ListElement(list, this);
1569         }
1570
1571         public async Task<long> GetListMembers(long listId, List<UserInfo> lists, long cursor)
1572         {
1573             this.CheckAccountState();
1574
1575             var users = await this.Api.ListsMembers(listId, cursor)
1576                 .ConfigureAwait(false);
1577
1578             Array.ForEach(users.Users, u => lists.Add(new UserInfo(u)));
1579
1580             return users.NextCursor;
1581         }
1582
1583         public async Task CreateListApi(string listName, bool isPrivate, string description)
1584         {
1585             this.CheckAccountState();
1586
1587             var response = await this.Api.ListsCreate(listName, description, isPrivate)
1588                 .ConfigureAwait(false);
1589
1590             var list = await response.LoadJsonAsync()
1591                 .ConfigureAwait(false);
1592
1593             TabInformations.GetInstance().SubscribableLists.Add(new ListElement(list, this));
1594         }
1595
1596         public async Task<bool> ContainsUserAtList(long listId, string user)
1597         {
1598             this.CheckAccountState();
1599
1600             try
1601             {
1602                 await this.Api.ListsMembersShow(listId, user)
1603                     .ConfigureAwait(false);
1604
1605                 return true;
1606             }
1607             catch (TwitterApiException ex)
1608                 when (ex.ErrorResponse.Errors.Any(x => x.Code == TwitterErrorCode.NotFound))
1609             {
1610                 return false;
1611             }
1612         }
1613
1614         private void ExtractEntities(TwitterEntities entities, List<(long UserId, string ScreenName)> AtList, List<MediaInfo> media)
1615         {
1616             if (entities != null)
1617             {
1618                 if (entities.Hashtags != null)
1619                 {
1620                     lock (this.LockObj)
1621                     {
1622                         this._hashList.AddRange(entities.Hashtags.Select(x => "#" + x.Text));
1623                     }
1624                 }
1625                 if (entities.UserMentions != null)
1626                 {
1627                     foreach (var ent in entities.UserMentions)
1628                     {
1629                         AtList.Add((ent.Id, ent.ScreenName));
1630                     }
1631                 }
1632                 if (entities.Media != null)
1633                 {
1634                     if (media != null)
1635                     {
1636                         foreach (var ent in entities.Media)
1637                         {
1638                             if (!media.Any(x => x.Url == ent.MediaUrlHttps))
1639                             {
1640                                 if (ent.VideoInfo != null &&
1641                                     ent.Type == "animated_gif" || ent.Type == "video")
1642                                 {
1643                                     //var videoUrl = ent.VideoInfo.Variants
1644                                     //    .Where(v => v.ContentType == "video/mp4")
1645                                     //    .OrderByDescending(v => v.Bitrate)
1646                                     //    .Select(v => v.Url).FirstOrDefault();
1647                                     media.Add(new MediaInfo(ent.MediaUrlHttps, ent.AltText, ent.ExpandedUrl));
1648                                 }
1649                                 else
1650                                     media.Add(new MediaInfo(ent.MediaUrlHttps, ent.AltText, videoUrl: null));
1651                             }
1652                         }
1653                     }
1654                 }
1655             }
1656         }
1657
1658         internal static string CreateHtmlAnchor(string text, TwitterEntities entities, TwitterQuotedStatusPermalink quotedStatusLink)
1659         {
1660             var mergedEntities = entities.Concat(TweetExtractor.ExtractEmojiEntities(text));
1661
1662             // PostClass.ExpandedUrlInfo を使用して非同期に URL 展開を行うためここでは expanded_url を使用しない
1663             text = TweetFormatter.AutoLinkHtml(text, mergedEntities, keepTco: true);
1664
1665             text = Regex.Replace(text, "(^|[^a-zA-Z0-9_/&##@@>=.~])(sm|nm)([0-9]{1,10})", "$1<a href=\"https://www.nicovideo.jp/watch/$2$3\">$2$3</a>");
1666             text = PreProcessUrl(text); //IDN置換
1667
1668             if (quotedStatusLink != null)
1669             {
1670                 text += string.Format(" <a href=\"{0}\" title=\"{0}\">{1}</a>",
1671                     WebUtility.HtmlEncode(quotedStatusLink.Url),
1672                     WebUtility.HtmlEncode(quotedStatusLink.Display));
1673             }
1674
1675             return text;
1676         }
1677
1678         private static readonly Uri SourceUriBase = new Uri("https://twitter.com/");
1679
1680         /// <summary>
1681         /// Twitter APIから得たHTML形式のsource文字列を分析し、source名とURLに分離します
1682         /// </summary>
1683         internal static (string SourceText, Uri SourceUri) ParseSource(string sourceHtml)
1684         {
1685             if (string.IsNullOrEmpty(sourceHtml))
1686                 return ("", null);
1687
1688             string sourceText;
1689             Uri sourceUri;
1690
1691             // sourceHtmlの例: <a href="http://twitter.com" rel="nofollow">Twitter Web Client</a>
1692
1693             var match = Regex.Match(sourceHtml, "^<a href=\"(?<uri>.+?)\".*?>(?<text>.+)</a>$", RegexOptions.IgnoreCase);
1694             if (match.Success)
1695             {
1696                 sourceText = WebUtility.HtmlDecode(match.Groups["text"].Value);
1697                 try
1698                 {
1699                     var uriStr = WebUtility.HtmlDecode(match.Groups["uri"].Value);
1700                     sourceUri = new Uri(SourceUriBase, uriStr);
1701                 }
1702                 catch (UriFormatException)
1703                 {
1704                     sourceUri = null;
1705                 }
1706             }
1707             else
1708             {
1709                 sourceText = WebUtility.HtmlDecode(sourceHtml);
1710                 sourceUri = null;
1711             }
1712
1713             return (sourceText, sourceUri);
1714         }
1715
1716         public async Task<TwitterApiStatus> GetInfoApi()
1717         {
1718             if (Twitter.AccountState != MyCommon.ACCOUNT_STATE.Valid) return null;
1719
1720             if (MyCommon._endingFlag) return null;
1721
1722             var limits = await this.Api.ApplicationRateLimitStatus()
1723                 .ConfigureAwait(false);
1724
1725             MyCommon.TwitterApiInfo.UpdateFromJson(limits);
1726
1727             return MyCommon.TwitterApiInfo;
1728         }
1729
1730         /// <summary>
1731         /// ブロック中のユーザーを更新します
1732         /// </summary>
1733         /// <exception cref="WebApiException"/>
1734         public async Task RefreshBlockIds()
1735         {
1736             if (MyCommon._endingFlag) return;
1737
1738             var cursor = -1L;
1739             var newBlockIds = Enumerable.Empty<long>();
1740             do
1741             {
1742                 var ret = await this.Api.BlocksIds(cursor)
1743                     .ConfigureAwait(false);
1744
1745                 newBlockIds = newBlockIds.Concat(ret.Ids);
1746                 cursor = ret.NextCursor;
1747             } while (cursor != 0);
1748
1749             var blockIdsSet = newBlockIds.ToHashSet();
1750             blockIdsSet.Remove(this.UserId); // 元のソースにあったので一応残しておく
1751
1752             TabInformations.GetInstance().BlockIds = blockIdsSet;
1753         }
1754
1755         /// <summary>
1756         /// ミュート中のユーザーIDを更新します
1757         /// </summary>
1758         /// <exception cref="WebApiException"/>
1759         public async Task RefreshMuteUserIdsAsync()
1760         {
1761             if (MyCommon._endingFlag) return;
1762
1763             var ids = await TwitterIds.GetAllItemsAsync(x => this.Api.MutesUsersIds(x))
1764                 .ConfigureAwait(false);
1765
1766             TabInformations.GetInstance().MuteUserIds = ids.ToHashSet();
1767         }
1768
1769         public string[] GetHashList()
1770         {
1771             string[] hashArray;
1772             lock (LockObj)
1773             {
1774                 hashArray = _hashList.ToArray();
1775                 _hashList.Clear();
1776             }
1777             return hashArray;
1778         }
1779
1780         public string AccessToken
1781             => ((TwitterApiConnection)this.Api.Connection).AccessToken;
1782
1783         public string AccessTokenSecret
1784             => ((TwitterApiConnection)this.Api.Connection).AccessSecret;
1785
1786         private void CheckAccountState()
1787         {
1788             if (Twitter.AccountState != MyCommon.ACCOUNT_STATE.Valid)
1789                 throw new WebApiException("Auth error. Check your account");
1790         }
1791
1792         private void CheckAccessLevel(TwitterApiAccessLevel accessLevelFlags)
1793         {
1794             if (!this.AccessLevel.HasFlag(accessLevelFlags))
1795                 throw new WebApiException("Auth Err:try to re-authorization.");
1796         }
1797
1798         public int GetTextLengthRemain(string postText)
1799         {
1800             var matchDm = Twitter.DMSendTextRegex.Match(postText);
1801             if (matchDm.Success)
1802                 return this.GetTextLengthRemainDM(matchDm.Groups["body"].Value);
1803
1804             return this.GetTextLengthRemainWeighted(postText);
1805         }
1806
1807         private int GetTextLengthRemainDM(string postText)
1808         {
1809             var textLength = 0;
1810
1811             var pos = 0;
1812             while (pos < postText.Length)
1813             {
1814                 textLength++;
1815
1816                 if (char.IsSurrogatePair(postText, pos))
1817                     pos += 2; // サロゲートペアの場合は2文字分進める
1818                 else
1819                     pos++;
1820             }
1821
1822             var urls = TweetExtractor.ExtractUrls(postText);
1823             foreach (var url in urls)
1824             {
1825                 var shortUrlLength = url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
1826                     ? this.Configuration.ShortUrlLengthHttps
1827                     : this.Configuration.ShortUrlLength;
1828
1829                 textLength += shortUrlLength - url.Length;
1830             }
1831
1832             return this.Configuration.DmTextCharacterLimit - textLength;
1833         }
1834
1835         private int GetTextLengthRemainWeighted(string postText)
1836         {
1837             var config = this.TextConfiguration;
1838             var totalWeight = 0;
1839
1840             int GetWeightFromCodepoint(int codepoint)
1841             {
1842                 foreach (var weightRange in config.Ranges)
1843                 {
1844                     if (codepoint >= weightRange.Start && codepoint <= weightRange.End)
1845                         return weightRange.Weight;
1846                 }
1847
1848                 return config.DefaultWeight;
1849             }
1850
1851             var urls = TweetExtractor.ExtractUrlEntities(postText).ToArray();
1852             var emojis = config.EmojiParsingEnabled
1853                 ? TweetExtractor.ExtractEmojiEntities(postText).ToArray()
1854                 : Array.Empty<TwitterEntityEmoji>();
1855
1856             var codepoints = postText.ToCodepoints().ToArray();
1857             var index = 0;
1858             while (index < codepoints.Length)
1859             {
1860                 var urlEntity = urls.FirstOrDefault(x => x.Indices[0] == index);
1861                 if (urlEntity != null)
1862                 {
1863                     totalWeight += config.TransformedURLLength * config.Scale;
1864                     index = urlEntity.Indices[1];
1865                     continue;
1866                 }
1867
1868                 var emojiEntity = emojis.FirstOrDefault(x => x.Indices[0] == index);
1869                 if (emojiEntity != null)
1870                 {
1871                     totalWeight += GetWeightFromCodepoint(codepoints[index]);
1872                     index = emojiEntity.Indices[1];
1873                     continue;
1874                 }
1875
1876                 var codepoint = codepoints[index];
1877                 totalWeight += GetWeightFromCodepoint(codepoint);
1878
1879                 index++;
1880             }
1881
1882             var remainWeight = config.MaxWeightedTweetLength * config.Scale - totalWeight;
1883
1884             return remainWeight / config.Scale;
1885         }
1886
1887
1888 #region "UserStream"
1889         public string TrackWord { get; set; } = "";
1890         public bool AllAtReply { get; set; } = false;
1891
1892         public event EventHandler NewPostFromStream;
1893         public event EventHandler UserStreamStarted;
1894         public event EventHandler UserStreamStopped;
1895         public event EventHandler<PostDeletedEventArgs> PostDeleted;
1896         public event EventHandler<UserStreamEventReceivedEventArgs> UserStreamEventReceived;
1897         private DateTimeUtc _lastUserstreamDataReceived;
1898         private StreamAutoConnector userStreamConnector;
1899
1900         public class FormattedEvent
1901         {
1902             public MyCommon.EVENTTYPE Eventtype { get; set; }
1903             public DateTimeUtc CreatedAt { get; set; }
1904             public string Event { get; set; }
1905             public string Username { get; set; }
1906             public string Target { get; set; }
1907             public Int64 Id { get; set; }
1908             public bool IsMe { get; set; }
1909         }
1910
1911         public List<FormattedEvent> StoredEvent { get; } = new List<FormattedEvent>();
1912
1913         private readonly IReadOnlyDictionary<string, MyCommon.EVENTTYPE> eventTable = new Dictionary<string, MyCommon.EVENTTYPE>
1914         {
1915             ["favorite"] = MyCommon.EVENTTYPE.Favorite,
1916             ["unfavorite"] = MyCommon.EVENTTYPE.Unfavorite,
1917             ["follow"] = MyCommon.EVENTTYPE.Follow,
1918             ["list_member_added"] = MyCommon.EVENTTYPE.ListMemberAdded,
1919             ["list_member_removed"] = MyCommon.EVENTTYPE.ListMemberRemoved,
1920             ["block"] = MyCommon.EVENTTYPE.Block,
1921             ["unblock"] = MyCommon.EVENTTYPE.Unblock,
1922             ["user_update"] = MyCommon.EVENTTYPE.UserUpdate,
1923             ["deleted"] = MyCommon.EVENTTYPE.Deleted,
1924             ["list_created"] = MyCommon.EVENTTYPE.ListCreated,
1925             ["list_destroyed"] = MyCommon.EVENTTYPE.ListDestroyed,
1926             ["list_updated"] = MyCommon.EVENTTYPE.ListUpdated,
1927             ["unfollow"] = MyCommon.EVENTTYPE.Unfollow,
1928             ["list_user_subscribed"] = MyCommon.EVENTTYPE.ListUserSubscribed,
1929             ["list_user_unsubscribed"] = MyCommon.EVENTTYPE.ListUserUnsubscribed,
1930             ["mute"] = MyCommon.EVENTTYPE.Mute,
1931             ["unmute"] = MyCommon.EVENTTYPE.Unmute,
1932             ["quoted_tweet"] = MyCommon.EVENTTYPE.QuotedTweet,
1933         };
1934
1935         public bool IsUserstreamDataReceived
1936             => (DateTimeUtc.Now - this._lastUserstreamDataReceived).TotalSeconds < 31;
1937
1938         private void userStream_MessageReceived(ITwitterStreamMessage message)
1939         {
1940             this._lastUserstreamDataReceived = DateTimeUtc.Now;
1941
1942             switch (message)
1943             {
1944                 case StreamMessageStatus statusMessage:
1945                     var status = statusMessage.Status.Normalize();
1946
1947                     if (status.RetweetedStatus is TwitterStatus retweetedStatus)
1948                     {
1949                         var sourceUserId = statusMessage.Status.User.Id;
1950                         var targetUserId = retweetedStatus.User.Id;
1951
1952                         // 自分に関係しないリツイートの場合は無視する
1953                         var selfUserId = this.UserId;
1954                         if (sourceUserId == selfUserId || targetUserId == selfUserId)
1955                         {
1956                             // 公式 RT をイベントとしても扱う
1957                             var evt = this.CreateEventFromRetweet(status);
1958                             this.StoredEvent.Insert(0, evt);
1959                             this.UserStreamEventReceived?.Invoke(this, new UserStreamEventReceivedEventArgs(evt));
1960                         }
1961                         // 従来通り公式 RT の表示も行うため break しない
1962                     }
1963
1964                     this.CreatePostsFromJson(new[] { status }, MyCommon.WORKERTYPE.UserStream, null, false);
1965                     this.NewPostFromStream?.Invoke(this, EventArgs.Empty);
1966                     break;
1967
1968                 case StreamMessageDirectMessage dmMessage:
1969                     this.CreateDirectMessagesFromJson(new[] { dmMessage.DirectMessage }, MyCommon.WORKERTYPE.UserStream, false);
1970                     this.NewPostFromStream?.Invoke(this, EventArgs.Empty);
1971                     break;
1972
1973                 case StreamMessageDelete deleteMessage:
1974                     var deletedId = deleteMessage.Status?.Id ?? deleteMessage.DirectMessage?.Id;
1975                     if (deletedId == null)
1976                         break;
1977
1978                     this.PostDeleted?.Invoke(this, new PostDeletedEventArgs(deletedId.Value));
1979
1980                     foreach (var index in MyCommon.CountDown(this.StoredEvent.Count - 1, 0))
1981                     {
1982                         var evt = this.StoredEvent[index];
1983                         if (evt.Id == deletedId.Value && (evt.Event == "favorite" || evt.Event == "unfavorite"))
1984                         {
1985                             this.StoredEvent.RemoveAt(index);
1986                         }
1987                     }
1988                     break;
1989
1990                 case StreamMessageEvent eventMessage:
1991                     this.CreateEventFromJson(eventMessage);
1992                     break;
1993
1994                 case StreamMessageScrubGeo scrubGeoMessage:
1995                     TabInformations.GetInstance().ScrubGeoReserve(scrubGeoMessage.UserId, scrubGeoMessage.UpToStatusId);
1996                     break;
1997
1998                 default:
1999                     break;
2000             }
2001         }
2002
2003         /// <summary>
2004         /// UserStreamsから受信した公式RTをイベントに変換します
2005         /// </summary>
2006         private FormattedEvent CreateEventFromRetweet(TwitterStatus status)
2007         {
2008             return new FormattedEvent
2009             {
2010                 Eventtype = MyCommon.EVENTTYPE.Retweet,
2011                 Event = "retweet",
2012                 CreatedAt = MyCommon.DateTimeParse(status.CreatedAt),
2013                 IsMe = status.User.Id == this.UserId,
2014                 Username = status.User.ScreenName,
2015                 Target = string.Format("@{0}:{1}", new[]
2016                 {
2017                     status.RetweetedStatus.User.ScreenName,
2018                     WebUtility.HtmlDecode(status.RetweetedStatus.FullText),
2019                 }),
2020                 Id = status.RetweetedStatus.Id,
2021             };
2022         }
2023
2024         private void CreateEventFromJson(StreamMessageEvent message)
2025         {
2026             var eventData = message.Event;
2027
2028             var evt = new FormattedEvent
2029             {
2030                 CreatedAt = MyCommon.DateTimeParse(eventData.CreatedAt),
2031                 Event = eventData.Event,
2032                 Username = eventData.Source.ScreenName,
2033                 IsMe = eventData.Source.Id == this.UserId,
2034                 Eventtype = eventTable.TryGetValue(eventData.Event, out var eventType) ? eventType : MyCommon.EVENTTYPE.None,
2035             };
2036
2037             TwitterStreamEvent<TwitterStatusCompat> tweetEvent;
2038             TwitterStatus tweet;
2039
2040             switch (eventData.Event)
2041             {
2042                 case "access_revoked":
2043                 case "access_unrevoked":
2044                 case "user_delete":
2045                 case "user_suspend":
2046                     return;
2047                 case "follow":
2048                     if (eventData.Target.Id == this.UserId)
2049                     {
2050                         if (!this.followerId.Contains(eventData.Source.Id)) this.followerId.Add(eventData.Source.Id);
2051                     }
2052                     else
2053                     {
2054                         return;    //Block後のUndoをすると、SourceとTargetが逆転したfollowイベントが帰ってくるため。
2055                     }
2056                     evt.Target = "";
2057                     break;
2058                 case "unfollow":
2059                     evt.Target = "@" + eventData.Target.ScreenName;
2060                     break;
2061                 case "favorited_retweet":
2062                 case "retweeted_retweet":
2063                     return;
2064                 case "favorite":
2065                 case "unfavorite":
2066                     tweetEvent = message.ParseTargetObjectAs<TwitterStatusCompat>();
2067                     tweet = tweetEvent.TargetObject.Normalize();
2068                     evt.Target = "@" + tweet.User.ScreenName + ":" + WebUtility.HtmlDecode(tweet.FullText);
2069                     evt.Id = tweet.Id;
2070
2071                     if (SettingManager.Common.IsRemoveSameEvent)
2072                     {
2073                         if (this.StoredEvent.Any(ev => ev.Username == evt.Username && ev.Eventtype == evt.Eventtype && ev.Target == evt.Target))
2074                             return;
2075                     }
2076
2077                     var tabinfo = TabInformations.GetInstance();
2078
2079                     var statusId = tweet.Id;
2080                     if (!tabinfo.Posts.TryGetValue(statusId, out var post))
2081                         break;
2082
2083                     if (eventData.Event == "favorite")
2084                     {
2085                         var favTab = tabinfo.GetTabByType(MyCommon.TabUsageType.Favorites);
2086                         favTab.AddPostQueue(post);
2087
2088                         if (tweetEvent.Source.Id == this.UserId)
2089                         {
2090                             post.IsFav = true;
2091                         }
2092                         else if (tweetEvent.Target.Id == this.UserId)
2093                         {
2094                             post.FavoritedCount++;
2095
2096                             if (SettingManager.Common.FavEventUnread)
2097                                 tabinfo.SetReadAllTab(post.StatusId, read: false);
2098                         }
2099                     }
2100                     else // unfavorite
2101                     {
2102                         if (tweetEvent.Source.Id == this.UserId)
2103                         {
2104                             post.IsFav = false;
2105                         }
2106                         else if (tweetEvent.Target.Id == this.UserId)
2107                         {
2108                             post.FavoritedCount = Math.Max(0, post.FavoritedCount - 1);
2109                         }
2110                     }
2111                     break;
2112                 case "quoted_tweet":
2113                     if (evt.IsMe) return;
2114
2115                     tweetEvent = message.ParseTargetObjectAs<TwitterStatusCompat>();
2116                     tweet = tweetEvent.TargetObject.Normalize();
2117                     evt.Target = "@" + tweet.User.ScreenName + ":" + WebUtility.HtmlDecode(tweet.FullText);
2118                     evt.Id = tweet.Id;
2119
2120                     if (SettingManager.Common.IsRemoveSameEvent)
2121                     {
2122                         if (this.StoredEvent.Any(ev => ev.Username == evt.Username && ev.Eventtype == evt.Eventtype && ev.Target == evt.Target))
2123                             return;
2124                     }
2125                     break;
2126                 case "list_member_added":
2127                 case "list_member_removed":
2128                 case "list_created":
2129                 case "list_destroyed":
2130                 case "list_updated":
2131                 case "list_user_subscribed":
2132                 case "list_user_unsubscribed":
2133                     var listEvent = message.ParseTargetObjectAs<TwitterList>();
2134                     evt.Target = listEvent.TargetObject.FullName;
2135                     break;
2136                 case "block":
2137                     if (!TabInformations.GetInstance().BlockIds.Contains(eventData.Target.Id)) TabInformations.GetInstance().BlockIds.Add(eventData.Target.Id);
2138                     evt.Target = "";
2139                     break;
2140                 case "unblock":
2141                     if (TabInformations.GetInstance().BlockIds.Contains(eventData.Target.Id)) TabInformations.GetInstance().BlockIds.Remove(eventData.Target.Id);
2142                     evt.Target = "";
2143                     break;
2144                 case "user_update":
2145                     evt.Target = "";
2146                     break;
2147                 
2148                 // Mute / Unmute
2149                 case "mute":
2150                     evt.Target = "@" + eventData.Target.ScreenName;
2151                     if (!TabInformations.GetInstance().MuteUserIds.Contains(eventData.Target.Id))
2152                     {
2153                         TabInformations.GetInstance().MuteUserIds.Add(eventData.Target.Id);
2154                     }
2155                     break;
2156                 case "unmute":
2157                     evt.Target = "@" + eventData.Target.ScreenName;
2158                     if (TabInformations.GetInstance().MuteUserIds.Contains(eventData.Target.Id))
2159                     {
2160                         TabInformations.GetInstance().MuteUserIds.Remove(eventData.Target.Id);
2161                     }
2162                     break;
2163
2164                 default:
2165                     MyCommon.TraceOut("Unknown Event:" + evt.Event + Environment.NewLine + message.Json);
2166                     break;
2167             }
2168             this.StoredEvent.Insert(0, evt);
2169
2170             this.UserStreamEventReceived?.Invoke(this, new UserStreamEventReceivedEventArgs(evt));
2171         }
2172
2173         private void userStream_Started()
2174             => this.UserStreamStarted?.Invoke(this, EventArgs.Empty);
2175
2176         private void userStream_Stopped()
2177             => this.UserStreamStopped?.Invoke(this, EventArgs.Empty);
2178
2179         public bool UserStreamActive
2180             => this.userStreamConnector != null && this.userStreamConnector.IsStreamActive;
2181
2182         public void StartUserStream()
2183         {
2184             var replies = this.AllAtReply ? "all" : null;
2185             var streamObservable = this.Api.UserStreams(replies, this.TrackWord);
2186             var newConnector = new StreamAutoConnector(streamObservable);
2187
2188             newConnector.MessageReceived += userStream_MessageReceived;
2189             newConnector.Started += userStream_Started;
2190             newConnector.Stopped += userStream_Stopped;
2191
2192             newConnector.Start();
2193
2194             var oldConnector = Interlocked.Exchange(ref this.userStreamConnector, newConnector);
2195             oldConnector?.Dispose();
2196         }
2197
2198         public void StopUserStream()
2199         {
2200             var oldConnector = Interlocked.Exchange(ref this.userStreamConnector, null);
2201             oldConnector?.Dispose();
2202         }
2203
2204         public void ReconnectUserStream()
2205         {
2206             if (this.userStreamConnector != null)
2207             {
2208                 this.StartUserStream();
2209             }
2210         }
2211
2212         private class StreamAutoConnector : IDisposable
2213         {
2214             private readonly TwitterStreamObservable streamObservable;
2215
2216             public bool IsStreamActive { get; private set; }
2217             public bool IsDisposed { get; private set; }
2218
2219             public event Action<ITwitterStreamMessage> MessageReceived;
2220             public event Action Stopped;
2221             public event Action Started;
2222
2223             private Task streamTask;
2224             private CancellationTokenSource streamCts = new CancellationTokenSource();
2225
2226             public StreamAutoConnector(TwitterStreamObservable streamObservable)
2227                 => this.streamObservable = streamObservable;
2228
2229             public void Start()
2230             {
2231                 var cts = new CancellationTokenSource();
2232
2233                 this.streamCts = cts;
2234                 this.streamTask = Task.Run(async () =>
2235                 {
2236                     try
2237                     {
2238                         await this.StreamLoop(cts.Token)
2239                             .ConfigureAwait(false);
2240                     }
2241                     catch (OperationCanceledException) { }
2242                 });
2243             }
2244
2245             public void Stop()
2246             {
2247                 this.streamCts?.Cancel();
2248
2249                 // streamTask の完了を待たずに IsStreamActive を false にセットする
2250                 this.IsStreamActive = false;
2251                 this.Stopped?.Invoke();
2252             }
2253
2254             private async Task StreamLoop(CancellationToken cancellationToken)
2255             {
2256                 TimeSpan sleep = TimeSpan.Zero;
2257                 for (; ; )
2258                 {
2259                     if (sleep != TimeSpan.Zero)
2260                     {
2261                         await Task.Delay(sleep, cancellationToken)
2262                             .ConfigureAwait(false);
2263                         sleep = TimeSpan.Zero;
2264                     }
2265
2266                     if (!MyCommon.IsNetworkAvailable())
2267                     {
2268                         sleep = TimeSpan.FromSeconds(30);
2269                         continue;
2270                     }
2271
2272                     this.IsStreamActive = true;
2273                     this.Started?.Invoke();
2274
2275                     try
2276                     {
2277                         await this.streamObservable.ForEachAsync(
2278                             x => this.MessageReceived?.Invoke(x),
2279                             cancellationToken);
2280
2281                         // キャンセルされていないのにストリームが終了した場合
2282                         sleep = TimeSpan.FromSeconds(30);
2283                     }
2284                     catch (TwitterApiException ex) when (ex.StatusCode == HttpStatusCode.Gone)
2285                     {
2286                         // UserStreams停止によるエラーの場合は長めに間隔を開ける
2287                         sleep = TimeSpan.FromMinutes(10);
2288                     }
2289                     catch (TwitterApiException) { sleep = TimeSpan.FromSeconds(30); }
2290                     catch (IOException) { sleep = TimeSpan.FromSeconds(30); }
2291                     catch (OperationCanceledException)
2292                     {
2293                         if (cancellationToken.IsCancellationRequested)
2294                             throw;
2295
2296                         // cancellationToken によるキャンセルではない(=タイムアウトエラー)
2297                         sleep = TimeSpan.FromSeconds(30);
2298                     }
2299                     catch (Exception ex)
2300                     {
2301                         MyCommon.ExceptionOut(ex);
2302                         sleep = TimeSpan.FromSeconds(30);
2303                     }
2304                     finally
2305                     {
2306                         this.IsStreamActive = false;
2307                         this.Stopped?.Invoke();
2308                     }
2309                 }
2310             }
2311
2312             public void Dispose()
2313             {
2314                 if (this.IsDisposed)
2315                     return;
2316
2317                 this.IsDisposed = true;
2318
2319                 this.Stop();
2320
2321                 this.Started = null;
2322                 this.Stopped = null;
2323                 this.MessageReceived = null;
2324             }
2325         }
2326 #endregion
2327
2328 #region "IDisposable Support"
2329         private bool disposedValue; // 重複する呼び出しを検出するには
2330
2331         // IDisposable
2332         protected virtual void Dispose(bool disposing)
2333         {
2334             if (!this.disposedValue)
2335             {
2336                 if (disposing)
2337                 {
2338                     this.StopUserStream();
2339                 }
2340             }
2341             this.disposedValue = true;
2342         }
2343
2344         //protected Overrides void Finalize()
2345         //{
2346         //    // このコードを変更しないでください。クリーンアップ コードを上の Dispose(bool disposing) に記述します。
2347         //    Dispose(false)
2348         //    MyBase.Finalize()
2349         //}
2350
2351         // このコードは、破棄可能なパターンを正しく実装できるように Visual Basic によって追加されました。
2352         public void Dispose()
2353         {
2354             // このコードを変更しないでください。クリーンアップ コードを上の Dispose(bool disposing) に記述します。
2355             Dispose(true);
2356             GC.SuppressFinalize(this);
2357         }
2358 #endregion
2359     }
2360
2361     public class PostDeletedEventArgs : EventArgs
2362     {
2363         public long StatusId { get; }
2364
2365         public PostDeletedEventArgs(long statusId)
2366             => this.StatusId = statusId;
2367     }
2368
2369     public class UserStreamEventReceivedEventArgs : EventArgs
2370     {
2371         public Twitter.FormattedEvent EventData { get; }
2372
2373         public UserStreamEventReceivedEventArgs(Twitter.FormattedEvent eventData)
2374             => this.EventData = eventData;
2375     }
2376 }