OSDN Git Service

1942c99ee818d58581186683d263154e7eb91370
[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             // Cookie を使用する場合のみ 99 件を超えて取得するとエラーが返る
670             if (this.Api.AppToken.AuthType == APIAuthType.TwitterComCookie)
671                 count = Math.Min(count, 99);
672
673             TwitterStatus[] statuses;
674             if (this.Api.AppToken.AuthType == APIAuthType.TwitterComCookie)
675             {
676                 var userId = tab.UserId;
677                 if (MyCommon.IsNullOrEmpty(userId))
678                 {
679                     var user = await this.GetUserInfo(tab.ScreenName)
680                         .ConfigureAwait(false);
681
682                     userId = user.IdStr;
683                     tab.UserId = user.IdStr;
684                 }
685
686                 var request = new UserTweetsRequest(userId)
687                 {
688                     Count = count,
689                     Cursor = more ? tab.CursorBottom : null,
690                 };
691                 var response = await request.Send(this.Api.Connection)
692                     .ConfigureAwait(false);
693
694                 statuses = response.Tweets.Select(x => x.ToTwitterStatus()).ToArray();
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.Select(x => x.ToTwitterStatus());
889                 if (!SettingManager.Instance.Common.IsListsIncludeRts)
890                     convertedStatuses = convertedStatuses.Where(x => x.RetweetedStatus == null);
891
892                 statuses = convertedStatuses.ToArray();
893                 tab.CursorBottom = response.CursorBottom;
894             }
895             else if (more)
896             {
897                 statuses = await this.Api.ListsStatuses(tab.ListInfo.Id, count, maxId: tab.OldestId as TwitterStatusId, includeRTs: SettingManager.Instance.Common.IsListsIncludeRts)
898                     .ConfigureAwait(false);
899             }
900             else
901             {
902                 statuses = await this.Api.ListsStatuses(tab.ListInfo.Id, count, includeRTs: SettingManager.Instance.Common.IsListsIncludeRts)
903                     .ConfigureAwait(false);
904             }
905
906             var minimumId = this.CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.List, tab, read);
907
908             if (minimumId != null)
909                 tab.OldestId = minimumId;
910         }
911
912         /// <summary>
913         /// startStatusId からリプライ先の発言を辿る。発言は posts 以外からは検索しない。
914         /// </summary>
915         /// <returns>posts の中から検索されたリプライチェインの末端</returns>
916         internal static PostClass FindTopOfReplyChain(IDictionary<PostId, PostClass> posts, PostId startStatusId)
917         {
918             if (!posts.ContainsKey(startStatusId))
919                 throw new ArgumentException("startStatusId (" + startStatusId.Id + ") が posts の中から見つかりませんでした。", nameof(startStatusId));
920
921             var nextPost = posts[startStatusId];
922             while (nextPost.InReplyToStatusId != null)
923             {
924                 if (!posts.ContainsKey(nextPost.InReplyToStatusId))
925                     break;
926                 nextPost = posts[nextPost.InReplyToStatusId];
927             }
928
929             return nextPost;
930         }
931
932         public async Task GetRelatedResult(bool read, RelatedPostsTabModel tab)
933         {
934             var targetPost = tab.TargetPost;
935
936             if (targetPost.RetweetedId != null)
937             {
938                 var originalPost = targetPost with
939                 {
940                     StatusId = targetPost.RetweetedId,
941                     RetweetedId = null,
942                     RetweetedBy = null,
943                 };
944                 targetPost = originalPost;
945             }
946
947             var relPosts = new Dictionary<PostId, PostClass>();
948             if (targetPost.TextFromApi.Contains("@") && targetPost.InReplyToStatusId == null)
949             {
950                 // 検索結果対応
951                 var p = TabInformations.GetInstance()[targetPost.StatusId];
952                 if (p != null && p.InReplyToStatusId != null)
953                 {
954                     targetPost = p;
955                 }
956                 else
957                 {
958                     p = await this.GetStatusApi(read, targetPost.StatusId.ToTwitterStatusId())
959                         .ConfigureAwait(false);
960                     targetPost = p;
961                 }
962             }
963             relPosts.Add(targetPost.StatusId, targetPost);
964
965             Exception? lastException = null;
966
967             // in_reply_to_status_id を使用してリプライチェインを辿る
968             var nextPost = FindTopOfReplyChain(relPosts, targetPost.StatusId);
969             var loopCount = 1;
970             while (nextPost.InReplyToStatusId != null && loopCount++ <= 20)
971             {
972                 var inReplyToId = nextPost.InReplyToStatusId;
973
974                 var inReplyToPost = TabInformations.GetInstance()[inReplyToId];
975                 if (inReplyToPost == null)
976                 {
977                     try
978                     {
979                         inReplyToPost = await this.GetStatusApi(read, inReplyToId.ToTwitterStatusId())
980                             .ConfigureAwait(false);
981                     }
982                     catch (WebApiException ex)
983                     {
984                         lastException = ex;
985                         break;
986                     }
987                 }
988
989                 relPosts.Add(inReplyToPost.StatusId, inReplyToPost);
990
991                 nextPost = FindTopOfReplyChain(relPosts, nextPost.StatusId);
992             }
993
994             // MRTとかに対応のためツイート内にあるツイートを指すURLを取り込む
995             var text = targetPost.Text;
996             var ma = Twitter.StatusUrlRegex.Matches(text).Cast<Match>()
997                 .Concat(Twitter.ThirdPartyStatusUrlRegex.Matches(text).Cast<Match>());
998             foreach (var match in ma)
999             {
1000                 var statusId = new TwitterStatusId(match.Groups["StatusId"].Value);
1001                 if (!relPosts.ContainsKey(statusId))
1002                 {
1003                     var p = TabInformations.GetInstance()[statusId];
1004                     if (p == null)
1005                     {
1006                         try
1007                         {
1008                             p = await this.GetStatusApi(read, statusId)
1009                                 .ConfigureAwait(false);
1010                         }
1011                         catch (WebApiException ex)
1012                         {
1013                             lastException = ex;
1014                             break;
1015                         }
1016                     }
1017
1018                     if (p != null)
1019                         relPosts.Add(p.StatusId, p);
1020                 }
1021             }
1022
1023             try
1024             {
1025                 var firstPost = nextPost;
1026                 var posts = await this.GetConversationPosts(firstPost, targetPost)
1027                     .ConfigureAwait(false);
1028
1029                 foreach (var post in posts.OrderBy(x => x.StatusId))
1030                 {
1031                     if (relPosts.ContainsKey(post.StatusId))
1032                         continue;
1033
1034                     // リプライチェーンが繋がらないツイートは除外
1035                     if (post.InReplyToStatusId == null || !relPosts.ContainsKey(post.InReplyToStatusId))
1036                         continue;
1037
1038                     relPosts.Add(post.StatusId, post);
1039                 }
1040             }
1041             catch (WebException ex)
1042             {
1043                 lastException = ex;
1044             }
1045
1046             relPosts.Values.ToList().ForEach(p =>
1047             {
1048                 var post = p with { };
1049                 if (post.IsMe && !read && this.ReadOwnPost)
1050                     post.IsRead = true;
1051                 else
1052                     post.IsRead = read;
1053
1054                 tab.AddPostQueue(post);
1055             });
1056
1057             if (lastException != null)
1058                 throw new WebApiException(lastException.Message, lastException);
1059         }
1060
1061         private async Task<PostClass[]> GetConversationPosts(PostClass firstPost, PostClass targetPost)
1062         {
1063             var conversationId = firstPost.StatusId;
1064             var query = $"conversation_id:{conversationId.Id}";
1065
1066             if (targetPost.InReplyToUser != null && targetPost.InReplyToUser != targetPost.ScreenName)
1067                 query += $" (from:{targetPost.ScreenName} to:{targetPost.InReplyToUser}) OR (from:{targetPost.InReplyToUser} to:{targetPost.ScreenName})";
1068             else
1069                 query += $" from:{targetPost.ScreenName} to:{targetPost.ScreenName}";
1070
1071             var statuses = await this.Api.SearchTweets(query, count: 100)
1072                 .ConfigureAwait(false);
1073
1074             return statuses.Statuses.Select(x => this.CreatePostsFromStatusData(x)).ToArray();
1075         }
1076
1077         public async Task GetSearch(bool read, PublicSearchTabModel tab, bool more)
1078         {
1079             var count = GetApiResultCount(MyCommon.WORKERTYPE.PublicSearch, more, false);
1080
1081             TwitterStatus[] statuses;
1082             if (this.Api.AppToken.AuthType == APIAuthType.TwitterComCookie)
1083             {
1084                 var request = new SearchTimelineRequest(tab.SearchWords)
1085                 {
1086                     Count = count,
1087                     Cursor = more ? tab.CursorBottom : null,
1088                 };
1089                 var response = await request.Send(this.Api.Connection)
1090                     .ConfigureAwait(false);
1091
1092                 statuses = response.Tweets.Select(x => x.ToTwitterStatus()).ToArray();
1093                 tab.CursorBottom = response.CursorBottom;
1094             }
1095             else
1096             {
1097                 TwitterStatusId? maxId = null;
1098                 TwitterStatusId? sinceId = null;
1099                 if (more)
1100                 {
1101                     maxId = tab.OldestId as TwitterStatusId;
1102                 }
1103                 else
1104                 {
1105                     sinceId = tab.SinceId as TwitterStatusId;
1106                 }
1107
1108                 var searchResult = await this.Api.SearchTweets(tab.SearchWords, tab.SearchLang, count, maxId, sinceId)
1109                     .ConfigureAwait(false);
1110
1111                 statuses = searchResult.Statuses;
1112             }
1113
1114             if (!TabInformations.GetInstance().ContainsTab(tab))
1115                 return;
1116
1117             var minimumId = this.CreatePostsFromSearchJson(statuses, tab, read, more);
1118
1119             if (minimumId != null)
1120                 tab.OldestId = minimumId;
1121         }
1122
1123         public async Task GetDirectMessageEvents(bool read, DirectMessagesTabModel dmTab, bool backward)
1124         {
1125             this.CheckAccountState();
1126             this.CheckAccessLevel(TwitterApiAccessLevel.ReadWriteAndDirectMessage);
1127
1128             var count = 50;
1129
1130             TwitterMessageEventList eventList;
1131             if (backward)
1132             {
1133                 eventList = await this.Api.DirectMessagesEventsList(count, dmTab.NextCursor)
1134                     .ConfigureAwait(false);
1135             }
1136             else
1137             {
1138                 eventList = await this.Api.DirectMessagesEventsList(count)
1139                     .ConfigureAwait(false);
1140             }
1141
1142             dmTab.NextCursor = eventList.NextCursor;
1143
1144             await this.CreateDirectMessagesEventFromJson(eventList, read)
1145                 .ConfigureAwait(false);
1146         }
1147
1148         private async Task CreateDirectMessagesEventFromJson(TwitterMessageEventSingle eventSingle, bool read)
1149         {
1150             var eventList = new TwitterMessageEventList
1151             {
1152                 Apps = new Dictionary<string, TwitterMessageEventList.App>(),
1153                 Events = new[] { eventSingle.Event },
1154             };
1155
1156             await this.CreateDirectMessagesEventFromJson(eventList, read)
1157                 .ConfigureAwait(false);
1158         }
1159
1160         private async Task CreateDirectMessagesEventFromJson(TwitterMessageEventList eventList, bool read)
1161         {
1162             var events = eventList.Events
1163                 .Where(x => x.Type == "message_create")
1164                 .ToArray();
1165
1166             if (events.Length == 0)
1167                 return;
1168
1169             var userIds = Enumerable.Concat(
1170                 events.Select(x => x.MessageCreate.SenderId),
1171                 events.Select(x => x.MessageCreate.Target.RecipientId)
1172             ).Distinct().ToArray();
1173
1174             var users = (await this.Api.UsersLookup(userIds).ConfigureAwait(false))
1175                 .ToDictionary(x => x.IdStr);
1176
1177             var apps = eventList.Apps ?? new Dictionary<string, TwitterMessageEventList.App>();
1178
1179             this.CreateDirectMessagesEventFromJson(events, users, apps, read);
1180         }
1181
1182         private void CreateDirectMessagesEventFromJson(
1183             IEnumerable<TwitterMessageEvent> events,
1184             IReadOnlyDictionary<string, TwitterUser> users,
1185             IReadOnlyDictionary<string, TwitterMessageEventList.App> apps,
1186             bool read)
1187         {
1188             var dmTab = TabInformations.GetInstance().DirectMessageTab;
1189
1190             foreach (var eventItem in events)
1191             {
1192                 var post = this.postFactory.CreateFromDirectMessageEvent(eventItem, users, apps, this.UserId);
1193
1194                 post.IsRead = read;
1195                 if (post.IsMe && !read && this.ReadOwnPost)
1196                     post.IsRead = true;
1197
1198                 dmTab.AddPostQueue(post);
1199             }
1200         }
1201
1202         public async Task GetFavoritesApi(bool read, FavoritesTabModel tab, bool backward)
1203         {
1204             this.CheckAccountState();
1205
1206             var count = GetApiResultCount(MyCommon.WORKERTYPE.Favorites, backward, false);
1207
1208             TwitterStatus[] statuses;
1209             if (backward)
1210             {
1211                 statuses = await this.Api.FavoritesList(count, maxId: tab.OldestId)
1212                     .ConfigureAwait(false);
1213             }
1214             else
1215             {
1216                 statuses = await this.Api.FavoritesList(count)
1217                     .ConfigureAwait(false);
1218             }
1219
1220             var minimumId = this.CreateFavoritePostsFromJson(statuses, read);
1221
1222             if (minimumId != null)
1223                 tab.OldestId = minimumId.Value;
1224         }
1225
1226         /// <summary>
1227         /// フォロワーIDを更新します
1228         /// </summary>
1229         /// <exception cref="WebApiException"/>
1230         public async Task RefreshFollowerIds()
1231         {
1232             if (MyCommon.EndingFlag) return;
1233
1234             var cursor = -1L;
1235             var newFollowerIds = Enumerable.Empty<long>();
1236             do
1237             {
1238                 var ret = await this.Api.FollowersIds(cursor)
1239                     .ConfigureAwait(false);
1240
1241                 if (ret.Ids == null)
1242                     throw new WebApiException("ret.ids == null");
1243
1244                 newFollowerIds = newFollowerIds.Concat(ret.Ids);
1245                 cursor = ret.NextCursor;
1246             }
1247             while (cursor != 0);
1248
1249             this.followerId = newFollowerIds.ToHashSet();
1250             TabInformations.GetInstance().RefreshOwl(this.followerId);
1251
1252             this.GetFollowersSuccess = true;
1253         }
1254
1255         /// <summary>
1256         /// RT 非表示ユーザーを更新します
1257         /// </summary>
1258         /// <exception cref="WebApiException"/>
1259         public async Task RefreshNoRetweetIds()
1260         {
1261             if (MyCommon.EndingFlag) return;
1262
1263             this.noRTId = await this.Api.NoRetweetIds()
1264                 .ConfigureAwait(false);
1265
1266             this.GetNoRetweetSuccess = true;
1267         }
1268
1269         /// <summary>
1270         /// t.co の文字列長などの設定情報を更新します
1271         /// </summary>
1272         /// <exception cref="WebApiException"/>
1273         public async Task RefreshConfiguration()
1274         {
1275             this.Configuration = await this.Api.Configuration()
1276                 .ConfigureAwait(false);
1277
1278             // TextConfiguration 相当の JSON を得る API が存在しないため、TransformedURLLength のみ help/configuration.json に合わせて更新する
1279             this.TextConfiguration.TransformedURLLength = this.Configuration.ShortUrlLengthHttps;
1280         }
1281
1282         public async Task GetListsApi()
1283         {
1284             this.CheckAccountState();
1285
1286             var ownedLists = await TwitterLists.GetAllItemsAsync(x =>
1287                 this.Api.ListsOwnerships(this.Username, cursor: x, count: 1000))
1288                     .ConfigureAwait(false);
1289
1290             var subscribedLists = await TwitterLists.GetAllItemsAsync(x =>
1291                 this.Api.ListsSubscriptions(this.Username, cursor: x, count: 1000))
1292                     .ConfigureAwait(false);
1293
1294             TabInformations.GetInstance().SubscribableLists = Enumerable.Concat(ownedLists, subscribedLists)
1295                 .Select(x => new ListElement(x, this))
1296                 .ToList();
1297         }
1298
1299         public async Task DeleteList(long listId)
1300         {
1301             await this.Api.ListsDestroy(listId)
1302                 .IgnoreResponse()
1303                 .ConfigureAwait(false);
1304
1305             var tabinfo = TabInformations.GetInstance();
1306
1307             tabinfo.SubscribableLists = tabinfo.SubscribableLists
1308                 .Where(x => x.Id != listId)
1309                 .ToList();
1310         }
1311
1312         public async Task<ListElement> EditList(long listId, string new_name, bool isPrivate, string description)
1313         {
1314             var response = await this.Api.ListsUpdate(listId, new_name, description, isPrivate)
1315                 .ConfigureAwait(false);
1316
1317             var list = await response.LoadJsonAsync()
1318                 .ConfigureAwait(false);
1319
1320             return new ListElement(list, this);
1321         }
1322
1323         public async Task<long> GetListMembers(long listId, List<UserInfo> lists, long cursor)
1324         {
1325             this.CheckAccountState();
1326
1327             var users = await this.Api.ListsMembers(listId, cursor)
1328                 .ConfigureAwait(false);
1329
1330             Array.ForEach(users.Users, u => lists.Add(new UserInfo(u)));
1331
1332             return users.NextCursor;
1333         }
1334
1335         public async Task CreateListApi(string listName, bool isPrivate, string description)
1336         {
1337             this.CheckAccountState();
1338
1339             var response = await this.Api.ListsCreate(listName, description, isPrivate)
1340                 .ConfigureAwait(false);
1341
1342             var list = await response.LoadJsonAsync()
1343                 .ConfigureAwait(false);
1344
1345             TabInformations.GetInstance().SubscribableLists.Add(new ListElement(list, this));
1346         }
1347
1348         public async Task<bool> ContainsUserAtList(long listId, string user)
1349         {
1350             this.CheckAccountState();
1351
1352             try
1353             {
1354                 await this.Api.ListsMembersShow(listId, user)
1355                     .ConfigureAwait(false);
1356
1357                 return true;
1358             }
1359             catch (TwitterApiException ex)
1360                 when (ex.Errors.Any(x => x.Code == TwitterErrorCode.NotFound))
1361             {
1362                 return false;
1363             }
1364         }
1365
1366         public async Task<TwitterApiStatus?> GetInfoApi()
1367         {
1368             if (Twitter.AccountState != MyCommon.ACCOUNT_STATE.Valid) return null;
1369
1370             if (MyCommon.EndingFlag) return null;
1371
1372             var limits = await this.Api.ApplicationRateLimitStatus()
1373                 .ConfigureAwait(false);
1374
1375             MyCommon.TwitterApiInfo.UpdateFromJson(limits);
1376
1377             return MyCommon.TwitterApiInfo;
1378         }
1379
1380         /// <summary>
1381         /// ブロック中のユーザーを更新します
1382         /// </summary>
1383         /// <exception cref="WebApiException"/>
1384         public async Task RefreshBlockIds()
1385         {
1386             if (MyCommon.EndingFlag) return;
1387
1388             var cursor = -1L;
1389             var newBlockIds = Enumerable.Empty<long>();
1390             do
1391             {
1392                 var ret = await this.Api.BlocksIds(cursor)
1393                     .ConfigureAwait(false);
1394
1395                 newBlockIds = newBlockIds.Concat(ret.Ids);
1396                 cursor = ret.NextCursor;
1397             }
1398             while (cursor != 0);
1399
1400             var blockIdsSet = newBlockIds.ToHashSet();
1401             blockIdsSet.Remove(this.UserId); // 元のソースにあったので一応残しておく
1402
1403             TabInformations.GetInstance().BlockIds = blockIdsSet;
1404         }
1405
1406         /// <summary>
1407         /// ミュート中のユーザーIDを更新します
1408         /// </summary>
1409         /// <exception cref="WebApiException"/>
1410         public async Task RefreshMuteUserIdsAsync()
1411         {
1412             if (MyCommon.EndingFlag) return;
1413
1414             var ids = await TwitterIds.GetAllItemsAsync(x => this.Api.MutesUsersIds(x))
1415                 .ConfigureAwait(false);
1416
1417             TabInformations.GetInstance().MuteUserIds = ids.ToHashSet();
1418         }
1419
1420         public string[] GetHashList()
1421             => this.postFactory.GetReceivedHashtags();
1422
1423         public string AccessToken
1424             => ((TwitterApiConnection)this.Api.Connection).AccessToken;
1425
1426         public string AccessTokenSecret
1427             => ((TwitterApiConnection)this.Api.Connection).AccessSecret;
1428
1429         private void CheckAccountState()
1430         {
1431             if (Twitter.AccountState != MyCommon.ACCOUNT_STATE.Valid)
1432                 throw new WebApiException("Auth error. Check your account");
1433         }
1434
1435         private void CheckAccessLevel(TwitterApiAccessLevel accessLevelFlags)
1436         {
1437             if (!this.AccessLevel.HasFlag(accessLevelFlags))
1438                 throw new WebApiException("Auth Err:try to re-authorization.");
1439         }
1440
1441         public int GetTextLengthRemain(string postText)
1442         {
1443             var matchDm = Twitter.DMSendTextRegex.Match(postText);
1444             if (matchDm.Success)
1445                 return this.GetTextLengthRemainDM(matchDm.Groups["body"].Value);
1446
1447             return this.GetTextLengthRemainWeighted(postText);
1448         }
1449
1450         private int GetTextLengthRemainDM(string postText)
1451         {
1452             var textLength = 0;
1453
1454             var pos = 0;
1455             while (pos < postText.Length)
1456             {
1457                 textLength++;
1458
1459                 if (char.IsSurrogatePair(postText, pos))
1460                     pos += 2; // サロゲートペアの場合は2文字分進める
1461                 else
1462                     pos++;
1463             }
1464
1465             var urls = TweetExtractor.ExtractUrls(postText);
1466             foreach (var url in urls)
1467             {
1468                 var shortUrlLength = url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
1469                     ? this.Configuration.ShortUrlLengthHttps
1470                     : this.Configuration.ShortUrlLength;
1471
1472                 textLength += shortUrlLength - url.Length;
1473             }
1474
1475             return this.Configuration.DmTextCharacterLimit - textLength;
1476         }
1477
1478         private int GetTextLengthRemainWeighted(string postText)
1479         {
1480             var config = this.TextConfiguration;
1481             var totalWeight = 0;
1482
1483             int GetWeightFromCodepoint(int codepoint)
1484             {
1485                 foreach (var weightRange in config.Ranges)
1486                 {
1487                     if (codepoint >= weightRange.Start && codepoint <= weightRange.End)
1488                         return weightRange.Weight;
1489                 }
1490
1491                 return config.DefaultWeight;
1492             }
1493
1494             var urls = TweetExtractor.ExtractUrlEntities(postText).ToArray();
1495             var emojis = config.EmojiParsingEnabled
1496                 ? TweetExtractor.ExtractEmojiEntities(postText).ToArray()
1497                 : Array.Empty<TwitterEntityEmoji>();
1498
1499             var codepoints = postText.ToCodepoints().ToArray();
1500             var index = 0;
1501             while (index < codepoints.Length)
1502             {
1503                 var urlEntity = urls.FirstOrDefault(x => x.Indices[0] == index);
1504                 if (urlEntity != null)
1505                 {
1506                     totalWeight += config.TransformedURLLength * config.Scale;
1507                     index = urlEntity.Indices[1];
1508                     continue;
1509                 }
1510
1511                 var emojiEntity = emojis.FirstOrDefault(x => x.Indices[0] == index);
1512                 if (emojiEntity != null)
1513                 {
1514                     totalWeight += GetWeightFromCodepoint(codepoints[index]);
1515                     index = emojiEntity.Indices[1];
1516                     continue;
1517                 }
1518
1519                 var codepoint = codepoints[index];
1520                 totalWeight += GetWeightFromCodepoint(codepoint);
1521
1522                 index++;
1523             }
1524
1525             var remainWeight = config.MaxWeightedTweetLength * config.Scale - totalWeight;
1526
1527             return remainWeight / config.Scale;
1528         }
1529
1530         /// <summary>
1531         /// プロフィール画像のサイズを指定したURLを生成
1532         /// </summary>
1533         /// <remarks>
1534         /// https://developer.twitter.com/en/docs/twitter-api/v1/accounts-and-users/user-profile-images-and-banners を参照
1535         /// </remarks>
1536         public static string CreateProfileImageUrl(string normalUrl, string size)
1537         {
1538             return size switch
1539             {
1540                 "original" => normalUrl.Replace("_normal.", "."),
1541                 "normal" => normalUrl,
1542                 "bigger" or "mini" => normalUrl.Replace("_normal.", $"_{size}."),
1543                 _ => throw new ArgumentException($"Invalid size: ${size}", nameof(size)),
1544             };
1545         }
1546
1547         public static string DecideProfileImageSize(int sizePx)
1548         {
1549             return sizePx switch
1550             {
1551                 <= 24 => "mini",
1552                 <= 48 => "normal",
1553                 <= 73 => "bigger",
1554                 _ => "original",
1555             };
1556         }
1557
1558         public bool IsDisposed { get; private set; } = false;
1559
1560         protected virtual void Dispose(bool disposing)
1561         {
1562             if (this.IsDisposed)
1563                 return;
1564
1565             if (disposing)
1566             {
1567                 this.Api.Dispose();
1568             }
1569
1570             this.IsDisposed = true;
1571         }
1572
1573         public void Dispose()
1574         {
1575             this.Dispose(true);
1576             GC.SuppressFinalize(this);
1577         }
1578     }
1579 }