OSDN Git Service

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