OSDN Git Service

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