OSDN Git Service

パラメータの行を揃える (SA1117)
[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 #nullable enable
29
30 using System.Diagnostics;
31 using System.IO;
32 using System.Linq;
33 using System.Net;
34 using System.Net.Http;
35 using System.Runtime.CompilerServices;
36 using System.Text;
37 using System.Text.RegularExpressions;
38 using System.Threading;
39 using System.Threading.Tasks;
40 using System;
41 using System.Reflection;
42 using System.Collections.Generic;
43 using System.Windows.Forms;
44 using OpenTween.Api;
45 using OpenTween.Api.DataModel;
46 using OpenTween.Connection;
47 using OpenTween.Models;
48 using OpenTween.Setting;
49 using System.Globalization;
50
51 namespace OpenTween
52 {
53     public class Twitter : IDisposable
54     {
55         #region Regexp from twitter-text-js
56
57         // The code in this region code block incorporates works covered by
58         // the following copyright and permission notices:
59         //
60         //   Copyright 2011 Twitter, Inc.
61         //
62         //   Licensed under the Apache License, Version 2.0 (the "License"); you
63         //   may not use this work except in compliance with the License. You
64         //   may obtain a copy of the License in the LICENSE file, or at:
65         //
66         //   http://www.apache.org/licenses/LICENSE-2.0
67         //
68         //   Unless required by applicable law or agreed to in writing, software
69         //   distributed under the License is distributed on an "AS IS" BASIS,
70         //   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
71         //   implied. See the License for the specific language governing
72         //   permissions and limitations under the License.
73
74         // Hashtag用正規表現
75         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";
76         private const string NON_LATIN_HASHTAG_CHARS = @"\u0400-\u04ff\u0500-\u0527\u1100-\u11ff\u3130-\u3185\uA960-\uA97F\uAC00-\uD7AF\uD7B0-\uD7FF";
77         private const string CJ_HASHTAG_CHARACTERS = @"\u30A1-\u30FA\u30FC\u3005\uFF66-\uFF9F\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\u3041-\u309A\u3400-\u4DBF\p{IsCJKUnifiedIdeographs}";
78         private const string HASHTAG_BOUNDARY = @"^|$|\s|「|」|。|\.|!";
79         private const string HASHTAG_ALPHA = "[A-Za-z_" + LATIN_ACCENTS + NON_LATIN_HASHTAG_CHARS + CJ_HASHTAG_CHARACTERS + "]";
80         private const string HASHTAG_ALPHANUMERIC = "[A-Za-z0-9_" + LATIN_ACCENTS + NON_LATIN_HASHTAG_CHARS + CJ_HASHTAG_CHARACTERS + "]";
81         private const string HASHTAG_TERMINATOR = "[^A-Za-z0-9_" + LATIN_ACCENTS + NON_LATIN_HASHTAG_CHARS + CJ_HASHTAG_CHARACTERS + "]";
82         public const string HASHTAG = "(" + HASHTAG_BOUNDARY + ")(#|#)(" + HASHTAG_ALPHANUMERIC + "*" + HASHTAG_ALPHA + HASHTAG_ALPHANUMERIC + "*)(?=" + HASHTAG_TERMINATOR + "|" + HASHTAG_BOUNDARY + ")";
83         // URL正規表現
84         private const string url_valid_preceding_chars = @"(?:[^A-Za-z0-9@@$##\ufffe\ufeff\uffff\u202a-\u202e]|^)";
85         public const string url_invalid_without_protocol_preceding_chars = @"[-_./]$";
86         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";
87         private const string url_valid_domain_chars = @"[^" + url_invalid_domain_chars + "]";
88         private const string url_valid_subdomain = @"(?:(?:" + url_valid_domain_chars + @"(?:[_-]|" + url_valid_domain_chars + @")*)?" + url_valid_domain_chars + @"\.)";
89         private const string url_valid_domain_name = @"(?:(?:" + url_valid_domain_chars + @"(?:-|" + url_valid_domain_chars + @")*)?" + url_valid_domain_chars + @"\.)";
90         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]|$))";
91         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]|$))";
92         private const string url_valid_punycode = @"(?:xn--[0-9a-z]+)";
93         private const string url_valid_domain = @"(?<domain>" + url_valid_subdomain + "*" + url_valid_domain_name + "(?:" + url_valid_GTLD + "|" + url_valid_CCTLD + ")|" + url_valid_punycode + ")";
94         public const string url_valid_ascii_domain = @"(?:(?:[a-z0-9" + LATIN_ACCENTS + @"]+)\.)+(?:" + url_valid_GTLD + "|" + url_valid_CCTLD + "|" + url_valid_punycode + ")";
95         public const string url_invalid_short_domain = "^" + url_valid_domain_name + url_valid_CCTLD + "$";
96         private const string url_valid_port_number = @"[0-9]+";
97
98         private const string url_valid_general_path_chars = @"[a-z0-9!*';:=+,.$/%#\[\]\-_~|&" + LATIN_ACCENTS + "]";
99         private const string url_balance_parens = @"(?:\(" + url_valid_general_path_chars + @"+\))";
100         private const string url_valid_path_ending_chars = @"(?:[+\-a-z0-9=_#/" + LATIN_ACCENTS + "]|" + url_balance_parens + ")";
101         private const string pth = "(?:" +
102             "(?:" +
103                 url_valid_general_path_chars + "*" +
104                 "(?:" + url_balance_parens + url_valid_general_path_chars + "*)*" +
105                 url_valid_path_ending_chars +
106                 ")|(?:@" + url_valid_general_path_chars + "+/)" +
107             ")";
108         private const string qry = @"(?<query>\?[a-z0-9!?*'();:&=+$/%#\[\]\-_.,~|]*[a-z0-9_&=#/])?";
109         public const string rgUrl = @"(?<before>" + url_valid_preceding_chars + ")" +
110                                     "(?<url>(?<protocol>https?://)?" +
111                                     "(?<domain>" + url_valid_domain + ")" +
112                                     "(?::" + url_valid_port_number + ")?" +
113                                     "(?<path>/" + pth + "*)?" +
114                                     qry +
115                                     ")";
116
117         #endregion
118
119         /// <summary>
120         /// Twitter API のステータスページのURL
121         /// </summary>
122         public const string ServiceAvailabilityStatusUrl = "https://status.io.watchmouse.com/7617";
123
124         /// <summary>
125         /// ツイートへのパーマリンクURLを判定する正規表現
126         /// </summary>
127         public static readonly Regex StatusUrlRegex = new Regex(@"https?://([^.]+\.)?twitter\.com/(#!/)?(?<ScreenName>[a-zA-Z0-9_]+)/status(es)?/(?<StatusId>[0-9]+)(/photo)?", RegexOptions.IgnoreCase);
128
129         /// <summary>
130         /// attachment_url に指定可能な URL を判定する正規表現
131         /// </summary>
132         public static readonly Regex AttachmentUrlRegex = new Regex(
133             @"https?://(
134    twitter\.com/[0-9A-Za-z_]+/status/[0-9]+
135  | mobile\.twitter\.com/[0-9A-Za-z_]+/status/[0-9]+
136  | twitter\.com/messages/compose\?recipient_id=[0-9]+(&.+)?
137 )$",
138             RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace);
139
140         /// <summary>
141         /// FavstarやaclogなどTwitter関連サービスのパーマリンクURLからステータスIDを抽出する正規表現
142         /// </summary>
143         public static readonly Regex ThirdPartyStatusUrlRegex = new Regex(
144             @"https?://(?:[^.]+\.)?(?:
145   favstar\.fm/users/[a-zA-Z0-9_]+/status/       # Favstar
146 | favstar\.fm/t/                                # Favstar (short)
147 | aclog\.koba789\.com/i/                        # aclog
148 | frtrt\.net/solo_status\.php\?status=          # RtRT
149 )(?<StatusId>[0-9]+)",
150             RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace);
151
152         /// <summary>
153         /// DM送信かどうかを判定する正規表現
154         /// </summary>
155         public static readonly Regex DMSendTextRegex = new Regex(@"^DM? +(?<id>[a-zA-Z0-9_]+) +(?<body>.*)", RegexOptions.IgnoreCase | RegexOptions.Singleline);
156
157         public TwitterApi Api { get; }
158         public TwitterConfiguration Configuration { get; private set; }
159         public TwitterTextConfiguration TextConfiguration { get; private set; }
160
161         public bool GetFollowersSuccess { get; private set; } = false;
162         public bool GetNoRetweetSuccess { get; private set; } = false;
163
164         delegate void GetIconImageDelegate(PostClass post);
165         private readonly object LockObj = new object();
166         private ISet<long> followerId = new HashSet<long>();
167         private long[] noRTId = Array.Empty<long>();
168
169         // プロパティからアクセスされる共通情報
170         private readonly List<string> _hashList = new List<string>();
171
172         private string? nextCursorDirectMessage = null;
173
174         private long previousStatusId = -1L;
175
176         public Twitter(TwitterApi api)
177         {
178             this.Api = api;
179             this.Configuration = TwitterConfiguration.DefaultConfiguration();
180             this.TextConfiguration = TwitterTextConfiguration.DefaultConfiguration();
181         }
182
183         public TwitterApiAccessLevel AccessLevel
184             => MyCommon.TwitterApiInfo.AccessLevel;
185
186         protected void ResetApiStatus()
187             => MyCommon.TwitterApiInfo.Reset();
188
189         public void ClearAuthInfo()
190         {
191             Twitter.AccountState = MyCommon.ACCOUNT_STATE.Invalid;
192             this.ResetApiStatus();
193         }
194
195         public void VerifyCredentials()
196         {
197             try
198             {
199                 this.VerifyCredentialsAsync().Wait();
200             }
201             catch (AggregateException ex) when (ex.InnerException is WebApiException)
202             {
203                 throw new WebApiException(ex.InnerException.Message, ex);
204             }
205         }
206
207         public async Task VerifyCredentialsAsync()
208         {
209             var user = await this.Api.AccountVerifyCredentials()
210                 .ConfigureAwait(false);
211
212             this.UpdateUserStats(user);
213         }
214
215         public void Initialize(string token, string tokenSecret, string username, long userId)
216         {
217             // OAuth認証
218             if (MyCommon.IsNullOrEmpty(token) || MyCommon.IsNullOrEmpty(tokenSecret) || MyCommon.IsNullOrEmpty(username))
219             {
220                 Twitter.AccountState = MyCommon.ACCOUNT_STATE.Invalid;
221             }
222             this.ResetApiStatus();
223             this.Api.Initialize(token, tokenSecret, userId, username);
224         }
225
226         internal static string PreProcessUrl(string orgData)
227         {
228             int posl1;
229             var posl2 = 0;
230             var href = "<a href=\"";
231
232             while (true)
233             {
234                 if (orgData.IndexOf(href, posl2, StringComparison.Ordinal) > -1)
235                 {
236                     // IDN展開
237                     posl1 = orgData.IndexOf(href, posl2, StringComparison.Ordinal);
238                     posl1 += href.Length;
239                     posl2 = orgData.IndexOf("\"", posl1, StringComparison.Ordinal);
240                     var urlStr = orgData.Substring(posl1, posl2 - posl1);
241
242                     if (!urlStr.StartsWith("http://", StringComparison.Ordinal)
243                         && !urlStr.StartsWith("https://", StringComparison.Ordinal)
244                         && !urlStr.StartsWith("ftp://", StringComparison.Ordinal))
245                     {
246                         continue;
247                     }
248
249                     var replacedUrl = MyCommon.IDNEncode(urlStr);
250                     if (replacedUrl == null) continue;
251                     if (replacedUrl == urlStr) continue;
252
253                     orgData = orgData.Replace("<a href=\"" + urlStr, "<a href=\"" + replacedUrl);
254                     posl2 = 0;
255                 }
256                 else
257                 {
258                     break;
259                 }
260             }
261             return orgData;
262         }
263
264         public async Task<PostClass?> PostStatus(PostStatusParams param)
265         {
266             this.CheckAccountState();
267
268             if (Twitter.DMSendTextRegex.IsMatch(param.Text))
269             {
270                 var mediaId = param.MediaIds != null && param.MediaIds.Any() ? param.MediaIds[0] : (long?)null;
271
272                 await this.SendDirectMessage(param.Text, mediaId)
273                     .ConfigureAwait(false);
274                 return null;
275             }
276
277             var response = await this.Api.StatusesUpdate(
278                     param.Text,
279                     param.InReplyToStatusId,
280                     param.MediaIds,
281                     param.AutoPopulateReplyMetadata,
282                     param.ExcludeReplyUserIds,
283                     param.AttachmentUrl
284                 )
285                 .ConfigureAwait(false);
286
287             var status = await response.LoadJsonAsync()
288                 .ConfigureAwait(false);
289
290             this.UpdateUserStats(status.User);
291
292             if (status.Id == this.previousStatusId)
293                 throw new WebApiException("OK:Delaying?");
294
295             this.previousStatusId = status.Id;
296
297             // 投稿したものを返す
298             var post = this.CreatePostsFromStatusData(status);
299             if (this.ReadOwnPost) post.IsRead = true;
300             return post;
301         }
302
303         public async Task<long> UploadMedia(IMediaItem item, string? mediaCategory = null)
304         {
305             this.CheckAccountState();
306
307             var mediaType = item.Extension switch
308             {
309                 ".png" => "image/png",
310                 ".jpg" => "image/jpeg",
311                 ".jpeg" => "image/jpeg",
312                 ".gif" => "image/gif",
313                 _ => "application/octet-stream",
314             };
315
316             var initResponse = await this.Api.MediaUploadInit(item.Size, mediaType, mediaCategory)
317                 .ConfigureAwait(false);
318
319             var initMedia = await initResponse.LoadJsonAsync()
320                 .ConfigureAwait(false);
321
322             var mediaId = initMedia.MediaId;
323
324             await this.Api.MediaUploadAppend(mediaId, 0, item)
325                 .ConfigureAwait(false);
326
327             var response = await this.Api.MediaUploadFinalize(mediaId)
328                 .ConfigureAwait(false);
329
330             var media = await response.LoadJsonAsync()
331                 .ConfigureAwait(false);
332
333             while (media.ProcessingInfo is TwitterUploadMediaResult.MediaProcessingInfo processingInfo)
334             {
335                 switch (processingInfo.State)
336                 {
337                     case "pending":
338                         break;
339                     case "in_progress":
340                         break;
341                     case "succeeded":
342                         goto succeeded;
343                     case "failed":
344                         throw new WebApiException($"Err:Upload failed ({processingInfo.Error?.Name})");
345                     default:
346                         throw new WebApiException($"Err:Invalid state ({processingInfo.State})");
347                 }
348
349                 await Task.Delay(TimeSpan.FromSeconds(processingInfo.CheckAfterSecs ?? 5))
350                     .ConfigureAwait(false);
351
352                 media = await this.Api.MediaUploadStatus(mediaId)
353                     .ConfigureAwait(false);
354             }
355
356             succeeded:
357             return media.MediaId;
358         }
359
360         public async Task SendDirectMessage(string postStr, long? mediaId = null)
361         {
362             this.CheckAccountState();
363             this.CheckAccessLevel(TwitterApiAccessLevel.ReadWriteAndDirectMessage);
364
365             var mc = Twitter.DMSendTextRegex.Match(postStr);
366
367             var body = mc.Groups["body"].Value;
368             var recipientName = mc.Groups["id"].Value;
369
370             var recipient = await this.Api.UsersShow(recipientName)
371                 .ConfigureAwait(false);
372
373             var response = await this.Api.DirectMessagesEventsNew(recipient.Id, body, mediaId)
374                 .ConfigureAwait(false);
375
376             var messageEventSingle = await response.LoadJsonAsync()
377                 .ConfigureAwait(false);
378
379             await this.CreateDirectMessagesEventFromJson(messageEventSingle, read: true)
380                 .ConfigureAwait(false);
381         }
382
383         public async Task<PostClass?> PostRetweet(long id, bool read)
384         {
385             this.CheckAccountState();
386
387             // データ部分の生成
388             var post = TabInformations.GetInstance()[id];
389             if (post == null)
390                 throw new WebApiException("Err:Target isn't found.");
391
392             var target = post.RetweetedId ?? id;  // 再RTの場合は元発言をRT
393
394             var response = await this.Api.StatusesRetweet(target)
395                 .ConfigureAwait(false);
396
397             var status = await response.LoadJsonAsync()
398                 .ConfigureAwait(false);
399
400             // 二重取得回避
401             lock (this.LockObj)
402             {
403                 if (TabInformations.GetInstance().ContainsKey(status.Id))
404                     return null;
405             }
406
407             // Retweet判定
408             if (status.RetweetedStatus == null)
409                 throw new WebApiException("Invalid Json!");
410
411             // Retweetしたものを返す
412             post = this.CreatePostsFromStatusData(status);
413
414             // ユーザー情報
415             post.IsMe = true;
416
417             post.IsRead = read;
418             post.IsOwl = false;
419             if (this.ReadOwnPost) post.IsRead = true;
420             post.IsDm = false;
421
422             return post;
423         }
424
425         public string Username
426             => this.Api.CurrentScreenName;
427
428         public long UserId
429             => this.Api.CurrentUserId;
430
431         public static MyCommon.ACCOUNT_STATE AccountState { get; set; } = MyCommon.ACCOUNT_STATE.Valid;
432         public bool RestrictFavCheck { get; set; }
433         public bool ReadOwnPost { get; set; }
434
435         public int FollowersCount { get; private set; }
436         public int FriendsCount { get; private set; }
437         public int StatusesCount { get; private set; }
438         public string Location { get; private set; } = "";
439         public string Bio { get; private set; } = "";
440
441         /// <summary>ユーザーのフォロワー数などの情報を更新します</summary>
442         private void UpdateUserStats(TwitterUser self)
443         {
444             this.FollowersCount = self.FollowersCount;
445             this.FriendsCount = self.FriendsCount;
446             this.StatusesCount = self.StatusesCount;
447             this.Location = self.Location ?? "";
448             this.Bio = self.Description ?? "";
449         }
450
451         /// <summary>
452         /// 渡された取得件数がWORKERTYPEに応じた取得可能範囲に収まっているか検証する
453         /// </summary>
454         public static bool VerifyApiResultCount(MyCommon.WORKERTYPE type, int count)
455             => count >= 20 && count <= GetMaxApiResultCount(type);
456
457         /// <summary>
458         /// 渡された取得件数が更新時の取得可能範囲に収まっているか検証する
459         /// </summary>
460         public static bool VerifyMoreApiResultCount(int count)
461             => count >= 20 && count <= 200;
462
463         /// <summary>
464         /// 渡された取得件数が起動時の取得可能範囲に収まっているか検証する
465         /// </summary>
466         public static bool VerifyFirstApiResultCount(int count)
467             => count >= 20 && count <= 200;
468
469         /// <summary>
470         /// WORKERTYPEに応じた取得可能な最大件数を取得する
471         /// </summary>
472         public static int GetMaxApiResultCount(MyCommon.WORKERTYPE type)
473         {
474             // 参照: REST APIs - 各endpointのcountパラメータ
475             // https://dev.twitter.com/rest/public
476             return type switch
477             {
478                 MyCommon.WORKERTYPE.Timeline => 200,
479                 MyCommon.WORKERTYPE.Reply => 200,
480                 MyCommon.WORKERTYPE.UserTimeline => 200,
481                 MyCommon.WORKERTYPE.Favorites => 200,
482                 MyCommon.WORKERTYPE.List => 200, // 不明
483                 MyCommon.WORKERTYPE.PublicSearch => 100,
484                 _ => throw new InvalidOperationException("Invalid type: " + type),
485             };
486         }
487
488         /// <summary>
489         /// WORKERTYPEに応じた取得件数を取得する
490         /// </summary>
491         public static int GetApiResultCount(MyCommon.WORKERTYPE type, bool more, bool startup)
492         {
493             if (SettingManager.Common.UseAdditionalCount)
494             {
495                 switch (type)
496                 {
497                     case MyCommon.WORKERTYPE.Favorites:
498                         if (SettingManager.Common.FavoritesCountApi != 0)
499                             return SettingManager.Common.FavoritesCountApi;
500                         break;
501                     case MyCommon.WORKERTYPE.List:
502                         if (SettingManager.Common.ListCountApi != 0)
503                             return SettingManager.Common.ListCountApi;
504                         break;
505                     case MyCommon.WORKERTYPE.PublicSearch:
506                         if (SettingManager.Common.SearchCountApi != 0)
507                             return SettingManager.Common.SearchCountApi;
508                         break;
509                     case MyCommon.WORKERTYPE.UserTimeline:
510                         if (SettingManager.Common.UserTimelineCountApi != 0)
511                             return SettingManager.Common.UserTimelineCountApi;
512                         break;
513                 }
514                 if (more && SettingManager.Common.MoreCountApi != 0)
515                 {
516                     return Math.Min(SettingManager.Common.MoreCountApi, GetMaxApiResultCount(type));
517                 }
518                 if (startup && SettingManager.Common.FirstCountApi != 0 && type != MyCommon.WORKERTYPE.Reply)
519                 {
520                     return Math.Min(SettingManager.Common.FirstCountApi, GetMaxApiResultCount(type));
521                 }
522             }
523
524             // 上記に当てはまらない場合の共通処理
525             var count = SettingManager.Common.CountApi;
526
527             if (type == MyCommon.WORKERTYPE.Reply)
528                 count = SettingManager.Common.CountApiReply;
529
530             return Math.Min(count, GetMaxApiResultCount(type));
531         }
532
533         public async Task GetHomeTimelineApi(bool read, HomeTabModel tab, bool more, bool startup)
534         {
535             this.CheckAccountState();
536
537             var count = GetApiResultCount(MyCommon.WORKERTYPE.Timeline, more, startup);
538
539             TwitterStatus[] statuses;
540             if (more)
541             {
542                 statuses = await this.Api.StatusesHomeTimeline(count, maxId: tab.OldestId)
543                     .ConfigureAwait(false);
544             }
545             else
546             {
547                 statuses = await this.Api.StatusesHomeTimeline(count)
548                     .ConfigureAwait(false);
549             }
550
551             var minimumId = this.CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.Timeline, tab, read);
552             if (minimumId != null)
553                 tab.OldestId = minimumId.Value;
554         }
555
556         public async Task GetMentionsTimelineApi(bool read, MentionsTabModel tab, bool more, bool startup)
557         {
558             this.CheckAccountState();
559
560             var count = GetApiResultCount(MyCommon.WORKERTYPE.Reply, more, startup);
561
562             TwitterStatus[] statuses;
563             if (more)
564             {
565                 statuses = await this.Api.StatusesMentionsTimeline(count, maxId: tab.OldestId)
566                     .ConfigureAwait(false);
567             }
568             else
569             {
570                 statuses = await this.Api.StatusesMentionsTimeline(count)
571                     .ConfigureAwait(false);
572             }
573
574             var minimumId = this.CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.Reply, tab, read);
575             if (minimumId != null)
576                 tab.OldestId = minimumId.Value;
577         }
578
579         public async Task GetUserTimelineApi(bool read, string userName, UserTimelineTabModel tab, bool more)
580         {
581             this.CheckAccountState();
582
583             var count = GetApiResultCount(MyCommon.WORKERTYPE.UserTimeline, more, false);
584
585             TwitterStatus[] statuses;
586             if (MyCommon.IsNullOrEmpty(userName))
587             {
588                 var target = tab.ScreenName;
589                 if (MyCommon.IsNullOrEmpty(target)) return;
590                 userName = target;
591                 statuses = await this.Api.StatusesUserTimeline(userName, count)
592                     .ConfigureAwait(false);
593             }
594             else
595             {
596                 if (more)
597                 {
598                     statuses = await this.Api.StatusesUserTimeline(userName, count, maxId: tab.OldestId)
599                         .ConfigureAwait(false);
600                 }
601                 else
602                 {
603                     statuses = await this.Api.StatusesUserTimeline(userName, count)
604                         .ConfigureAwait(false);
605                 }
606             }
607
608             var minimumId = this.CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.UserTimeline, tab, read);
609
610             if (minimumId != null)
611                 tab.OldestId = minimumId.Value;
612         }
613
614         public async Task<PostClass> GetStatusApi(bool read, long id)
615         {
616             this.CheckAccountState();
617
618             var status = await this.Api.StatusesShow(id)
619                 .ConfigureAwait(false);
620
621             var item = this.CreatePostsFromStatusData(status);
622
623             item.IsRead = read;
624             if (item.IsMe && !read && this.ReadOwnPost) item.IsRead = true;
625
626             return item;
627         }
628
629         public async Task GetStatusApi(bool read, long id, TabModel tab)
630         {
631             var post = await this.GetStatusApi(read, id)
632                 .ConfigureAwait(false);
633
634             // 非同期アイコン取得&StatusDictionaryに追加
635             if (tab != null && tab.IsInnerStorageTabType)
636                 tab.AddPostQueue(post);
637             else
638                 TabInformations.GetInstance().AddPost(post);
639         }
640
641         private PostClass CreatePostsFromStatusData(TwitterStatus status)
642             => this.CreatePostsFromStatusData(status, false);
643
644         private PostClass CreatePostsFromStatusData(TwitterStatus status, bool favTweet)
645         {
646             var post = new PostClass();
647             TwitterEntities entities;
648             string sourceHtml;
649
650             post.StatusId = status.Id;
651             if (status.RetweetedStatus != null)
652             {
653                 var retweeted = status.RetweetedStatus;
654
655                 post.CreatedAt = MyCommon.DateTimeParse(retweeted.CreatedAt);
656
657                 // Id
658                 post.RetweetedId = retweeted.Id;
659                 // 本文
660                 post.TextFromApi = retweeted.FullText;
661                 entities = retweeted.MergedEntities;
662                 sourceHtml = retweeted.Source;
663                 // Reply先
664                 post.InReplyToStatusId = retweeted.InReplyToStatusId;
665                 post.InReplyToUser = retweeted.InReplyToScreenName;
666                 post.InReplyToUserId = status.InReplyToUserId;
667
668                 if (favTweet)
669                 {
670                     post.IsFav = true;
671                 }
672                 else
673                 {
674                     // 幻覚fav対策
675                     var tc = TabInformations.GetInstance().FavoriteTab;
676                     post.IsFav = tc.Contains(retweeted.Id);
677                 }
678
679                 if (retweeted.Coordinates != null)
680                     post.PostGeo = new PostClass.StatusGeo(retweeted.Coordinates.Coordinates[0], retweeted.Coordinates.Coordinates[1]);
681
682                 // 以下、ユーザー情報
683                 var user = retweeted.User;
684                 if (user != null)
685                 {
686                     post.UserId = user.Id;
687                     post.ScreenName = user.ScreenName;
688                     post.Nickname = user.Name.Trim();
689                     post.ImageUrl = user.ProfileImageUrlHttps;
690                     post.IsProtect = user.Protected;
691                 }
692                 else
693                 {
694                     post.UserId = 0L;
695                     post.ScreenName = "?????";
696                     post.Nickname = "Unknown User";
697                 }
698
699                 // Retweetした人
700                 if (status.User != null)
701                 {
702                     post.RetweetedBy = status.User.ScreenName;
703                     post.RetweetedByUserId = status.User.Id;
704                     post.IsMe = post.RetweetedByUserId == this.UserId;
705                 }
706                 else
707                 {
708                     post.RetweetedBy = "?????";
709                     post.RetweetedByUserId = 0L;
710                 }
711             }
712             else
713             {
714                 post.CreatedAt = MyCommon.DateTimeParse(status.CreatedAt);
715                 // 本文
716                 post.TextFromApi = status.FullText;
717                 entities = status.MergedEntities;
718                 sourceHtml = status.Source;
719                 post.InReplyToStatusId = status.InReplyToStatusId;
720                 post.InReplyToUser = status.InReplyToScreenName;
721                 post.InReplyToUserId = status.InReplyToUserId;
722
723                 if (favTweet)
724                 {
725                     post.IsFav = true;
726                 }
727                 else
728                 {
729                     // 幻覚fav対策
730                     var tc = TabInformations.GetInstance().FavoriteTab;
731                     post.IsFav = tc.Posts.TryGetValue(post.StatusId, out var tabinfoPost) && tabinfoPost.IsFav;
732                 }
733
734                 if (status.Coordinates != null)
735                     post.PostGeo = new PostClass.StatusGeo(status.Coordinates.Coordinates[0], status.Coordinates.Coordinates[1]);
736
737                 // 以下、ユーザー情報
738                 var user = status.User;
739                 if (user != null)
740                 {
741                     post.UserId = user.Id;
742                     post.ScreenName = user.ScreenName;
743                     post.Nickname = user.Name.Trim();
744                     post.ImageUrl = user.ProfileImageUrlHttps;
745                     post.IsProtect = user.Protected;
746                     post.IsMe = post.UserId == this.UserId;
747                 }
748                 else
749                 {
750                     post.UserId = 0L;
751                     post.ScreenName = "?????";
752                     post.Nickname = "Unknown User";
753                 }
754             }
755             // HTMLに整形
756             var textFromApi = post.TextFromApi;
757
758             var quotedStatusLink = (status.RetweetedStatus ?? status).QuotedStatusPermalink;
759
760             if (quotedStatusLink != null && entities.Urls.Any(x => x.ExpandedUrl == quotedStatusLink.Expanded))
761                 quotedStatusLink = null; // 移行期は entities.urls と quoted_status_permalink の両方に含まれる場合がある
762
763             post.Text = CreateHtmlAnchor(textFromApi, entities, quotedStatusLink);
764             post.TextFromApi = textFromApi;
765             post.TextFromApi = this.ReplaceTextFromApi(post.TextFromApi, entities, quotedStatusLink);
766             post.TextFromApi = WebUtility.HtmlDecode(post.TextFromApi);
767             post.TextFromApi = post.TextFromApi.Replace("<3", "\u2661");
768             post.AccessibleText = CreateAccessibleText(textFromApi, entities, (status.RetweetedStatus ?? status).QuotedStatus, quotedStatusLink);
769             post.AccessibleText = WebUtility.HtmlDecode(post.AccessibleText);
770             post.AccessibleText = post.AccessibleText.Replace("<3", "\u2661");
771
772             this.ExtractEntities(entities, post.ReplyToList, post.Media);
773
774             post.QuoteStatusIds = GetQuoteTweetStatusIds(entities, quotedStatusLink)
775                 .Where(x => x != post.StatusId && x != post.RetweetedId)
776                 .Distinct().ToArray();
777
778             post.ExpandedUrls = entities.OfType<TwitterEntityUrl>()
779                 .Select(x => new PostClass.ExpandedUrlInfo(x.Url, x.ExpandedUrl))
780                 .ToArray();
781
782             // メモリ使用量削減 (同一のテキストであれば同一の string インスタンスを参照させる)
783             if (post.Text == post.TextFromApi)
784                 post.Text = post.TextFromApi;
785             if (post.AccessibleText == post.TextFromApi)
786                 post.AccessibleText = post.TextFromApi;
787
788             // 他の発言と重複しやすい (共通化できる) 文字列は string.Intern を通す
789             post.ScreenName = string.Intern(post.ScreenName);
790             post.Nickname = string.Intern(post.Nickname);
791             post.ImageUrl = string.Intern(post.ImageUrl);
792             post.RetweetedBy = post.RetweetedBy != null ? string.Intern(post.RetweetedBy) : null;
793
794             // Source整形
795             var (sourceText, sourceUri) = ParseSource(sourceHtml);
796             post.Source = string.Intern(sourceText);
797             post.SourceUri = sourceUri;
798
799             post.IsReply = post.RetweetedId == null && post.ReplyToList.Any(x => x.UserId == this.UserId);
800             post.IsExcludeReply = false;
801
802             if (post.IsMe)
803             {
804                 post.IsOwl = false;
805             }
806             else
807             {
808                 if (this.followerId.Count > 0) post.IsOwl = !this.followerId.Contains(post.UserId);
809             }
810
811             post.IsDm = false;
812             return post;
813         }
814
815         /// <summary>
816         /// ツイートに含まれる引用ツイートのURLからステータスIDを抽出
817         /// </summary>
818         public static IEnumerable<long> GetQuoteTweetStatusIds(IEnumerable<TwitterEntity>? entities, TwitterQuotedStatusPermalink? quotedStatusLink)
819         {
820             entities ??= Enumerable.Empty<TwitterEntity>();
821
822             var urls = entities.OfType<TwitterEntityUrl>().Select(x => x.ExpandedUrl);
823
824             if (quotedStatusLink != null)
825                 urls = urls.Append(quotedStatusLink.Expanded);
826
827             return GetQuoteTweetStatusIds(urls);
828         }
829
830         public static IEnumerable<long> GetQuoteTweetStatusIds(IEnumerable<string> urls)
831         {
832             foreach (var url in urls)
833             {
834                 var match = Twitter.StatusUrlRegex.Match(url);
835                 if (match.Success)
836                 {
837                     if (long.TryParse(match.Groups["StatusId"].Value, out var statusId))
838                         yield return statusId;
839                 }
840             }
841         }
842
843         private long? CreatePostsFromJson(TwitterStatus[] items, MyCommon.WORKERTYPE gType, TabModel? tab, bool read)
844         {
845             long? minimumId = null;
846
847             foreach (var status in items)
848             {
849                 if (minimumId == null || minimumId.Value > status.Id)
850                     minimumId = status.Id;
851
852                 // 二重取得回避
853                 lock (this.LockObj)
854                 {
855                     if (tab == null)
856                     {
857                         if (TabInformations.GetInstance().ContainsKey(status.Id)) continue;
858                     }
859                     else
860                     {
861                         if (tab.Contains(status.Id)) continue;
862                     }
863                 }
864
865                 // RT禁止ユーザーによるもの
866                 if (gType != MyCommon.WORKERTYPE.UserTimeline &&
867                     status.RetweetedStatus != null && this.noRTId.Contains(status.User.Id)) continue;
868
869                 var post = this.CreatePostsFromStatusData(status);
870
871                 post.IsRead = read;
872                 if (post.IsMe && !read && this.ReadOwnPost) post.IsRead = true;
873
874                 if (tab != null && tab.IsInnerStorageTabType)
875                     tab.AddPostQueue(post);
876                 else
877                     TabInformations.GetInstance().AddPost(post);
878             }
879
880             return minimumId;
881         }
882
883         private long? CreatePostsFromSearchJson(TwitterSearchResult items, PublicSearchTabModel tab, bool read, bool more)
884         {
885             long? minimumId = null;
886
887             foreach (var status in items.Statuses)
888             {
889                 if (minimumId == null || minimumId.Value > status.Id)
890                     minimumId = status.Id;
891
892                 if (!more && status.Id > tab.SinceId) tab.SinceId = status.Id;
893                 // 二重取得回避
894                 lock (this.LockObj)
895                 {
896                     if (tab.Contains(status.Id)) continue;
897                 }
898
899                 var post = this.CreatePostsFromStatusData(status);
900
901                 post.IsRead = read;
902                 if ((post.IsMe && !read) && this.ReadOwnPost) post.IsRead = true;
903
904                 tab.AddPostQueue(post);
905             }
906
907             return minimumId;
908         }
909
910         private long? CreateFavoritePostsFromJson(TwitterStatus[] items, bool read)
911         {
912             var favTab = TabInformations.GetInstance().FavoriteTab;
913             long? minimumId = null;
914
915             foreach (var status in items)
916             {
917                 if (minimumId == null || minimumId.Value > status.Id)
918                     minimumId = status.Id;
919
920                 // 二重取得回避
921                 lock (this.LockObj)
922                 {
923                     if (favTab.Contains(status.Id)) continue;
924                 }
925
926                 var post = this.CreatePostsFromStatusData(status, true);
927
928                 post.IsRead = read;
929
930                 TabInformations.GetInstance().AddPost(post);
931             }
932
933             return minimumId;
934         }
935
936         public async Task GetListStatus(bool read, ListTimelineTabModel tab, bool more, bool startup)
937         {
938             var count = GetApiResultCount(MyCommon.WORKERTYPE.List, more, startup);
939
940             TwitterStatus[] statuses;
941             if (more)
942             {
943                 statuses = await this.Api.ListsStatuses(tab.ListInfo.Id, count, maxId: tab.OldestId, includeRTs: SettingManager.Common.IsListsIncludeRts)
944                     .ConfigureAwait(false);
945             }
946             else
947             {
948                 statuses = await this.Api.ListsStatuses(tab.ListInfo.Id, count, includeRTs: SettingManager.Common.IsListsIncludeRts)
949                     .ConfigureAwait(false);
950             }
951
952             var minimumId = this.CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.List, tab, read);
953
954             if (minimumId != null)
955                 tab.OldestId = minimumId.Value;
956         }
957
958         /// <summary>
959         /// startStatusId からリプライ先の発言を辿る。発言は posts 以外からは検索しない。
960         /// </summary>
961         /// <returns>posts の中から検索されたリプライチェインの末端</returns>
962         internal static PostClass FindTopOfReplyChain(IDictionary<long, PostClass> posts, long startStatusId)
963         {
964             if (!posts.ContainsKey(startStatusId))
965                 throw new ArgumentException("startStatusId (" + startStatusId + ") が posts の中から見つかりませんでした。", nameof(startStatusId));
966
967             var nextPost = posts[startStatusId];
968             while (nextPost.InReplyToStatusId != null)
969             {
970                 if (!posts.ContainsKey(nextPost.InReplyToStatusId.Value))
971                     break;
972                 nextPost = posts[nextPost.InReplyToStatusId.Value];
973             }
974
975             return nextPost;
976         }
977
978         public async Task GetRelatedResult(bool read, RelatedPostsTabModel tab)
979         {
980             var targetPost = tab.TargetPost;
981             var relPosts = new Dictionary<long, PostClass>();
982             if (targetPost.TextFromApi.Contains("@") && targetPost.InReplyToStatusId == null)
983             {
984                 // 検索結果対応
985                 var p = TabInformations.GetInstance()[targetPost.StatusId];
986                 if (p != null && p.InReplyToStatusId != null)
987                 {
988                     targetPost = p;
989                 }
990                 else
991                 {
992                     p = await this.GetStatusApi(read, targetPost.StatusId)
993                         .ConfigureAwait(false);
994                     targetPost = p;
995                 }
996             }
997             relPosts.Add(targetPost.StatusId, targetPost);
998
999             Exception? lastException = null;
1000
1001             // in_reply_to_status_id を使用してリプライチェインを辿る
1002             var nextPost = FindTopOfReplyChain(relPosts, targetPost.StatusId);
1003             var loopCount = 1;
1004             while (nextPost.InReplyToStatusId != null && loopCount++ <= 20)
1005             {
1006                 var inReplyToId = nextPost.InReplyToStatusId.Value;
1007
1008                 var inReplyToPost = TabInformations.GetInstance()[inReplyToId];
1009                 if (inReplyToPost == null)
1010                 {
1011                     try
1012                     {
1013                         inReplyToPost = await this.GetStatusApi(read, inReplyToId)
1014                             .ConfigureAwait(false);
1015                     }
1016                     catch (WebApiException ex)
1017                     {
1018                         lastException = ex;
1019                         break;
1020                     }
1021                 }
1022
1023                 relPosts.Add(inReplyToPost.StatusId, inReplyToPost);
1024
1025                 nextPost = FindTopOfReplyChain(relPosts, nextPost.StatusId);
1026             }
1027
1028             // MRTとかに対応のためツイート内にあるツイートを指すURLを取り込む
1029             var text = targetPost.Text;
1030             var ma = Twitter.StatusUrlRegex.Matches(text).Cast<Match>()
1031                 .Concat(Twitter.ThirdPartyStatusUrlRegex.Matches(text).Cast<Match>());
1032             foreach (var _match in ma)
1033             {
1034                 if (long.TryParse(_match.Groups["StatusId"].Value, out var _statusId))
1035                 {
1036                     if (relPosts.ContainsKey(_statusId))
1037                         continue;
1038
1039                     var p = TabInformations.GetInstance()[_statusId];
1040                     if (p == null)
1041                     {
1042                         try
1043                         {
1044                             p = await this.GetStatusApi(read, _statusId)
1045                                 .ConfigureAwait(false);
1046                         }
1047                         catch (WebApiException ex)
1048                         {
1049                             lastException = ex;
1050                             break;
1051                         }
1052                     }
1053
1054                     if (p != null)
1055                         relPosts.Add(p.StatusId, p);
1056                 }
1057             }
1058
1059             relPosts.Values.ToList().ForEach(p =>
1060             {
1061                 if (p.IsMe && !read && this.ReadOwnPost)
1062                     p.IsRead = true;
1063                 else
1064                     p.IsRead = read;
1065
1066                 tab.AddPostQueue(p);
1067             });
1068
1069             if (lastException != null)
1070                 throw new WebApiException(lastException.Message, lastException);
1071         }
1072
1073         public async Task GetSearch(bool read, PublicSearchTabModel tab, bool more)
1074         {
1075             var count = GetApiResultCount(MyCommon.WORKERTYPE.PublicSearch, more, false);
1076
1077             long? maxId = null;
1078             long? sinceId = null;
1079             if (more)
1080             {
1081                 maxId = tab.OldestId - 1;
1082             }
1083             else
1084             {
1085                 sinceId = tab.SinceId;
1086             }
1087
1088             var searchResult = await this.Api.SearchTweets(tab.SearchWords, tab.SearchLang, count, maxId, sinceId)
1089                 .ConfigureAwait(false);
1090
1091             if (!TabInformations.GetInstance().ContainsTab(tab))
1092                 return;
1093
1094             var minimumId = this.CreatePostsFromSearchJson(searchResult, tab, read, more);
1095
1096             if (minimumId != null)
1097                 tab.OldestId = minimumId.Value;
1098         }
1099
1100         public async Task GetDirectMessageEvents(bool read, bool backward)
1101         {
1102             this.CheckAccountState();
1103             this.CheckAccessLevel(TwitterApiAccessLevel.ReadWriteAndDirectMessage);
1104
1105             var count = 50;
1106
1107             TwitterMessageEventList eventList;
1108             if (backward)
1109             {
1110                 eventList = await this.Api.DirectMessagesEventsList(count, this.nextCursorDirectMessage)
1111                     .ConfigureAwait(false);
1112             }
1113             else
1114             {
1115                 eventList = await this.Api.DirectMessagesEventsList(count)
1116                     .ConfigureAwait(false);
1117             }
1118
1119             this.nextCursorDirectMessage = eventList.NextCursor;
1120
1121             await this.CreateDirectMessagesEventFromJson(eventList, read)
1122                 .ConfigureAwait(false);
1123         }
1124
1125         private async Task CreateDirectMessagesEventFromJson(TwitterMessageEventSingle eventSingle, bool read)
1126         {
1127             var eventList = new TwitterMessageEventList
1128             {
1129                 Apps = new Dictionary<string, TwitterMessageEventList.App>(),
1130                 Events = new[] { eventSingle.Event },
1131             };
1132
1133             await this.CreateDirectMessagesEventFromJson(eventList, read)
1134                 .ConfigureAwait(false);
1135         }
1136
1137         private async Task CreateDirectMessagesEventFromJson(TwitterMessageEventList eventList, bool read)
1138         {
1139             var events = eventList.Events
1140                 .Where(x => x.Type == "message_create")
1141                 .ToArray();
1142
1143             if (events.Length == 0)
1144                 return;
1145
1146             var userIds = Enumerable.Concat(
1147                 events.Select(x => x.MessageCreate.SenderId),
1148                 events.Select(x => x.MessageCreate.Target.RecipientId)
1149             ).Distinct().ToArray();
1150
1151             var users = (await this.Api.UsersLookup(userIds).ConfigureAwait(false))
1152                 .ToDictionary(x => x.IdStr);
1153
1154             var apps = eventList.Apps ?? new Dictionary<string, TwitterMessageEventList.App>();
1155
1156             this.CreateDirectMessagesEventFromJson(events, users, apps, read);
1157         }
1158
1159         private void CreateDirectMessagesEventFromJson(
1160             IEnumerable<TwitterMessageEvent> events,
1161             IReadOnlyDictionary<string, TwitterUser> users,
1162             IReadOnlyDictionary<string, TwitterMessageEventList.App> apps,
1163             bool read)
1164         {
1165             foreach (var eventItem in events)
1166             {
1167                 var post = new PostClass();
1168                 post.StatusId = long.Parse(eventItem.Id);
1169
1170                 var timestamp = long.Parse(eventItem.CreatedTimestamp);
1171                 post.CreatedAt = DateTimeUtc.UnixEpoch + TimeSpan.FromTicks(timestamp * TimeSpan.TicksPerMillisecond);
1172                 // 本文
1173                 var textFromApi = eventItem.MessageCreate.MessageData.Text;
1174
1175                 var entities = eventItem.MessageCreate.MessageData.Entities;
1176                 var mediaEntity = eventItem.MessageCreate.MessageData.Attachment?.Media;
1177
1178                 if (mediaEntity != null)
1179                     entities.Media = new[] { mediaEntity };
1180
1181                 // HTMLに整形
1182                 post.Text = CreateHtmlAnchor(textFromApi, entities, quotedStatusLink: null);
1183                 post.TextFromApi = this.ReplaceTextFromApi(textFromApi, entities, quotedStatusLink: null);
1184                 post.TextFromApi = WebUtility.HtmlDecode(post.TextFromApi);
1185                 post.TextFromApi = post.TextFromApi.Replace("<3", "\u2661");
1186                 post.AccessibleText = CreateAccessibleText(textFromApi, entities, quotedStatus: null, quotedStatusLink: null);
1187                 post.AccessibleText = WebUtility.HtmlDecode(post.AccessibleText);
1188                 post.AccessibleText = post.AccessibleText.Replace("<3", "\u2661");
1189                 post.IsFav = false;
1190
1191                 this.ExtractEntities(entities, post.ReplyToList, post.Media);
1192
1193                 post.QuoteStatusIds = GetQuoteTweetStatusIds(entities, quotedStatusLink: null)
1194                     .Distinct().ToArray();
1195
1196                 post.ExpandedUrls = entities.OfType<TwitterEntityUrl>()
1197                     .Select(x => new PostClass.ExpandedUrlInfo(x.Url, x.ExpandedUrl))
1198                     .ToArray();
1199
1200                 // 以下、ユーザー情報
1201                 string userId;
1202                 if (eventItem.MessageCreate.SenderId != this.Api.CurrentUserId.ToString(CultureInfo.InvariantCulture))
1203                 {
1204                     userId = eventItem.MessageCreate.SenderId;
1205                     post.IsMe = false;
1206                     post.IsOwl = true;
1207                 }
1208                 else
1209                 {
1210                     userId = eventItem.MessageCreate.Target.RecipientId;
1211                     post.IsMe = true;
1212                     post.IsOwl = false;
1213                 }
1214
1215                 if (!users.TryGetValue(userId, out var user))
1216                     continue;
1217
1218                 post.UserId = user.Id;
1219                 post.ScreenName = user.ScreenName;
1220                 post.Nickname = user.Name.Trim();
1221                 post.ImageUrl = user.ProfileImageUrlHttps;
1222                 post.IsProtect = user.Protected;
1223
1224                 // メモリ使用量削減 (同一のテキストであれば同一の string インスタンスを参照させる)
1225                 if (post.Text == post.TextFromApi)
1226                     post.Text = post.TextFromApi;
1227                 if (post.AccessibleText == post.TextFromApi)
1228                     post.AccessibleText = post.TextFromApi;
1229
1230                 // 他の発言と重複しやすい (共通化できる) 文字列は string.Intern を通す
1231                 post.ScreenName = string.Intern(post.ScreenName);
1232                 post.Nickname = string.Intern(post.Nickname);
1233                 post.ImageUrl = string.Intern(post.ImageUrl);
1234
1235                 var appId = eventItem.MessageCreate.SourceAppId;
1236                 if (appId != null && apps.TryGetValue(appId, out var app))
1237                 {
1238                     post.Source = string.Intern(app.Name);
1239
1240                     try
1241                     {
1242                         post.SourceUri = new Uri(SourceUriBase, app.Url);
1243                     }
1244                     catch (UriFormatException) { }
1245                 }
1246
1247                 post.IsRead = read;
1248                 if (post.IsMe && !read && this.ReadOwnPost)
1249                     post.IsRead = true;
1250                 post.IsReply = false;
1251                 post.IsExcludeReply = false;
1252                 post.IsDm = true;
1253
1254                 var dmTab = TabInformations.GetInstance().DirectMessageTab;
1255                 dmTab.AddPostQueue(post);
1256             }
1257         }
1258
1259         public async Task GetFavoritesApi(bool read, FavoritesTabModel tab, bool backward)
1260         {
1261             this.CheckAccountState();
1262
1263             var count = GetApiResultCount(MyCommon.WORKERTYPE.Favorites, backward, false);
1264
1265             TwitterStatus[] statuses;
1266             if (backward)
1267             {
1268                 statuses = await this.Api.FavoritesList(count, maxId: tab.OldestId)
1269                     .ConfigureAwait(false);
1270             }
1271             else
1272             {
1273                 statuses = await this.Api.FavoritesList(count)
1274                     .ConfigureAwait(false);
1275             }
1276
1277             var minimumId = this.CreateFavoritePostsFromJson(statuses, read);
1278
1279             if (minimumId != null)
1280                 tab.OldestId = minimumId.Value;
1281         }
1282
1283         private string ReplaceTextFromApi(string text, TwitterEntities? entities, TwitterQuotedStatusPermalink? quotedStatusLink)
1284         {
1285             if (entities != null)
1286             {
1287                 if (entities.Urls != null)
1288                 {
1289                     foreach (var m in entities.Urls)
1290                     {
1291                         if (!MyCommon.IsNullOrEmpty(m.DisplayUrl)) text = text.Replace(m.Url, m.DisplayUrl);
1292                     }
1293                 }
1294                 if (entities.Media != null)
1295                 {
1296                     foreach (var m in entities.Media)
1297                     {
1298                         if (!MyCommon.IsNullOrEmpty(m.DisplayUrl)) text = text.Replace(m.Url, m.DisplayUrl);
1299                     }
1300                 }
1301             }
1302
1303             if (quotedStatusLink != null)
1304                 text += " " + quotedStatusLink.Display;
1305
1306             return text;
1307         }
1308
1309         internal static string CreateAccessibleText(string text, TwitterEntities? entities, TwitterStatus? quotedStatus, TwitterQuotedStatusPermalink? quotedStatusLink)
1310         {
1311             if (entities == null)
1312                 return text;
1313
1314             if (entities.Urls != null)
1315             {
1316                 foreach (var entity in entities.Urls)
1317                 {
1318                     if (quotedStatus != null)
1319                     {
1320                         var matchStatusUrl = Twitter.StatusUrlRegex.Match(entity.ExpandedUrl);
1321                         if (matchStatusUrl.Success && matchStatusUrl.Groups["StatusId"].Value == quotedStatus.IdStr)
1322                         {
1323                             var quotedText = CreateAccessibleText(quotedStatus.FullText, quotedStatus.MergedEntities, quotedStatus: null, quotedStatusLink: null);
1324                             text = text.Replace(entity.Url, string.Format(Properties.Resources.QuoteStatus_AccessibleText, quotedStatus.User.ScreenName, quotedText));
1325                             continue;
1326                         }
1327                     }
1328
1329                     if (!MyCommon.IsNullOrEmpty(entity.DisplayUrl))
1330                         text = text.Replace(entity.Url, entity.DisplayUrl);
1331                 }
1332             }
1333
1334             if (entities.Media != null)
1335             {
1336                 foreach (var entity in entities.Media)
1337                 {
1338                     if (!MyCommon.IsNullOrEmpty(entity.AltText))
1339                     {
1340                         text = text.Replace(entity.Url, string.Format(Properties.Resources.ImageAltText, entity.AltText));
1341                     }
1342                     else if (!MyCommon.IsNullOrEmpty(entity.DisplayUrl))
1343                     {
1344                         text = text.Replace(entity.Url, entity.DisplayUrl);
1345                     }
1346                 }
1347             }
1348
1349             if (quotedStatus != null && quotedStatusLink != null)
1350             {
1351                 var quoteText = CreateAccessibleText(quotedStatus.FullText, quotedStatus.MergedEntities, quotedStatus: null, quotedStatusLink: null);
1352                 text += " " + string.Format(Properties.Resources.QuoteStatus_AccessibleText, quotedStatus.User.ScreenName, quoteText);
1353             }
1354
1355             return text;
1356         }
1357
1358         /// <summary>
1359         /// フォロワーIDを更新します
1360         /// </summary>
1361         /// <exception cref="WebApiException"/>
1362         public async Task RefreshFollowerIds()
1363         {
1364             if (MyCommon._endingFlag) return;
1365
1366             var cursor = -1L;
1367             var newFollowerIds = Enumerable.Empty<long>();
1368             do
1369             {
1370                 var ret = await this.Api.FollowersIds(cursor)
1371                     .ConfigureAwait(false);
1372
1373                 if (ret.Ids == null)
1374                     throw new WebApiException("ret.ids == null");
1375
1376                 newFollowerIds = newFollowerIds.Concat(ret.Ids);
1377                 cursor = ret.NextCursor;
1378             } while (cursor != 0);
1379
1380             this.followerId = newFollowerIds.ToHashSet();
1381             TabInformations.GetInstance().RefreshOwl(this.followerId);
1382
1383             this.GetFollowersSuccess = true;
1384         }
1385
1386         /// <summary>
1387         /// RT 非表示ユーザーを更新します
1388         /// </summary>
1389         /// <exception cref="WebApiException"/>
1390         public async Task RefreshNoRetweetIds()
1391         {
1392             if (MyCommon._endingFlag) return;
1393
1394             this.noRTId = await this.Api.NoRetweetIds()
1395                 .ConfigureAwait(false);
1396
1397             this.GetNoRetweetSuccess = true;
1398         }
1399
1400         /// <summary>
1401         /// t.co の文字列長などの設定情報を更新します
1402         /// </summary>
1403         /// <exception cref="WebApiException"/>
1404         public async Task RefreshConfiguration()
1405         {
1406             this.Configuration = await this.Api.Configuration()
1407                 .ConfigureAwait(false);
1408
1409             // TextConfiguration 相当の JSON を得る API が存在しないため、TransformedURLLength のみ help/configuration.json に合わせて更新する
1410             this.TextConfiguration.TransformedURLLength = this.Configuration.ShortUrlLengthHttps;
1411         }
1412
1413         public async Task GetListsApi()
1414         {
1415             this.CheckAccountState();
1416
1417             var ownedLists = await TwitterLists.GetAllItemsAsync(x =>
1418                 this.Api.ListsOwnerships(this.Username, cursor: x, count: 1000))
1419                     .ConfigureAwait(false);
1420
1421             var subscribedLists = await TwitterLists.GetAllItemsAsync(x =>
1422                 this.Api.ListsSubscriptions(this.Username, cursor: x, count: 1000))
1423                     .ConfigureAwait(false);
1424
1425             TabInformations.GetInstance().SubscribableLists = Enumerable.Concat(ownedLists, subscribedLists)
1426                 .Select(x => new ListElement(x, this))
1427                 .ToList();
1428         }
1429
1430         public async Task DeleteList(long listId)
1431         {
1432             await this.Api.ListsDestroy(listId)
1433                 .IgnoreResponse()
1434                 .ConfigureAwait(false);
1435
1436             var tabinfo = TabInformations.GetInstance();
1437
1438             tabinfo.SubscribableLists = tabinfo.SubscribableLists
1439                 .Where(x => x.Id != listId)
1440                 .ToList();
1441         }
1442
1443         public async Task<ListElement> EditList(long listId, string new_name, bool isPrivate, string description)
1444         {
1445             var response = await this.Api.ListsUpdate(listId, new_name, description, isPrivate)
1446                 .ConfigureAwait(false);
1447
1448             var list = await response.LoadJsonAsync()
1449                 .ConfigureAwait(false);
1450
1451             return new ListElement(list, this);
1452         }
1453
1454         public async Task<long> GetListMembers(long listId, List<UserInfo> lists, long cursor)
1455         {
1456             this.CheckAccountState();
1457
1458             var users = await this.Api.ListsMembers(listId, cursor)
1459                 .ConfigureAwait(false);
1460
1461             Array.ForEach(users.Users, u => lists.Add(new UserInfo(u)));
1462
1463             return users.NextCursor;
1464         }
1465
1466         public async Task CreateListApi(string listName, bool isPrivate, string description)
1467         {
1468             this.CheckAccountState();
1469
1470             var response = await this.Api.ListsCreate(listName, description, isPrivate)
1471                 .ConfigureAwait(false);
1472
1473             var list = await response.LoadJsonAsync()
1474                 .ConfigureAwait(false);
1475
1476             TabInformations.GetInstance().SubscribableLists.Add(new ListElement(list, this));
1477         }
1478
1479         public async Task<bool> ContainsUserAtList(long listId, string user)
1480         {
1481             this.CheckAccountState();
1482
1483             try
1484             {
1485                 await this.Api.ListsMembersShow(listId, user)
1486                     .ConfigureAwait(false);
1487
1488                 return true;
1489             }
1490             catch (TwitterApiException ex)
1491                 when (ex.Errors.Any(x => x.Code == TwitterErrorCode.NotFound))
1492             {
1493                 return false;
1494             }
1495         }
1496
1497         private void ExtractEntities(TwitterEntities? entities, List<(long UserId, string ScreenName)> AtList, List<MediaInfo> media)
1498         {
1499             if (entities != null)
1500             {
1501                 if (entities.Hashtags != null)
1502                 {
1503                     lock (this.LockObj)
1504                     {
1505                         this._hashList.AddRange(entities.Hashtags.Select(x => "#" + x.Text));
1506                     }
1507                 }
1508                 if (entities.UserMentions != null)
1509                 {
1510                     foreach (var ent in entities.UserMentions)
1511                     {
1512                         AtList.Add((ent.Id, ent.ScreenName));
1513                     }
1514                 }
1515                 if (entities.Media != null)
1516                 {
1517                     if (media != null)
1518                     {
1519                         foreach (var ent in entities.Media)
1520                         {
1521                             if (!media.Any(x => x.Url == ent.MediaUrlHttps))
1522                             {
1523                                 if (ent.VideoInfo != null &&
1524                                     ent.Type == "animated_gif" || ent.Type == "video")
1525                                 {
1526                                     media.Add(new MediaInfo(ent.MediaUrlHttps, ent.AltText, ent.ExpandedUrl));
1527                                 }
1528                                 else
1529                                     media.Add(new MediaInfo(ent.MediaUrlHttps, ent.AltText, videoUrl: null));
1530                             }
1531                         }
1532                     }
1533                 }
1534             }
1535         }
1536
1537         internal static string CreateHtmlAnchor(string text, TwitterEntities? entities, TwitterQuotedStatusPermalink? quotedStatusLink)
1538         {
1539             var mergedEntities = entities.Concat(TweetExtractor.ExtractEmojiEntities(text));
1540
1541             // PostClass.ExpandedUrlInfo を使用して非同期に URL 展開を行うためここでは expanded_url を使用しない
1542             text = TweetFormatter.AutoLinkHtml(text, mergedEntities, keepTco: true);
1543
1544             text = Regex.Replace(text, "(^|[^a-zA-Z0-9_/&##@@>=.~])(sm|nm)([0-9]{1,10})", "$1<a href=\"https://www.nicovideo.jp/watch/$2$3\">$2$3</a>");
1545             text = PreProcessUrl(text); // IDN置換
1546
1547             if (quotedStatusLink != null)
1548             {
1549                 text += string.Format(" <a href=\"{0}\" title=\"{0}\">{1}</a>",
1550                     WebUtility.HtmlEncode(quotedStatusLink.Url),
1551                     WebUtility.HtmlEncode(quotedStatusLink.Display));
1552             }
1553
1554             return text;
1555         }
1556
1557         private static readonly Uri SourceUriBase = new Uri("https://twitter.com/");
1558
1559         /// <summary>
1560         /// Twitter APIから得たHTML形式のsource文字列を分析し、source名とURLに分離します
1561         /// </summary>
1562         internal static (string SourceText, Uri? SourceUri) ParseSource(string? sourceHtml)
1563         {
1564             if (MyCommon.IsNullOrEmpty(sourceHtml))
1565                 return ("", null);
1566
1567             string sourceText;
1568             Uri? sourceUri;
1569
1570             // sourceHtmlの例: <a href="http://twitter.com" rel="nofollow">Twitter Web Client</a>
1571
1572             var match = Regex.Match(sourceHtml, "^<a href=\"(?<uri>.+?)\".*?>(?<text>.+)</a>$", RegexOptions.IgnoreCase);
1573             if (match.Success)
1574             {
1575                 sourceText = WebUtility.HtmlDecode(match.Groups["text"].Value);
1576                 try
1577                 {
1578                     var uriStr = WebUtility.HtmlDecode(match.Groups["uri"].Value);
1579                     sourceUri = new Uri(SourceUriBase, uriStr);
1580                 }
1581                 catch (UriFormatException)
1582                 {
1583                     sourceUri = null;
1584                 }
1585             }
1586             else
1587             {
1588                 sourceText = WebUtility.HtmlDecode(sourceHtml);
1589                 sourceUri = null;
1590             }
1591
1592             return (sourceText, sourceUri);
1593         }
1594
1595         public async Task<TwitterApiStatus?> GetInfoApi()
1596         {
1597             if (Twitter.AccountState != MyCommon.ACCOUNT_STATE.Valid) return null;
1598
1599             if (MyCommon._endingFlag) return null;
1600
1601             var limits = await this.Api.ApplicationRateLimitStatus()
1602                 .ConfigureAwait(false);
1603
1604             MyCommon.TwitterApiInfo.UpdateFromJson(limits);
1605
1606             return MyCommon.TwitterApiInfo;
1607         }
1608
1609         /// <summary>
1610         /// ブロック中のユーザーを更新します
1611         /// </summary>
1612         /// <exception cref="WebApiException"/>
1613         public async Task RefreshBlockIds()
1614         {
1615             if (MyCommon._endingFlag) return;
1616
1617             var cursor = -1L;
1618             var newBlockIds = Enumerable.Empty<long>();
1619             do
1620             {
1621                 var ret = await this.Api.BlocksIds(cursor)
1622                     .ConfigureAwait(false);
1623
1624                 newBlockIds = newBlockIds.Concat(ret.Ids);
1625                 cursor = ret.NextCursor;
1626             } while (cursor != 0);
1627
1628             var blockIdsSet = newBlockIds.ToHashSet();
1629             blockIdsSet.Remove(this.UserId); // 元のソースにあったので一応残しておく
1630
1631             TabInformations.GetInstance().BlockIds = blockIdsSet;
1632         }
1633
1634         /// <summary>
1635         /// ミュート中のユーザーIDを更新します
1636         /// </summary>
1637         /// <exception cref="WebApiException"/>
1638         public async Task RefreshMuteUserIdsAsync()
1639         {
1640             if (MyCommon._endingFlag) return;
1641
1642             var ids = await TwitterIds.GetAllItemsAsync(x => this.Api.MutesUsersIds(x))
1643                 .ConfigureAwait(false);
1644
1645             TabInformations.GetInstance().MuteUserIds = ids.ToHashSet();
1646         }
1647
1648         public string[] GetHashList()
1649         {
1650             string[] hashArray;
1651             lock (this.LockObj)
1652             {
1653                 hashArray = this._hashList.ToArray();
1654                 this._hashList.Clear();
1655             }
1656             return hashArray;
1657         }
1658
1659         public string AccessToken
1660             => ((TwitterApiConnection)this.Api.Connection).AccessToken;
1661
1662         public string AccessTokenSecret
1663             => ((TwitterApiConnection)this.Api.Connection).AccessSecret;
1664
1665         private void CheckAccountState()
1666         {
1667             if (Twitter.AccountState != MyCommon.ACCOUNT_STATE.Valid)
1668                 throw new WebApiException("Auth error. Check your account");
1669         }
1670
1671         private void CheckAccessLevel(TwitterApiAccessLevel accessLevelFlags)
1672         {
1673             if (!this.AccessLevel.HasFlag(accessLevelFlags))
1674                 throw new WebApiException("Auth Err:try to re-authorization.");
1675         }
1676
1677         public int GetTextLengthRemain(string postText)
1678         {
1679             var matchDm = Twitter.DMSendTextRegex.Match(postText);
1680             if (matchDm.Success)
1681                 return this.GetTextLengthRemainDM(matchDm.Groups["body"].Value);
1682
1683             return this.GetTextLengthRemainWeighted(postText);
1684         }
1685
1686         private int GetTextLengthRemainDM(string postText)
1687         {
1688             var textLength = 0;
1689
1690             var pos = 0;
1691             while (pos < postText.Length)
1692             {
1693                 textLength++;
1694
1695                 if (char.IsSurrogatePair(postText, pos))
1696                     pos += 2; // サロゲートペアの場合は2文字分進める
1697                 else
1698                     pos++;
1699             }
1700
1701             var urls = TweetExtractor.ExtractUrls(postText);
1702             foreach (var url in urls)
1703             {
1704                 var shortUrlLength = url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
1705                     ? this.Configuration.ShortUrlLengthHttps
1706                     : this.Configuration.ShortUrlLength;
1707
1708                 textLength += shortUrlLength - url.Length;
1709             }
1710
1711             return this.Configuration.DmTextCharacterLimit - textLength;
1712         }
1713
1714         private int GetTextLengthRemainWeighted(string postText)
1715         {
1716             var config = this.TextConfiguration;
1717             var totalWeight = 0;
1718
1719             int GetWeightFromCodepoint(int codepoint)
1720             {
1721                 foreach (var weightRange in config.Ranges)
1722                 {
1723                     if (codepoint >= weightRange.Start && codepoint <= weightRange.End)
1724                         return weightRange.Weight;
1725                 }
1726
1727                 return config.DefaultWeight;
1728             }
1729
1730             var urls = TweetExtractor.ExtractUrlEntities(postText).ToArray();
1731             var emojis = config.EmojiParsingEnabled
1732                 ? TweetExtractor.ExtractEmojiEntities(postText).ToArray()
1733                 : Array.Empty<TwitterEntityEmoji>();
1734
1735             var codepoints = postText.ToCodepoints().ToArray();
1736             var index = 0;
1737             while (index < codepoints.Length)
1738             {
1739                 var urlEntity = urls.FirstOrDefault(x => x.Indices[0] == index);
1740                 if (urlEntity != null)
1741                 {
1742                     totalWeight += config.TransformedURLLength * config.Scale;
1743                     index = urlEntity.Indices[1];
1744                     continue;
1745                 }
1746
1747                 var emojiEntity = emojis.FirstOrDefault(x => x.Indices[0] == index);
1748                 if (emojiEntity != null)
1749                 {
1750                     totalWeight += GetWeightFromCodepoint(codepoints[index]);
1751                     index = emojiEntity.Indices[1];
1752                     continue;
1753                 }
1754
1755                 var codepoint = codepoints[index];
1756                 totalWeight += GetWeightFromCodepoint(codepoint);
1757
1758                 index++;
1759             }
1760
1761             var remainWeight = config.MaxWeightedTweetLength * config.Scale - totalWeight;
1762
1763             return remainWeight / config.Scale;
1764         }
1765
1766         public bool IsDisposed { get; private set; } = false;
1767
1768         protected virtual void Dispose(bool disposing)
1769         {
1770             if (this.IsDisposed)
1771                 return;
1772
1773             if (disposing)
1774             {
1775                 this.Api.Dispose();
1776             }
1777
1778             this.IsDisposed = true;
1779         }
1780
1781         public void Dispose()
1782         {
1783             this.Dispose(true);
1784             GC.SuppressFinalize(this);
1785         }
1786     }
1787 }