OSDN Git Service

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