OSDN Git Service

凍結されたユーザーのツイートに対するエラー表示に対応
[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.IO;
34 using System.Linq;
35 using System.Net;
36 using System.Net.Http;
37 using System.Reflection;
38 using System.Runtime.CompilerServices;
39 using System.Text;
40 using System.Text.RegularExpressions;
41 using System.Threading;
42 using System.Threading.Tasks;
43 using System.Windows.Forms;
44 using OpenTween.Api;
45 using OpenTween.Api.DataModel;
46 using OpenTween.Api.GraphQL;
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         private readonly TwitterPostFactory postFactory;
176
177         private string? previousStatusId = null;
178
179         public Twitter(TwitterApi api)
180         {
181             this.postFactory = new(TabInformations.GetInstance());
182
183             this.Api = api;
184             this.Configuration = TwitterConfiguration.DefaultConfiguration();
185             this.TextConfiguration = TwitterTextConfiguration.DefaultConfiguration();
186         }
187
188         public TwitterApiAccessLevel AccessLevel
189             => MyCommon.TwitterApiInfo.AccessLevel;
190
191         protected void ResetApiStatus()
192             => MyCommon.TwitterApiInfo.Reset();
193
194         public void ClearAuthInfo()
195         {
196             Twitter.AccountState = MyCommon.ACCOUNT_STATE.Invalid;
197             this.ResetApiStatus();
198         }
199
200         public void VerifyCredentials()
201         {
202             try
203             {
204                 this.VerifyCredentialsAsync().Wait();
205             }
206             catch (AggregateException ex) when (ex.InnerException is WebApiException)
207             {
208                 throw new WebApiException(ex.InnerException.Message, ex);
209             }
210         }
211
212         public async Task VerifyCredentialsAsync()
213         {
214             var user = await this.Api.AccountVerifyCredentials()
215                 .ConfigureAwait(false);
216
217             this.UpdateUserStats(user);
218         }
219
220         public void Initialize(string token, string tokenSecret, string username, long userId)
221         {
222             // OAuth認証
223             if (MyCommon.IsNullOrEmpty(token) || MyCommon.IsNullOrEmpty(tokenSecret) || MyCommon.IsNullOrEmpty(username))
224             {
225                 Twitter.AccountState = MyCommon.ACCOUNT_STATE.Invalid;
226             }
227             this.ResetApiStatus();
228             this.Api.Initialize(token, tokenSecret, userId, username);
229         }
230
231         public void Initialize(TwitterAppToken appToken, string token, string tokenSecret, string username, long userId)
232         {
233             // OAuth認証
234             if (MyCommon.IsNullOrEmpty(token) || MyCommon.IsNullOrEmpty(tokenSecret) || MyCommon.IsNullOrEmpty(username))
235             {
236                 Twitter.AccountState = MyCommon.ACCOUNT_STATE.Invalid;
237             }
238             this.ResetApiStatus();
239             this.Api.Initialize(appToken, token, tokenSecret, userId, username);
240         }
241
242         public async Task<PostClass?> PostStatus(PostStatusParams param)
243         {
244             this.CheckAccountState();
245
246             if (Twitter.DMSendTextRegex.IsMatch(param.Text))
247             {
248                 var mediaId = param.MediaIds != null && param.MediaIds.Any() ? param.MediaIds[0] : (long?)null;
249
250                 await this.SendDirectMessage(param.Text, mediaId)
251                     .ConfigureAwait(false);
252                 return null;
253             }
254
255             TwitterStatus status;
256
257             if (this.Api.AppToken.AuthType == APIAuthType.TwitterComCookie)
258             {
259                 var request = new CreateTweetRequest
260                 {
261                     TweetText = param.Text,
262                     InReplyToTweetId = param.InReplyToStatusId?.ToTwitterStatusId(),
263                     ExcludeReplyUserIds = param.ExcludeReplyUserIds.Select(x => x.ToString()).ToArray(),
264                     MediaIds = param.MediaIds.Select(x => x.ToString()).ToArray(),
265                     AttachmentUrl = param.AttachmentUrl,
266                 };
267
268                 status = await request.Send(this.Api.Connection)
269                     .ConfigureAwait(false);
270             }
271             else
272             {
273                 var response = await this.Api.StatusesUpdate(
274                         param.Text,
275                         param.InReplyToStatusId?.ToTwitterStatusId(),
276                         param.MediaIds,
277                         param.AutoPopulateReplyMetadata,
278                         param.ExcludeReplyUserIds,
279                         param.AttachmentUrl
280                     )
281                     .ConfigureAwait(false);
282
283                 status = await response.LoadJsonAsync()
284                     .ConfigureAwait(false);
285             }
286
287             this.UpdateUserStats(status.User);
288
289             if (status.IdStr == this.previousStatusId)
290                 throw new WebApiException("OK:Delaying?");
291
292             this.previousStatusId = status.IdStr;
293
294             // 投稿したものを返す
295             var post = this.CreatePostsFromStatusData(status);
296             if (this.ReadOwnPost) post.IsRead = true;
297             return post;
298         }
299
300         public async Task DeleteTweet(TwitterStatusId tweetId)
301         {
302             if (this.Api.AppToken.AuthType == APIAuthType.TwitterComCookie)
303             {
304                 var request = new DeleteTweetRequest
305                 {
306                     TweetId = tweetId,
307                 };
308                 await request.Send(this.Api.Connection);
309             }
310             else
311             {
312                 await this.Api.StatusesDestroy(tweetId)
313                     .IgnoreResponse();
314             }
315         }
316
317         public async Task<long> UploadMedia(IMediaItem item, string? mediaCategory = null)
318         {
319             this.CheckAccountState();
320
321             var mediaType = item.Extension switch
322             {
323                 ".png" => "image/png",
324                 ".jpg" => "image/jpeg",
325                 ".jpeg" => "image/jpeg",
326                 ".gif" => "image/gif",
327                 _ => "application/octet-stream",
328             };
329
330             var initResponse = await this.Api.MediaUploadInit(item.Size, mediaType, mediaCategory)
331                 .ConfigureAwait(false);
332
333             var initMedia = await initResponse.LoadJsonAsync()
334                 .ConfigureAwait(false);
335
336             var mediaId = initMedia.MediaId;
337
338             await this.Api.MediaUploadAppend(mediaId, 0, item)
339                 .ConfigureAwait(false);
340
341             var response = await this.Api.MediaUploadFinalize(mediaId)
342                 .ConfigureAwait(false);
343
344             var media = await response.LoadJsonAsync()
345                 .ConfigureAwait(false);
346
347             while (media.ProcessingInfo is TwitterUploadMediaResult.MediaProcessingInfo processingInfo)
348             {
349                 switch (processingInfo.State)
350                 {
351                     case "pending":
352                         break;
353                     case "in_progress":
354                         break;
355                     case "succeeded":
356                         goto succeeded;
357                     case "failed":
358                         throw new WebApiException($"Err:Upload failed ({processingInfo.Error?.Name})");
359                     default:
360                         throw new WebApiException($"Err:Invalid state ({processingInfo.State})");
361                 }
362
363                 await Task.Delay(TimeSpan.FromSeconds(processingInfo.CheckAfterSecs ?? 5))
364                     .ConfigureAwait(false);
365
366                 media = await this.Api.MediaUploadStatus(mediaId)
367                     .ConfigureAwait(false);
368             }
369
370             succeeded:
371             return media.MediaId;
372         }
373
374         public async Task SendDirectMessage(string postStr, long? mediaId = null)
375         {
376             this.CheckAccountState();
377             this.CheckAccessLevel(TwitterApiAccessLevel.ReadWriteAndDirectMessage);
378
379             var mc = Twitter.DMSendTextRegex.Match(postStr);
380
381             var body = mc.Groups["body"].Value;
382             var recipientName = mc.Groups["id"].Value;
383
384             var recipient = await this.GetUserInfo(recipientName)
385                 .ConfigureAwait(false);
386
387             var response = await this.Api.DirectMessagesEventsNew(recipient.Id, body, mediaId)
388                 .ConfigureAwait(false);
389
390             var messageEventSingle = await response.LoadJsonAsync()
391                 .ConfigureAwait(false);
392
393             await this.CreateDirectMessagesEventFromJson(messageEventSingle, read: true)
394                 .ConfigureAwait(false);
395         }
396
397         public async Task<PostClass?> PostRetweet(PostId id, bool read)
398         {
399             this.CheckAccountState();
400
401             // データ部分の生成
402             var post = TabInformations.GetInstance()[id];
403             if (post == null)
404                 throw new WebApiException("Err:Target isn't found.");
405
406             var target = post.RetweetedId ?? id;  // 再RTの場合は元発言をRT
407
408             if (this.Api.AppToken.AuthType == APIAuthType.TwitterComCookie)
409             {
410                 var request = new CreateRetweetRequest
411                 {
412                     TweetId = target.ToTwitterStatusId(),
413                 };
414                 await request.Send(this.Api.Connection).ConfigureAwait(false);
415                 return null;
416             }
417
418             var response = await this.Api.StatusesRetweet(target.ToTwitterStatusId())
419                 .ConfigureAwait(false);
420
421             var status = await response.LoadJsonAsync()
422                 .ConfigureAwait(false);
423
424             // 二重取得回避
425             lock (this.lockObj)
426             {
427                 var statusId = new TwitterStatusId(status.IdStr);
428                 if (TabInformations.GetInstance().ContainsKey(statusId))
429                     return null;
430             }
431
432             // Retweet判定
433             if (status.RetweetedStatus == null)
434                 throw new WebApiException("Invalid Json!");
435
436             // Retweetしたものを返す
437             return this.CreatePostsFromStatusData(status) with
438             {
439                 IsMe = true,
440                 IsRead = this.ReadOwnPost ? true : read,
441                 IsOwl = false,
442             };
443         }
444
445         public async Task DeleteRetweet(PostClass post)
446         {
447             if (post.RetweetedId == null)
448                 throw new ArgumentException("post is not retweeted status", nameof(post));
449
450             if (this.Api.AppToken.AuthType == APIAuthType.TwitterComCookie)
451             {
452                 var request = new DeleteRetweetRequest
453                 {
454                     SourceTweetId = post.RetweetedId.ToTwitterStatusId(),
455                 };
456                 await request.Send(this.Api.Connection).ConfigureAwait(false);
457             }
458             else
459             {
460                 await this.Api.StatusesDestroy(post.StatusId.ToTwitterStatusId())
461                     .IgnoreResponse();
462             }
463         }
464
465         public async Task<TwitterUser> GetUserInfo(string screenName)
466         {
467             if (this.Api.AppToken.AuthType == APIAuthType.TwitterComCookie)
468             {
469                 var request = new UserByScreenNameRequest
470                 {
471                     ScreenName = screenName,
472                 };
473                 var response = await request.Send(this.Api.Connection)
474                     .ConfigureAwait(false);
475
476                 return response.ToTwitterUser();
477             }
478             else
479             {
480                 var user = await this.Api.UsersShow(screenName)
481                     .ConfigureAwait(false);
482
483                 return user;
484             }
485         }
486
487         public string Username
488             => this.Api.CurrentScreenName;
489
490         public long UserId
491             => this.Api.CurrentUserId;
492
493         public static MyCommon.ACCOUNT_STATE AccountState { get; set; } = MyCommon.ACCOUNT_STATE.Valid;
494
495         public bool RestrictFavCheck { get; set; }
496
497         public bool ReadOwnPost { get; set; }
498
499         public int FollowersCount { get; private set; }
500
501         public int FriendsCount { get; private set; }
502
503         public int StatusesCount { get; private set; }
504
505         public string Location { get; private set; } = "";
506
507         public string Bio { get; private set; } = "";
508
509         /// <summary>ユーザーのフォロワー数などの情報を更新します</summary>
510         private void UpdateUserStats(TwitterUser self)
511         {
512             this.FollowersCount = self.FollowersCount;
513             this.FriendsCount = self.FriendsCount;
514             this.StatusesCount = self.StatusesCount;
515             this.Location = self.Location ?? "";
516             this.Bio = self.Description ?? "";
517         }
518
519         /// <summary>
520         /// 渡された取得件数がWORKERTYPEに応じた取得可能範囲に収まっているか検証する
521         /// </summary>
522         public static bool VerifyApiResultCount(MyCommon.WORKERTYPE type, int count)
523             => count >= 20 && count <= GetMaxApiResultCount(type);
524
525         /// <summary>
526         /// 渡された取得件数が更新時の取得可能範囲に収まっているか検証する
527         /// </summary>
528         public static bool VerifyMoreApiResultCount(int count)
529             => count >= 20 && count <= 200;
530
531         /// <summary>
532         /// 渡された取得件数が起動時の取得可能範囲に収まっているか検証する
533         /// </summary>
534         public static bool VerifyFirstApiResultCount(int count)
535             => count >= 20 && count <= 200;
536
537         /// <summary>
538         /// WORKERTYPEに応じた取得可能な最大件数を取得する
539         /// </summary>
540         public static int GetMaxApiResultCount(MyCommon.WORKERTYPE type)
541         {
542             // 参照: REST APIs - 各endpointのcountパラメータ
543             // https://dev.twitter.com/rest/public
544             return type switch
545             {
546                 MyCommon.WORKERTYPE.Timeline => 100,
547                 MyCommon.WORKERTYPE.Reply => 200,
548                 MyCommon.WORKERTYPE.UserTimeline => 200,
549                 MyCommon.WORKERTYPE.Favorites => 200,
550                 MyCommon.WORKERTYPE.List => 200, // 不明
551                 MyCommon.WORKERTYPE.PublicSearch => 100,
552                 _ => throw new InvalidOperationException("Invalid type: " + type),
553             };
554         }
555
556         /// <summary>
557         /// WORKERTYPEに応じた取得件数を取得する
558         /// </summary>
559         public static int GetApiResultCount(MyCommon.WORKERTYPE type, bool more, bool startup)
560         {
561             if (SettingManager.Instance.Common.UseAdditionalCount)
562             {
563                 switch (type)
564                 {
565                     case MyCommon.WORKERTYPE.Favorites:
566                         if (SettingManager.Instance.Common.FavoritesCountApi != 0)
567                             return SettingManager.Instance.Common.FavoritesCountApi;
568                         break;
569                     case MyCommon.WORKERTYPE.List:
570                         if (SettingManager.Instance.Common.ListCountApi != 0)
571                             return SettingManager.Instance.Common.ListCountApi;
572                         break;
573                     case MyCommon.WORKERTYPE.PublicSearch:
574                         if (SettingManager.Instance.Common.SearchCountApi != 0)
575                             return SettingManager.Instance.Common.SearchCountApi;
576                         break;
577                     case MyCommon.WORKERTYPE.UserTimeline:
578                         if (SettingManager.Instance.Common.UserTimelineCountApi != 0)
579                             return SettingManager.Instance.Common.UserTimelineCountApi;
580                         break;
581                 }
582                 if (more && SettingManager.Instance.Common.MoreCountApi != 0)
583                 {
584                     return Math.Min(SettingManager.Instance.Common.MoreCountApi, GetMaxApiResultCount(type));
585                 }
586                 if (startup && SettingManager.Instance.Common.FirstCountApi != 0 && type != MyCommon.WORKERTYPE.Reply)
587                 {
588                     return Math.Min(SettingManager.Instance.Common.FirstCountApi, GetMaxApiResultCount(type));
589                 }
590             }
591
592             // 上記に当てはまらない場合の共通処理
593             var count = SettingManager.Instance.Common.CountApi;
594
595             if (type == MyCommon.WORKERTYPE.Reply)
596                 count = SettingManager.Instance.Common.CountApiReply;
597
598             return Math.Min(count, GetMaxApiResultCount(type));
599         }
600
601         public async Task GetHomeTimelineApi(bool read, HomeTabModel tab, bool more, bool startup)
602         {
603             this.CheckAccountState();
604
605             var count = GetApiResultCount(MyCommon.WORKERTYPE.Timeline, more, startup);
606
607             TwitterStatus[] statuses;
608             if (SettingManager.Instance.Common.EnableTwitterV2Api)
609             {
610                 var request = new GetTimelineRequest(this.UserId)
611                 {
612                     MaxResults = count,
613                     UntilId = more ? tab.OldestId as TwitterStatusId : null,
614                 };
615
616                 var response = await request.Send(this.Api.Connection)
617                     .ConfigureAwait(false);
618
619                 if (response.Data == null || response.Data.Length == 0)
620                     return;
621
622                 var tweetIds = response.Data.Select(x => x.Id).ToList();
623
624                 statuses = await this.Api.StatusesLookup(tweetIds)
625                     .ConfigureAwait(false);
626             }
627             else
628             {
629                 var maxId = more ? tab.OldestId : null;
630
631                 statuses = await this.Api.StatusesHomeTimeline(count, maxId as TwitterStatusId)
632                     .ConfigureAwait(false);
633             }
634
635             var minimumId = this.CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.Timeline, tab, read);
636             if (minimumId != null)
637                 tab.OldestId = minimumId;
638         }
639
640         public async Task GetMentionsTimelineApi(bool read, MentionsTabModel tab, bool more, bool startup)
641         {
642             this.CheckAccountState();
643
644             var count = GetApiResultCount(MyCommon.WORKERTYPE.Reply, more, startup);
645
646             TwitterStatus[] statuses;
647             if (more)
648             {
649                 statuses = await this.Api.StatusesMentionsTimeline(count, maxId: tab.OldestId as TwitterStatusId)
650                     .ConfigureAwait(false);
651             }
652             else
653             {
654                 statuses = await this.Api.StatusesMentionsTimeline(count)
655                     .ConfigureAwait(false);
656             }
657
658             var minimumId = this.CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.Reply, tab, read);
659             if (minimumId != null)
660                 tab.OldestId = minimumId;
661         }
662
663         public async Task GetUserTimelineApi(bool read, UserTimelineTabModel tab, bool more)
664         {
665             this.CheckAccountState();
666
667             var count = GetApiResultCount(MyCommon.WORKERTYPE.UserTimeline, more, false);
668
669             TwitterStatus[] statuses;
670             if (this.Api.AppToken.AuthType == APIAuthType.TwitterComCookie)
671             {
672                 var userId = tab.UserId;
673                 if (MyCommon.IsNullOrEmpty(userId))
674                 {
675                     var user = await this.GetUserInfo(tab.ScreenName)
676                         .ConfigureAwait(false);
677
678                     userId = user.IdStr;
679                     tab.UserId = user.IdStr;
680                 }
681
682                 var request = new UserTweetsRequest(userId)
683                 {
684                     Count = count,
685                     Cursor = more ? tab.CursorBottom : null,
686                 };
687                 var response = await request.Send(this.Api.Connection)
688                     .ConfigureAwait(false);
689
690                 statuses = response.Tweets
691                     .Where(x => !x.IsTombstone)
692                     .Select(x => x.ToTwitterStatus())
693                     .ToArray();
694
695                 tab.CursorBottom = response.CursorBottom;
696             }
697             else
698             {
699                 if (more)
700                 {
701                     statuses = await this.Api.StatusesUserTimeline(tab.ScreenName, count, maxId: tab.OldestId as TwitterStatusId)
702                         .ConfigureAwait(false);
703                 }
704                 else
705                 {
706                     statuses = await this.Api.StatusesUserTimeline(tab.ScreenName, count)
707                         .ConfigureAwait(false);
708                 }
709             }
710
711             var minimumId = this.CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.UserTimeline, tab, read);
712
713             if (minimumId != null)
714                 tab.OldestId = minimumId;
715         }
716
717         public async Task<PostClass> GetStatusApi(bool read, TwitterStatusId id)
718         {
719             this.CheckAccountState();
720
721             TwitterStatus status;
722             if (this.Api.AppToken.AuthType == APIAuthType.TwitterComCookie)
723             {
724                 var request = new TweetDetailRequest
725                 {
726                     FocalTweetId = id,
727                 };
728                 var tweets = await request.Send(this.Api.Connection).ConfigureAwait(false);
729                 status = tweets.Select(x => x.ToTwitterStatus())
730                     .Where(x => x.IdStr == id.Id)
731                     .FirstOrDefault() ?? throw new WebApiException("Empty result set");
732             }
733             else
734             {
735                 status = await this.Api.StatusesShow(id)
736                     .ConfigureAwait(false);
737             }
738
739             var item = this.CreatePostsFromStatusData(status);
740
741             item.IsRead = read;
742             if (item.IsMe && !read && this.ReadOwnPost) item.IsRead = true;
743
744             return item;
745         }
746
747         public async Task GetStatusApi(bool read, TwitterStatusId id, TabModel tab)
748         {
749             var post = await this.GetStatusApi(read, id)
750                 .ConfigureAwait(false);
751
752             // 非同期アイコン取得&StatusDictionaryに追加
753             if (tab != null && tab.IsInnerStorageTabType)
754                 tab.AddPostQueue(post);
755             else
756                 TabInformations.GetInstance().AddPost(post);
757         }
758
759         private PostClass CreatePostsFromStatusData(TwitterStatus status)
760             => this.CreatePostsFromStatusData(status, favTweet: false);
761
762         private PostClass CreatePostsFromStatusData(TwitterStatus status, bool favTweet)
763             => this.postFactory.CreateFromStatus(status, this.UserId, this.followerId, favTweet);
764
765         private PostId? CreatePostsFromJson(TwitterStatus[] items, MyCommon.WORKERTYPE gType, TabModel? tab, bool read)
766         {
767             PostId? minimumId = null;
768
769             var posts = items.Select(x => this.CreatePostsFromStatusData(x)).ToArray();
770
771             TwitterPostFactory.AdjustSortKeyForPromotedPost(posts);
772
773             foreach (var post in posts)
774             {
775                 if (!post.IsPromoted)
776                 {
777                     if (minimumId == null || minimumId > post.StatusId)
778                         minimumId = post.StatusId;
779                 }
780
781                 // 二重取得回避
782                 lock (this.lockObj)
783                 {
784                     var id = post.StatusId;
785                     if (tab == null)
786                     {
787                         if (TabInformations.GetInstance().ContainsKey(id)) continue;
788                     }
789                     else
790                     {
791                         if (tab.Contains(id)) continue;
792                     }
793                 }
794
795                 // RT禁止ユーザーによるもの
796                 if (gType != MyCommon.WORKERTYPE.UserTimeline &&
797                     post.RetweetedByUserId != null && this.noRTId.Contains(post.RetweetedByUserId.Value)) continue;
798
799                 post.IsRead = read;
800                 if (post.IsMe && !read && this.ReadOwnPost) post.IsRead = true;
801
802                 if (tab != null && tab.IsInnerStorageTabType)
803                     tab.AddPostQueue(post);
804                 else
805                     TabInformations.GetInstance().AddPost(post);
806             }
807
808             return minimumId;
809         }
810
811         private PostId? CreatePostsFromSearchJson(TwitterStatus[] statuses, PublicSearchTabModel tab, bool read, bool more)
812         {
813             PostId? minimumId = null;
814
815             var posts = statuses.Select(x => this.CreatePostsFromStatusData(x)).ToArray();
816
817             TwitterPostFactory.AdjustSortKeyForPromotedPost(posts);
818
819             foreach (var post in posts)
820             {
821                 if (!post.IsPromoted)
822                 {
823                     if (minimumId == null || minimumId > post.StatusId)
824                         minimumId = post.StatusId;
825
826                     if (!more && (tab.SinceId == null || post.StatusId > tab.SinceId))
827                         tab.SinceId = post.StatusId;
828                 }
829
830                 // 二重取得回避
831                 lock (this.lockObj)
832                 {
833                     if (tab.Contains(post.StatusId))
834                         continue;
835                 }
836
837                 post.IsRead = read;
838                 if ((post.IsMe && !read) && this.ReadOwnPost) post.IsRead = true;
839
840                 tab.AddPostQueue(post);
841             }
842
843             return minimumId;
844         }
845
846         private long? CreateFavoritePostsFromJson(TwitterStatus[] items, bool read)
847         {
848             var favTab = TabInformations.GetInstance().FavoriteTab;
849             long? minimumId = null;
850
851             foreach (var status in items)
852             {
853                 if (minimumId == null || minimumId.Value > status.Id)
854                     minimumId = status.Id;
855
856                 // 二重取得回避
857                 lock (this.lockObj)
858                 {
859                     if (favTab.Contains(new TwitterStatusId(status.IdStr)))
860                         continue;
861                 }
862
863                 var post = this.CreatePostsFromStatusData(status, true);
864
865                 post.IsRead = read;
866
867                 TabInformations.GetInstance().AddPost(post);
868             }
869
870             return minimumId;
871         }
872
873         public async Task GetListStatus(bool read, ListTimelineTabModel tab, bool more, bool startup)
874         {
875             var count = GetApiResultCount(MyCommon.WORKERTYPE.List, more, startup);
876
877             TwitterStatus[] statuses;
878             if (this.Api.AppToken.AuthType == APIAuthType.TwitterComCookie)
879             {
880                 var request = new ListLatestTweetsTimelineRequest(tab.ListInfo.Id.ToString())
881                 {
882                     Count = count,
883                     Cursor = more ? tab.CursorBottom : null,
884                 };
885                 var response = await request.Send(this.Api.Connection)
886                     .ConfigureAwait(false);
887
888                 var convertedStatuses = response.Tweets
889                     .Where(x => !x.IsTombstone)
890                     .Select(x => x.ToTwitterStatus());
891
892                 if (!SettingManager.Instance.Common.IsListsIncludeRts)
893                     convertedStatuses = convertedStatuses.Where(x => x.RetweetedStatus == null);
894
895                 statuses = convertedStatuses.ToArray();
896                 tab.CursorBottom = response.CursorBottom;
897             }
898             else if (more)
899             {
900                 statuses = await this.Api.ListsStatuses(tab.ListInfo.Id, count, maxId: tab.OldestId as TwitterStatusId, includeRTs: SettingManager.Instance.Common.IsListsIncludeRts)
901                     .ConfigureAwait(false);
902             }
903             else
904             {
905                 statuses = await this.Api.ListsStatuses(tab.ListInfo.Id, count, includeRTs: SettingManager.Instance.Common.IsListsIncludeRts)
906                     .ConfigureAwait(false);
907             }
908
909             var minimumId = this.CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.List, tab, read);
910
911             if (minimumId != null)
912                 tab.OldestId = minimumId;
913         }
914
915         /// <summary>
916         /// startStatusId からリプライ先の発言を辿る。発言は posts 以外からは検索しない。
917         /// </summary>
918         /// <returns>posts の中から検索されたリプライチェインの末端</returns>
919         internal static PostClass FindTopOfReplyChain(IDictionary<PostId, PostClass> posts, PostId startStatusId)
920         {
921             if (!posts.ContainsKey(startStatusId))
922                 throw new ArgumentException("startStatusId (" + startStatusId.Id + ") が posts の中から見つかりませんでした。", nameof(startStatusId));
923
924             var nextPost = posts[startStatusId];
925             while (nextPost.InReplyToStatusId != null)
926             {
927                 if (!posts.ContainsKey(nextPost.InReplyToStatusId))
928                     break;
929                 nextPost = posts[nextPost.InReplyToStatusId];
930             }
931
932             return nextPost;
933         }
934
935         public async Task GetRelatedResult(bool read, RelatedPostsTabModel tab)
936         {
937             var targetPost = tab.TargetPost;
938
939             if (targetPost.RetweetedId != null)
940             {
941                 var originalPost = targetPost with
942                 {
943                     StatusId = targetPost.RetweetedId,
944                     RetweetedId = null,
945                     RetweetedBy = null,
946                 };
947                 targetPost = originalPost;
948             }
949
950             var relPosts = new Dictionary<PostId, PostClass>();
951             if (targetPost.TextFromApi.Contains("@") && targetPost.InReplyToStatusId == null)
952             {
953                 // 検索結果対応
954                 var p = TabInformations.GetInstance()[targetPost.StatusId];
955                 if (p != null && p.InReplyToStatusId != null)
956                 {
957                     targetPost = p;
958                 }
959                 else
960                 {
961                     p = await this.GetStatusApi(read, targetPost.StatusId.ToTwitterStatusId())
962                         .ConfigureAwait(false);
963                     targetPost = p;
964                 }
965             }
966             relPosts.Add(targetPost.StatusId, targetPost);
967
968             Exception? lastException = null;
969
970             // in_reply_to_status_id を使用してリプライチェインを辿る
971             var nextPost = FindTopOfReplyChain(relPosts, targetPost.StatusId);
972             var loopCount = 1;
973             while (nextPost.InReplyToStatusId != null && loopCount++ <= 20)
974             {
975                 var inReplyToId = nextPost.InReplyToStatusId;
976
977                 var inReplyToPost = TabInformations.GetInstance()[inReplyToId];
978                 if (inReplyToPost == null)
979                 {
980                     try
981                     {
982                         inReplyToPost = await this.GetStatusApi(read, inReplyToId.ToTwitterStatusId())
983                             .ConfigureAwait(false);
984                     }
985                     catch (WebApiException ex)
986                     {
987                         lastException = ex;
988                         break;
989                     }
990                 }
991
992                 relPosts.Add(inReplyToPost.StatusId, inReplyToPost);
993
994                 nextPost = FindTopOfReplyChain(relPosts, nextPost.StatusId);
995             }
996
997             // MRTとかに対応のためツイート内にあるツイートを指すURLを取り込む
998             var text = targetPost.Text;
999             var ma = Twitter.StatusUrlRegex.Matches(text).Cast<Match>()
1000                 .Concat(Twitter.ThirdPartyStatusUrlRegex.Matches(text).Cast<Match>());
1001             foreach (var match in ma)
1002             {
1003                 var statusId = new TwitterStatusId(match.Groups["StatusId"].Value);
1004                 if (!relPosts.ContainsKey(statusId))
1005                 {
1006                     var p = TabInformations.GetInstance()[statusId];
1007                     if (p == null)
1008                     {
1009                         try
1010                         {
1011                             p = await this.GetStatusApi(read, statusId)
1012                                 .ConfigureAwait(false);
1013                         }
1014                         catch (WebApiException ex)
1015                         {
1016                             lastException = ex;
1017                             break;
1018                         }
1019                     }
1020
1021                     if (p != null)
1022                         relPosts.Add(p.StatusId, p);
1023                 }
1024             }
1025
1026             try
1027             {
1028                 var firstPost = nextPost;
1029                 var posts = await this.GetConversationPosts(firstPost, targetPost)
1030                     .ConfigureAwait(false);
1031
1032                 foreach (var post in posts.OrderBy(x => x.StatusId))
1033                 {
1034                     if (relPosts.ContainsKey(post.StatusId))
1035                         continue;
1036
1037                     // リプライチェーンが繋がらないツイートは除外
1038                     if (post.InReplyToStatusId == null || !relPosts.ContainsKey(post.InReplyToStatusId))
1039                         continue;
1040
1041                     relPosts.Add(post.StatusId, post);
1042                 }
1043             }
1044             catch (WebException ex)
1045             {
1046                 lastException = ex;
1047             }
1048
1049             relPosts.Values.ToList().ForEach(p =>
1050             {
1051                 var post = p with { };
1052                 if (post.IsMe && !read && this.ReadOwnPost)
1053                     post.IsRead = true;
1054                 else
1055                     post.IsRead = read;
1056
1057                 tab.AddPostQueue(post);
1058             });
1059
1060             if (lastException != null)
1061                 throw new WebApiException(lastException.Message, lastException);
1062         }
1063
1064         private async Task<PostClass[]> GetConversationPosts(PostClass firstPost, PostClass targetPost)
1065         {
1066             var conversationId = firstPost.StatusId;
1067             var query = $"conversation_id:{conversationId.Id}";
1068
1069             if (targetPost.InReplyToUser != null && targetPost.InReplyToUser != targetPost.ScreenName)
1070                 query += $" (from:{targetPost.ScreenName} to:{targetPost.InReplyToUser}) OR (from:{targetPost.InReplyToUser} to:{targetPost.ScreenName})";
1071             else
1072                 query += $" from:{targetPost.ScreenName} to:{targetPost.ScreenName}";
1073
1074             var statuses = await this.Api.SearchTweets(query, count: 100)
1075                 .ConfigureAwait(false);
1076
1077             return statuses.Statuses.Select(x => this.CreatePostsFromStatusData(x)).ToArray();
1078         }
1079
1080         public async Task GetSearch(bool read, PublicSearchTabModel tab, bool more)
1081         {
1082             var count = GetApiResultCount(MyCommon.WORKERTYPE.PublicSearch, more, false);
1083
1084             TwitterStatus[] statuses;
1085             if (this.Api.AppToken.AuthType == APIAuthType.TwitterComCookie)
1086             {
1087                 var request = new SearchTimelineRequest(tab.SearchWords)
1088                 {
1089                     Count = count,
1090                     Cursor = more ? tab.CursorBottom : null,
1091                 };
1092                 var response = await request.Send(this.Api.Connection)
1093                     .ConfigureAwait(false);
1094
1095                 statuses = response.Tweets
1096                     .Where(x => !x.IsTombstone)
1097                     .Select(x => x.ToTwitterStatus())
1098                     .ToArray();
1099
1100                 tab.CursorBottom = response.CursorBottom;
1101             }
1102             else
1103             {
1104                 TwitterStatusId? maxId = null;
1105                 TwitterStatusId? sinceId = null;
1106                 if (more)
1107                 {
1108                     maxId = tab.OldestId as TwitterStatusId;
1109                 }
1110                 else
1111                 {
1112                     sinceId = tab.SinceId as TwitterStatusId;
1113                 }
1114
1115                 var searchResult = await this.Api.SearchTweets(tab.SearchWords, tab.SearchLang, count, maxId, sinceId)
1116                     .ConfigureAwait(false);
1117
1118                 statuses = searchResult.Statuses;
1119             }
1120
1121             if (!TabInformations.GetInstance().ContainsTab(tab))
1122                 return;
1123
1124             var minimumId = this.CreatePostsFromSearchJson(statuses, tab, read, more);
1125
1126             if (minimumId != null)
1127                 tab.OldestId = minimumId;
1128         }
1129
1130         public async Task GetDirectMessageEvents(bool read, DirectMessagesTabModel dmTab, bool backward)
1131         {
1132             this.CheckAccountState();
1133             this.CheckAccessLevel(TwitterApiAccessLevel.ReadWriteAndDirectMessage);
1134
1135             var count = 50;
1136
1137             TwitterMessageEventList eventList;
1138             if (backward)
1139             {
1140                 eventList = await this.Api.DirectMessagesEventsList(count, dmTab.NextCursor)
1141                     .ConfigureAwait(false);
1142             }
1143             else
1144             {
1145                 eventList = await this.Api.DirectMessagesEventsList(count)
1146                     .ConfigureAwait(false);
1147             }
1148
1149             dmTab.NextCursor = eventList.NextCursor;
1150
1151             await this.CreateDirectMessagesEventFromJson(eventList, read)
1152                 .ConfigureAwait(false);
1153         }
1154
1155         private async Task CreateDirectMessagesEventFromJson(TwitterMessageEventSingle eventSingle, bool read)
1156         {
1157             var eventList = new TwitterMessageEventList
1158             {
1159                 Apps = new Dictionary<string, TwitterMessageEventList.App>(),
1160                 Events = new[] { eventSingle.Event },
1161             };
1162
1163             await this.CreateDirectMessagesEventFromJson(eventList, read)
1164                 .ConfigureAwait(false);
1165         }
1166
1167         private async Task CreateDirectMessagesEventFromJson(TwitterMessageEventList eventList, bool read)
1168         {
1169             var events = eventList.Events
1170                 .Where(x => x.Type == "message_create")
1171                 .ToArray();
1172
1173             if (events.Length == 0)
1174                 return;
1175
1176             var userIds = Enumerable.Concat(
1177                 events.Select(x => x.MessageCreate.SenderId),
1178                 events.Select(x => x.MessageCreate.Target.RecipientId)
1179             ).Distinct().ToArray();
1180
1181             var users = (await this.Api.UsersLookup(userIds).ConfigureAwait(false))
1182                 .ToDictionary(x => x.IdStr);
1183
1184             var apps = eventList.Apps ?? new Dictionary<string, TwitterMessageEventList.App>();
1185
1186             this.CreateDirectMessagesEventFromJson(events, users, apps, read);
1187         }
1188
1189         private void CreateDirectMessagesEventFromJson(
1190             IEnumerable<TwitterMessageEvent> events,
1191             IReadOnlyDictionary<string, TwitterUser> users,
1192             IReadOnlyDictionary<string, TwitterMessageEventList.App> apps,
1193             bool read)
1194         {
1195             var dmTab = TabInformations.GetInstance().DirectMessageTab;
1196
1197             foreach (var eventItem in events)
1198             {
1199                 var post = this.postFactory.CreateFromDirectMessageEvent(eventItem, users, apps, this.UserId);
1200
1201                 post.IsRead = read;
1202                 if (post.IsMe && !read && this.ReadOwnPost)
1203                     post.IsRead = true;
1204
1205                 dmTab.AddPostQueue(post);
1206             }
1207         }
1208
1209         public async Task GetFavoritesApi(bool read, FavoritesTabModel tab, bool backward)
1210         {
1211             this.CheckAccountState();
1212
1213             var count = GetApiResultCount(MyCommon.WORKERTYPE.Favorites, backward, false);
1214
1215             TwitterStatus[] statuses;
1216             if (backward)
1217             {
1218                 statuses = await this.Api.FavoritesList(count, maxId: tab.OldestId)
1219                     .ConfigureAwait(false);
1220             }
1221             else
1222             {
1223                 statuses = await this.Api.FavoritesList(count)
1224                     .ConfigureAwait(false);
1225             }
1226
1227             var minimumId = this.CreateFavoritePostsFromJson(statuses, read);
1228
1229             if (minimumId != null)
1230                 tab.OldestId = minimumId.Value;
1231         }
1232
1233         /// <summary>
1234         /// フォロワーIDを更新します
1235         /// </summary>
1236         /// <exception cref="WebApiException"/>
1237         public async Task RefreshFollowerIds()
1238         {
1239             if (MyCommon.EndingFlag) return;
1240
1241             var cursor = -1L;
1242             var newFollowerIds = Enumerable.Empty<long>();
1243             do
1244             {
1245                 var ret = await this.Api.FollowersIds(cursor)
1246                     .ConfigureAwait(false);
1247
1248                 if (ret.Ids == null)
1249                     throw new WebApiException("ret.ids == null");
1250
1251                 newFollowerIds = newFollowerIds.Concat(ret.Ids);
1252                 cursor = ret.NextCursor;
1253             }
1254             while (cursor != 0);
1255
1256             this.followerId = newFollowerIds.ToHashSet();
1257             TabInformations.GetInstance().RefreshOwl(this.followerId);
1258
1259             this.GetFollowersSuccess = true;
1260         }
1261
1262         /// <summary>
1263         /// RT 非表示ユーザーを更新します
1264         /// </summary>
1265         /// <exception cref="WebApiException"/>
1266         public async Task RefreshNoRetweetIds()
1267         {
1268             if (MyCommon.EndingFlag) return;
1269
1270             this.noRTId = await this.Api.NoRetweetIds()
1271                 .ConfigureAwait(false);
1272
1273             this.GetNoRetweetSuccess = true;
1274         }
1275
1276         /// <summary>
1277         /// t.co の文字列長などの設定情報を更新します
1278         /// </summary>
1279         /// <exception cref="WebApiException"/>
1280         public async Task RefreshConfiguration()
1281         {
1282             this.Configuration = await this.Api.Configuration()
1283                 .ConfigureAwait(false);
1284
1285             // TextConfiguration 相当の JSON を得る API が存在しないため、TransformedURLLength のみ help/configuration.json に合わせて更新する
1286             this.TextConfiguration.TransformedURLLength = this.Configuration.ShortUrlLengthHttps;
1287         }
1288
1289         public async Task GetListsApi()
1290         {
1291             this.CheckAccountState();
1292
1293             var ownedLists = await TwitterLists.GetAllItemsAsync(x =>
1294                 this.Api.ListsOwnerships(this.Username, cursor: x, count: 1000))
1295                     .ConfigureAwait(false);
1296
1297             var subscribedLists = await TwitterLists.GetAllItemsAsync(x =>
1298                 this.Api.ListsSubscriptions(this.Username, cursor: x, count: 1000))
1299                     .ConfigureAwait(false);
1300
1301             TabInformations.GetInstance().SubscribableLists = Enumerable.Concat(ownedLists, subscribedLists)
1302                 .Select(x => new ListElement(x, this))
1303                 .ToList();
1304         }
1305
1306         public async Task DeleteList(long listId)
1307         {
1308             await this.Api.ListsDestroy(listId)
1309                 .IgnoreResponse()
1310                 .ConfigureAwait(false);
1311
1312             var tabinfo = TabInformations.GetInstance();
1313
1314             tabinfo.SubscribableLists = tabinfo.SubscribableLists
1315                 .Where(x => x.Id != listId)
1316                 .ToList();
1317         }
1318
1319         public async Task<ListElement> EditList(long listId, string new_name, bool isPrivate, string description)
1320         {
1321             var response = await this.Api.ListsUpdate(listId, new_name, description, isPrivate)
1322                 .ConfigureAwait(false);
1323
1324             var list = await response.LoadJsonAsync()
1325                 .ConfigureAwait(false);
1326
1327             return new ListElement(list, this);
1328         }
1329
1330         public async Task<long> GetListMembers(long listId, List<UserInfo> lists, long cursor)
1331         {
1332             this.CheckAccountState();
1333
1334             var users = await this.Api.ListsMembers(listId, cursor)
1335                 .ConfigureAwait(false);
1336
1337             Array.ForEach(users.Users, u => lists.Add(new UserInfo(u)));
1338
1339             return users.NextCursor;
1340         }
1341
1342         public async Task CreateListApi(string listName, bool isPrivate, string description)
1343         {
1344             this.CheckAccountState();
1345
1346             var response = await this.Api.ListsCreate(listName, description, isPrivate)
1347                 .ConfigureAwait(false);
1348
1349             var list = await response.LoadJsonAsync()
1350                 .ConfigureAwait(false);
1351
1352             TabInformations.GetInstance().SubscribableLists.Add(new ListElement(list, this));
1353         }
1354
1355         public async Task<bool> ContainsUserAtList(long listId, string user)
1356         {
1357             this.CheckAccountState();
1358
1359             try
1360             {
1361                 await this.Api.ListsMembersShow(listId, user)
1362                     .ConfigureAwait(false);
1363
1364                 return true;
1365             }
1366             catch (TwitterApiException ex)
1367                 when (ex.Errors.Any(x => x.Code == TwitterErrorCode.NotFound))
1368             {
1369                 return false;
1370             }
1371         }
1372
1373         public async Task<TwitterApiStatus?> GetInfoApi()
1374         {
1375             if (Twitter.AccountState != MyCommon.ACCOUNT_STATE.Valid) return null;
1376
1377             if (MyCommon.EndingFlag) return null;
1378
1379             var limits = await this.Api.ApplicationRateLimitStatus()
1380                 .ConfigureAwait(false);
1381
1382             MyCommon.TwitterApiInfo.UpdateFromJson(limits);
1383
1384             return MyCommon.TwitterApiInfo;
1385         }
1386
1387         /// <summary>
1388         /// ブロック中のユーザーを更新します
1389         /// </summary>
1390         /// <exception cref="WebApiException"/>
1391         public async Task RefreshBlockIds()
1392         {
1393             if (MyCommon.EndingFlag) return;
1394
1395             var cursor = -1L;
1396             var newBlockIds = Enumerable.Empty<long>();
1397             do
1398             {
1399                 var ret = await this.Api.BlocksIds(cursor)
1400                     .ConfigureAwait(false);
1401
1402                 newBlockIds = newBlockIds.Concat(ret.Ids);
1403                 cursor = ret.NextCursor;
1404             }
1405             while (cursor != 0);
1406
1407             var blockIdsSet = newBlockIds.ToHashSet();
1408             blockIdsSet.Remove(this.UserId); // 元のソースにあったので一応残しておく
1409
1410             TabInformations.GetInstance().BlockIds = blockIdsSet;
1411         }
1412
1413         /// <summary>
1414         /// ミュート中のユーザーIDを更新します
1415         /// </summary>
1416         /// <exception cref="WebApiException"/>
1417         public async Task RefreshMuteUserIdsAsync()
1418         {
1419             if (MyCommon.EndingFlag) return;
1420
1421             var ids = await TwitterIds.GetAllItemsAsync(x => this.Api.MutesUsersIds(x))
1422                 .ConfigureAwait(false);
1423
1424             TabInformations.GetInstance().MuteUserIds = ids.ToHashSet();
1425         }
1426
1427         public string[] GetHashList()
1428             => this.postFactory.GetReceivedHashtags();
1429
1430         public string AccessToken
1431             => ((TwitterApiConnection)this.Api.Connection).AccessToken;
1432
1433         public string AccessTokenSecret
1434             => ((TwitterApiConnection)this.Api.Connection).AccessSecret;
1435
1436         private void CheckAccountState()
1437         {
1438             if (Twitter.AccountState != MyCommon.ACCOUNT_STATE.Valid)
1439                 throw new WebApiException("Auth error. Check your account");
1440         }
1441
1442         private void CheckAccessLevel(TwitterApiAccessLevel accessLevelFlags)
1443         {
1444             if (!this.AccessLevel.HasFlag(accessLevelFlags))
1445                 throw new WebApiException("Auth Err:try to re-authorization.");
1446         }
1447
1448         public int GetTextLengthRemain(string postText)
1449         {
1450             var matchDm = Twitter.DMSendTextRegex.Match(postText);
1451             if (matchDm.Success)
1452                 return this.GetTextLengthRemainDM(matchDm.Groups["body"].Value);
1453
1454             return this.GetTextLengthRemainWeighted(postText);
1455         }
1456
1457         private int GetTextLengthRemainDM(string postText)
1458         {
1459             var textLength = 0;
1460
1461             var pos = 0;
1462             while (pos < postText.Length)
1463             {
1464                 textLength++;
1465
1466                 if (char.IsSurrogatePair(postText, pos))
1467                     pos += 2; // サロゲートペアの場合は2文字分進める
1468                 else
1469                     pos++;
1470             }
1471
1472             var urls = TweetExtractor.ExtractUrls(postText);
1473             foreach (var url in urls)
1474             {
1475                 var shortUrlLength = url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
1476                     ? this.Configuration.ShortUrlLengthHttps
1477                     : this.Configuration.ShortUrlLength;
1478
1479                 textLength += shortUrlLength - url.Length;
1480             }
1481
1482             return this.Configuration.DmTextCharacterLimit - textLength;
1483         }
1484
1485         private int GetTextLengthRemainWeighted(string postText)
1486         {
1487             var config = this.TextConfiguration;
1488             var totalWeight = 0;
1489
1490             int GetWeightFromCodepoint(int codepoint)
1491             {
1492                 foreach (var weightRange in config.Ranges)
1493                 {
1494                     if (codepoint >= weightRange.Start && codepoint <= weightRange.End)
1495                         return weightRange.Weight;
1496                 }
1497
1498                 return config.DefaultWeight;
1499             }
1500
1501             var urls = TweetExtractor.ExtractUrlEntities(postText).ToArray();
1502             var emojis = config.EmojiParsingEnabled
1503                 ? TweetExtractor.ExtractEmojiEntities(postText).ToArray()
1504                 : Array.Empty<TwitterEntityEmoji>();
1505
1506             var codepoints = postText.ToCodepoints().ToArray();
1507             var index = 0;
1508             while (index < codepoints.Length)
1509             {
1510                 var urlEntity = urls.FirstOrDefault(x => x.Indices[0] == index);
1511                 if (urlEntity != null)
1512                 {
1513                     totalWeight += config.TransformedURLLength * config.Scale;
1514                     index = urlEntity.Indices[1];
1515                     continue;
1516                 }
1517
1518                 var emojiEntity = emojis.FirstOrDefault(x => x.Indices[0] == index);
1519                 if (emojiEntity != null)
1520                 {
1521                     totalWeight += GetWeightFromCodepoint(codepoints[index]);
1522                     index = emojiEntity.Indices[1];
1523                     continue;
1524                 }
1525
1526                 var codepoint = codepoints[index];
1527                 totalWeight += GetWeightFromCodepoint(codepoint);
1528
1529                 index++;
1530             }
1531
1532             var remainWeight = config.MaxWeightedTweetLength * config.Scale - totalWeight;
1533
1534             return remainWeight / config.Scale;
1535         }
1536
1537         /// <summary>
1538         /// プロフィール画像のサイズを指定したURLを生成
1539         /// </summary>
1540         /// <remarks>
1541         /// https://developer.twitter.com/en/docs/twitter-api/v1/accounts-and-users/user-profile-images-and-banners を参照
1542         /// </remarks>
1543         public static string CreateProfileImageUrl(string normalUrl, string size)
1544         {
1545             return size switch
1546             {
1547                 "original" => normalUrl.Replace("_normal.", "."),
1548                 "normal" => normalUrl,
1549                 "bigger" or "mini" => normalUrl.Replace("_normal.", $"_{size}."),
1550                 _ => throw new ArgumentException($"Invalid size: ${size}", nameof(size)),
1551             };
1552         }
1553
1554         public static string DecideProfileImageSize(int sizePx)
1555         {
1556             return sizePx switch
1557             {
1558                 <= 24 => "mini",
1559                 <= 48 => "normal",
1560                 <= 73 => "bigger",
1561                 _ => "original",
1562             };
1563         }
1564
1565         public bool IsDisposed { get; private set; } = false;
1566
1567         protected virtual void Dispose(bool disposing)
1568         {
1569             if (this.IsDisposed)
1570                 return;
1571
1572             if (disposing)
1573             {
1574                 this.Api.Dispose();
1575             }
1576
1577             this.IsDisposed = true;
1578         }
1579
1580         public void Dispose()
1581         {
1582             this.Dispose(true);
1583             GC.SuppressFinalize(this);
1584         }
1585     }
1586 }