OSDN Git Service

4e1030f5526fb0d5cabfcc9e1d06ea3046103c0f
[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 (this.Api.AuthType == APIAuthType.TwitterComCookie)
599             {
600                 var request = new HomeLatestTimelineRequest
601                 {
602                     Count = count,
603                     Cursor = more ? tab.CursorBottom : tab.CursorTop,
604                 };
605                 var response = await request.Send(this.Api.Connection)
606                     .ConfigureAwait(false);
607
608                 statuses = response.ToTwitterStatuses();
609
610                 tab.CursorBottom = response.CursorBottom;
611
612                 if (!more)
613                     tab.CursorTop = response.CursorTop;
614             }
615             else if (SettingManager.Instance.Common.EnableTwitterV2Api)
616             {
617                 var request = new GetTimelineRequest(this.UserId)
618                 {
619                     MaxResults = count,
620                     UntilId = more ? tab.OldestId as TwitterStatusId : null,
621                 };
622
623                 var response = await request.Send(this.Api.Connection)
624                     .ConfigureAwait(false);
625
626                 if (response.Data == null || response.Data.Length == 0)
627                     return;
628
629                 var tweetIds = response.Data.Select(x => x.Id).ToList();
630
631                 statuses = await this.Api.StatusesLookup(tweetIds)
632                     .ConfigureAwait(false);
633             }
634             else
635             {
636                 var maxId = more ? tab.OldestId : null;
637
638                 statuses = await this.Api.StatusesHomeTimeline(count, maxId as TwitterStatusId)
639                     .ConfigureAwait(false);
640             }
641
642             var minimumId = this.CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.Timeline, tab, read);
643             if (minimumId != null)
644                 tab.OldestId = minimumId;
645         }
646
647         public async Task GetMentionsTimelineApi(bool read, MentionsTabModel tab, bool more, bool startup)
648         {
649             this.CheckAccountState();
650
651             var count = GetApiResultCount(MyCommon.WORKERTYPE.Reply, more, startup);
652
653             TwitterStatus[] statuses;
654             if (more)
655             {
656                 statuses = await this.Api.StatusesMentionsTimeline(count, maxId: tab.OldestId as TwitterStatusId)
657                     .ConfigureAwait(false);
658             }
659             else
660             {
661                 statuses = await this.Api.StatusesMentionsTimeline(count)
662                     .ConfigureAwait(false);
663             }
664
665             var minimumId = this.CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.Reply, tab, read);
666             if (minimumId != null)
667                 tab.OldestId = minimumId;
668         }
669
670         public async Task GetUserTimelineApi(bool read, UserTimelineTabModel tab, bool more)
671         {
672             this.CheckAccountState();
673
674             var count = GetApiResultCount(MyCommon.WORKERTYPE.UserTimeline, more, false);
675
676             TwitterStatus[] statuses;
677             if (this.Api.AuthType == APIAuthType.TwitterComCookie)
678             {
679                 var userId = tab.UserId;
680                 if (MyCommon.IsNullOrEmpty(userId))
681                 {
682                     var user = await this.GetUserInfo(tab.ScreenName)
683                         .ConfigureAwait(false);
684
685                     userId = user.IdStr;
686                     tab.UserId = user.IdStr;
687                 }
688
689                 var request = new UserTweetsAndRepliesRequest(userId)
690                 {
691                     Count = count,
692                     Cursor = more ? tab.CursorBottom : tab.CursorTop,
693                 };
694                 var response = await request.Send(this.Api.Connection)
695                     .ConfigureAwait(false);
696
697                 statuses = response.ToTwitterStatuses()
698                     .Where(x => x.User.IdStr == userId) // リプライツリーに含まれる他ユーザーのツイートを除外
699                     .ToArray();
700
701                 tab.CursorBottom = response.CursorBottom;
702
703                 if (!more)
704                     tab.CursorTop = response.CursorTop;
705             }
706             else
707             {
708                 if (more)
709                 {
710                     statuses = await this.Api.StatusesUserTimeline(tab.ScreenName, count, maxId: tab.OldestId as TwitterStatusId)
711                         .ConfigureAwait(false);
712                 }
713                 else
714                 {
715                     statuses = await this.Api.StatusesUserTimeline(tab.ScreenName, count)
716                         .ConfigureAwait(false);
717                 }
718             }
719
720             var minimumId = this.CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.UserTimeline, tab, read);
721
722             if (minimumId != null)
723                 tab.OldestId = minimumId;
724         }
725
726         public async Task<PostClass> GetStatusApi(bool read, TwitterStatusId id)
727         {
728             this.CheckAccountState();
729
730             TwitterStatus status;
731             if (this.Api.AuthType == APIAuthType.TwitterComCookie)
732             {
733                 var request = new TweetDetailRequest
734                 {
735                     FocalTweetId = id,
736                 };
737                 var tweets = await request.Send(this.Api.Connection).ConfigureAwait(false);
738                 status = tweets.Select(x => x.ToTwitterStatus())
739                     .Where(x => x.IdStr == id.Id)
740                     .FirstOrDefault() ?? throw new WebApiException("Empty result set");
741             }
742             else
743             {
744                 status = await this.Api.StatusesShow(id)
745                     .ConfigureAwait(false);
746             }
747
748             var item = this.CreatePostsFromStatusData(status);
749
750             item.IsRead = read;
751             if (item.IsMe && !read && this.ReadOwnPost) item.IsRead = true;
752
753             return item;
754         }
755
756         public async Task GetStatusApi(bool read, TwitterStatusId id, TabModel tab)
757         {
758             var post = await this.GetStatusApi(read, id)
759                 .ConfigureAwait(false);
760
761             // 非同期アイコン取得&StatusDictionaryに追加
762             if (tab != null && tab.IsInnerStorageTabType)
763                 tab.AddPostQueue(post);
764             else
765                 TabInformations.GetInstance().AddPost(post);
766         }
767
768         private PostClass CreatePostsFromStatusData(TwitterStatus status)
769             => this.CreatePostsFromStatusData(status, favTweet: false);
770
771         private PostClass CreatePostsFromStatusData(TwitterStatus status, bool favTweet)
772         {
773             var post = this.postFactory.CreateFromStatus(status, this.UserId, this.followerId, favTweet);
774             _ = this.urlExpander.Expand(post);
775
776             return post;
777         }
778
779         private PostId? CreatePostsFromJson(TwitterStatus[] items, MyCommon.WORKERTYPE gType, TabModel? tab, bool read)
780         {
781             PostId? minimumId = null;
782
783             var posts = items.Select(x => this.CreatePostsFromStatusData(x)).ToArray();
784
785             TwitterPostFactory.AdjustSortKeyForPromotedPost(posts);
786
787             foreach (var post in posts)
788             {
789                 if (!post.IsPromoted)
790                 {
791                     if (minimumId == null || minimumId > post.StatusId)
792                         minimumId = post.StatusId;
793                 }
794
795                 // 二重取得回避
796                 lock (this.lockObj)
797                 {
798                     var id = post.StatusId;
799                     if (tab == null)
800                     {
801                         if (TabInformations.GetInstance().ContainsKey(id)) continue;
802                     }
803                     else
804                     {
805                         if (tab.Contains(id)) continue;
806                     }
807                 }
808
809                 // RT禁止ユーザーによるもの
810                 if (gType != MyCommon.WORKERTYPE.UserTimeline &&
811                     post.RetweetedByUserId != null && this.noRTId.Contains(post.RetweetedByUserId.Value)) continue;
812
813                 post.IsRead = read;
814                 if (post.IsMe && !read && this.ReadOwnPost) post.IsRead = true;
815
816                 if (tab != null && tab.IsInnerStorageTabType)
817                     tab.AddPostQueue(post);
818                 else
819                     TabInformations.GetInstance().AddPost(post);
820             }
821
822             return minimumId;
823         }
824
825         private PostId? CreatePostsFromSearchJson(TwitterStatus[] statuses, PublicSearchTabModel tab, bool read, bool more)
826         {
827             PostId? minimumId = null;
828
829             var posts = statuses.Select(x => this.CreatePostsFromStatusData(x)).ToArray();
830
831             TwitterPostFactory.AdjustSortKeyForPromotedPost(posts);
832
833             foreach (var post in posts)
834             {
835                 if (!post.IsPromoted)
836                 {
837                     if (minimumId == null || minimumId > post.StatusId)
838                         minimumId = post.StatusId;
839
840                     if (!more && (tab.SinceId == null || post.StatusId > tab.SinceId))
841                         tab.SinceId = post.StatusId;
842                 }
843
844                 // 二重取得回避
845                 lock (this.lockObj)
846                 {
847                     if (tab.Contains(post.StatusId))
848                         continue;
849                 }
850
851                 post.IsRead = read;
852                 if ((post.IsMe && !read) && this.ReadOwnPost) post.IsRead = true;
853
854                 tab.AddPostQueue(post);
855             }
856
857             return minimumId;
858         }
859
860         private long? CreateFavoritePostsFromJson(TwitterStatus[] items, bool read)
861         {
862             var favTab = TabInformations.GetInstance().FavoriteTab;
863             long? minimumId = null;
864
865             foreach (var status in items)
866             {
867                 if (minimumId == null || minimumId.Value > status.Id)
868                     minimumId = status.Id;
869
870                 // 二重取得回避
871                 lock (this.lockObj)
872                 {
873                     if (favTab.Contains(new TwitterStatusId(status.IdStr)))
874                         continue;
875                 }
876
877                 var post = this.CreatePostsFromStatusData(status, true);
878
879                 post.IsRead = read;
880
881                 TabInformations.GetInstance().AddPost(post);
882             }
883
884             return minimumId;
885         }
886
887         public async Task GetListStatus(bool read, ListTimelineTabModel tab, bool more, bool startup)
888         {
889             var count = GetApiResultCount(MyCommon.WORKERTYPE.List, more, startup);
890
891             TwitterStatus[] statuses;
892             if (this.Api.AuthType == APIAuthType.TwitterComCookie)
893             {
894                 var request = new ListLatestTweetsTimelineRequest(tab.ListInfo.Id.ToString())
895                 {
896                     Count = count,
897                     Cursor = more ? tab.CursorBottom : tab.CursorTop,
898                 };
899                 var response = await request.Send(this.Api.Connection)
900                     .ConfigureAwait(false);
901
902                 var convertedStatuses = response.ToTwitterStatuses();
903
904                 if (!SettingManager.Instance.Common.IsListsIncludeRts)
905                     convertedStatuses = convertedStatuses.Where(x => x.RetweetedStatus == null).ToArray();
906
907                 statuses = convertedStatuses.ToArray();
908                 tab.CursorBottom = response.CursorBottom;
909
910                 if (!more)
911                     tab.CursorTop = response.CursorTop;
912             }
913             else if (more)
914             {
915                 statuses = await this.Api.ListsStatuses(tab.ListInfo.Id, count, maxId: tab.OldestId as TwitterStatusId, includeRTs: SettingManager.Instance.Common.IsListsIncludeRts)
916                     .ConfigureAwait(false);
917             }
918             else
919             {
920                 statuses = await this.Api.ListsStatuses(tab.ListInfo.Id, count, includeRTs: SettingManager.Instance.Common.IsListsIncludeRts)
921                     .ConfigureAwait(false);
922             }
923
924             var minimumId = this.CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.List, tab, read);
925
926             if (minimumId != null)
927                 tab.OldestId = minimumId;
928         }
929
930         /// <summary>
931         /// startStatusId からリプライ先の発言を辿る。発言は posts 以外からは検索しない。
932         /// </summary>
933         /// <returns>posts の中から検索されたリプライチェインの末端</returns>
934         internal static PostClass FindTopOfReplyChain(IDictionary<PostId, PostClass> posts, PostId startStatusId)
935         {
936             if (!posts.ContainsKey(startStatusId))
937                 throw new ArgumentException("startStatusId (" + startStatusId.Id + ") が posts の中から見つかりませんでした。", nameof(startStatusId));
938
939             var nextPost = posts[startStatusId];
940             while (nextPost.InReplyToStatusId != null)
941             {
942                 if (!posts.ContainsKey(nextPost.InReplyToStatusId))
943                     break;
944                 nextPost = posts[nextPost.InReplyToStatusId];
945             }
946
947             return nextPost;
948         }
949
950         public async Task GetRelatedResult(bool read, RelatedPostsTabModel tab)
951         {
952             var targetPost = tab.TargetPost;
953
954             if (targetPost.RetweetedId != null)
955             {
956                 var originalPost = targetPost with
957                 {
958                     StatusId = targetPost.RetweetedId,
959                     RetweetedId = null,
960                     RetweetedBy = null,
961                 };
962                 targetPost = originalPost;
963             }
964
965             var relPosts = new Dictionary<PostId, PostClass>();
966             if (targetPost.TextFromApi.Contains("@") && targetPost.InReplyToStatusId == null)
967             {
968                 // 検索結果対応
969                 var p = TabInformations.GetInstance()[targetPost.StatusId];
970                 if (p != null && p.InReplyToStatusId != null)
971                 {
972                     targetPost = p;
973                 }
974                 else
975                 {
976                     p = await this.GetStatusApi(read, targetPost.StatusId.ToTwitterStatusId())
977                         .ConfigureAwait(false);
978                     targetPost = p;
979                 }
980             }
981             relPosts.Add(targetPost.StatusId, targetPost);
982
983             Exception? lastException = null;
984
985             // in_reply_to_status_id を使用してリプライチェインを辿る
986             var nextPost = FindTopOfReplyChain(relPosts, targetPost.StatusId);
987             var loopCount = 1;
988             while (nextPost.InReplyToStatusId != null && loopCount++ <= 20)
989             {
990                 var inReplyToId = nextPost.InReplyToStatusId;
991
992                 var inReplyToPost = TabInformations.GetInstance()[inReplyToId];
993                 if (inReplyToPost == null)
994                 {
995                     try
996                     {
997                         inReplyToPost = await this.GetStatusApi(read, inReplyToId.ToTwitterStatusId())
998                             .ConfigureAwait(false);
999                     }
1000                     catch (WebApiException ex)
1001                     {
1002                         lastException = ex;
1003                         break;
1004                     }
1005                 }
1006
1007                 relPosts.Add(inReplyToPost.StatusId, inReplyToPost);
1008
1009                 nextPost = FindTopOfReplyChain(relPosts, nextPost.StatusId);
1010             }
1011
1012             // MRTとかに対応のためツイート内にあるツイートを指すURLを取り込む
1013             var text = targetPost.Text;
1014             var ma = Twitter.StatusUrlRegex.Matches(text).Cast<Match>()
1015                 .Concat(Twitter.ThirdPartyStatusUrlRegex.Matches(text).Cast<Match>());
1016             foreach (var match in ma)
1017             {
1018                 var statusId = new TwitterStatusId(match.Groups["StatusId"].Value);
1019                 if (!relPosts.ContainsKey(statusId))
1020                 {
1021                     var p = TabInformations.GetInstance()[statusId];
1022                     if (p == null)
1023                     {
1024                         try
1025                         {
1026                             p = await this.GetStatusApi(read, statusId)
1027                                 .ConfigureAwait(false);
1028                         }
1029                         catch (WebApiException ex)
1030                         {
1031                             lastException = ex;
1032                             break;
1033                         }
1034                     }
1035
1036                     if (p != null)
1037                         relPosts.Add(p.StatusId, p);
1038                 }
1039             }
1040
1041             try
1042             {
1043                 var firstPost = nextPost;
1044                 var posts = await this.GetConversationPosts(firstPost, targetPost)
1045                     .ConfigureAwait(false);
1046
1047                 foreach (var post in posts.OrderBy(x => x.StatusId))
1048                 {
1049                     if (relPosts.ContainsKey(post.StatusId))
1050                         continue;
1051
1052                     // リプライチェーンが繋がらないツイートは除外
1053                     if (post.InReplyToStatusId == null || !relPosts.ContainsKey(post.InReplyToStatusId))
1054                         continue;
1055
1056                     relPosts.Add(post.StatusId, post);
1057                 }
1058             }
1059             catch (WebException ex)
1060             {
1061                 lastException = ex;
1062             }
1063
1064             relPosts.Values.ToList().ForEach(p =>
1065             {
1066                 var post = p with { };
1067                 if (post.IsMe && !read && this.ReadOwnPost)
1068                     post.IsRead = true;
1069                 else
1070                     post.IsRead = read;
1071
1072                 tab.AddPostQueue(post);
1073             });
1074
1075             if (lastException != null)
1076                 throw new WebApiException(lastException.Message, lastException);
1077         }
1078
1079         private async Task<PostClass[]> GetConversationPosts(PostClass firstPost, PostClass targetPost)
1080         {
1081             var conversationId = firstPost.StatusId;
1082             var query = $"conversation_id:{conversationId.Id}";
1083
1084             if (targetPost.InReplyToUser != null && targetPost.InReplyToUser != targetPost.ScreenName)
1085                 query += $" (from:{targetPost.ScreenName} to:{targetPost.InReplyToUser}) OR (from:{targetPost.InReplyToUser} to:{targetPost.ScreenName})";
1086             else
1087                 query += $" from:{targetPost.ScreenName} to:{targetPost.ScreenName}";
1088
1089             TwitterStatus[] statuses;
1090             if (this.Api.AuthType == APIAuthType.TwitterComCookie)
1091             {
1092                 var request = new SearchTimelineRequest(query);
1093                 var response = await request.Send(this.Api.Connection)
1094                     .ConfigureAwait(false);
1095
1096                 statuses = response.ToTwitterStatuses();
1097             }
1098             else
1099             {
1100                 var response = await this.Api.SearchTweets(query, count: 100)
1101                     .ConfigureAwait(false);
1102
1103                 statuses = response.Statuses;
1104             }
1105
1106             return statuses.Select(x => this.CreatePostsFromStatusData(x)).ToArray();
1107         }
1108
1109         public async Task GetSearch(bool read, PublicSearchTabModel tab, bool more)
1110         {
1111             var count = GetApiResultCount(MyCommon.WORKERTYPE.PublicSearch, more, false);
1112
1113             TwitterStatus[] statuses;
1114             if (this.Api.AuthType == APIAuthType.TwitterComCookie)
1115             {
1116                 var request = new SearchTimelineRequest(tab.SearchWords)
1117                 {
1118                     Count = count,
1119                     Cursor = more ? tab.CursorBottom : tab.CursorTop,
1120                 };
1121                 var response = await request.Send(this.Api.Connection)
1122                     .ConfigureAwait(false);
1123
1124                 statuses = response.ToTwitterStatuses();
1125
1126                 tab.CursorBottom = response.CursorBottom;
1127
1128                 if (!more)
1129                     tab.CursorTop = response.CursorTop;
1130             }
1131             else
1132             {
1133                 TwitterStatusId? maxId = null;
1134                 TwitterStatusId? sinceId = null;
1135                 if (more)
1136                 {
1137                     maxId = tab.OldestId as TwitterStatusId;
1138                 }
1139                 else
1140                 {
1141                     sinceId = tab.SinceId as TwitterStatusId;
1142                 }
1143
1144                 var searchResult = await this.Api.SearchTweets(tab.SearchWords, tab.SearchLang, count, maxId, sinceId)
1145                     .ConfigureAwait(false);
1146
1147                 statuses = searchResult.Statuses;
1148             }
1149
1150             if (!TabInformations.GetInstance().ContainsTab(tab))
1151                 return;
1152
1153             var minimumId = this.CreatePostsFromSearchJson(statuses, tab, read, more);
1154
1155             if (minimumId != null)
1156                 tab.OldestId = minimumId;
1157         }
1158
1159         public async Task GetDirectMessageEvents(bool read, DirectMessagesTabModel dmTab, bool backward)
1160         {
1161             this.CheckAccountState();
1162             this.CheckAccessLevel(TwitterApiAccessLevel.ReadWriteAndDirectMessage);
1163
1164             var count = 50;
1165
1166             TwitterMessageEventList eventList;
1167             if (backward)
1168             {
1169                 eventList = await this.Api.DirectMessagesEventsList(count, dmTab.NextCursor)
1170                     .ConfigureAwait(false);
1171             }
1172             else
1173             {
1174                 eventList = await this.Api.DirectMessagesEventsList(count)
1175                     .ConfigureAwait(false);
1176             }
1177
1178             dmTab.NextCursor = eventList.NextCursor;
1179
1180             await this.CreateDirectMessagesEventFromJson(eventList, read)
1181                 .ConfigureAwait(false);
1182         }
1183
1184         private async Task CreateDirectMessagesEventFromJson(TwitterMessageEventSingle eventSingle, bool read)
1185         {
1186             var eventList = new TwitterMessageEventList
1187             {
1188                 Apps = new Dictionary<string, TwitterMessageEventList.App>(),
1189                 Events = new[] { eventSingle.Event },
1190             };
1191
1192             await this.CreateDirectMessagesEventFromJson(eventList, read)
1193                 .ConfigureAwait(false);
1194         }
1195
1196         private async Task CreateDirectMessagesEventFromJson(TwitterMessageEventList eventList, bool read)
1197         {
1198             var events = eventList.Events
1199                 .Where(x => x.Type == "message_create")
1200                 .ToArray();
1201
1202             if (events.Length == 0)
1203                 return;
1204
1205             var userIds = Enumerable.Concat(
1206                 events.Select(x => x.MessageCreate.SenderId),
1207                 events.Select(x => x.MessageCreate.Target.RecipientId)
1208             ).Distinct().ToArray();
1209
1210             var users = (await this.Api.UsersLookup(userIds).ConfigureAwait(false))
1211                 .ToDictionary(x => x.IdStr);
1212
1213             var apps = eventList.Apps ?? new Dictionary<string, TwitterMessageEventList.App>();
1214
1215             this.CreateDirectMessagesEventFromJson(events, users, apps, read);
1216         }
1217
1218         private void CreateDirectMessagesEventFromJson(
1219             IEnumerable<TwitterMessageEvent> events,
1220             IReadOnlyDictionary<string, TwitterUser> users,
1221             IReadOnlyDictionary<string, TwitterMessageEventList.App> apps,
1222             bool read)
1223         {
1224             var dmTab = TabInformations.GetInstance().DirectMessageTab;
1225
1226             foreach (var eventItem in events)
1227             {
1228                 var post = this.postFactory.CreateFromDirectMessageEvent(eventItem, users, apps, this.UserId);
1229                 _ = this.urlExpander.Expand(post);
1230
1231                 post.IsRead = read;
1232                 if (post.IsMe && !read && this.ReadOwnPost)
1233                     post.IsRead = true;
1234
1235                 dmTab.AddPostQueue(post);
1236             }
1237         }
1238
1239         public async Task GetFavoritesApi(bool read, FavoritesTabModel tab, bool backward)
1240         {
1241             this.CheckAccountState();
1242
1243             var count = GetApiResultCount(MyCommon.WORKERTYPE.Favorites, backward, false);
1244
1245             TwitterStatus[] statuses;
1246             if (backward)
1247             {
1248                 statuses = await this.Api.FavoritesList(count, maxId: tab.OldestId)
1249                     .ConfigureAwait(false);
1250             }
1251             else
1252             {
1253                 statuses = await this.Api.FavoritesList(count)
1254                     .ConfigureAwait(false);
1255             }
1256
1257             var minimumId = this.CreateFavoritePostsFromJson(statuses, read);
1258
1259             if (minimumId != null)
1260                 tab.OldestId = minimumId.Value;
1261         }
1262
1263         /// <summary>
1264         /// フォロワーIDを更新します
1265         /// </summary>
1266         /// <exception cref="WebApiException"/>
1267         public async Task RefreshFollowerIds()
1268         {
1269             if (MyCommon.EndingFlag) return;
1270
1271             var cursor = -1L;
1272             var newFollowerIds = Enumerable.Empty<long>();
1273             do
1274             {
1275                 var ret = await this.Api.FollowersIds(cursor)
1276                     .ConfigureAwait(false);
1277
1278                 if (ret.Ids == null)
1279                     throw new WebApiException("ret.ids == null");
1280
1281                 newFollowerIds = newFollowerIds.Concat(ret.Ids);
1282                 cursor = ret.NextCursor;
1283             }
1284             while (cursor != 0);
1285
1286             this.followerId = newFollowerIds.ToHashSet();
1287             TabInformations.GetInstance().RefreshOwl(this.followerId);
1288
1289             this.GetFollowersSuccess = true;
1290         }
1291
1292         /// <summary>
1293         /// RT 非表示ユーザーを更新します
1294         /// </summary>
1295         /// <exception cref="WebApiException"/>
1296         public async Task RefreshNoRetweetIds()
1297         {
1298             if (MyCommon.EndingFlag) return;
1299
1300             this.noRTId = await this.Api.NoRetweetIds()
1301                 .ConfigureAwait(false);
1302
1303             this.GetNoRetweetSuccess = true;
1304         }
1305
1306         /// <summary>
1307         /// t.co の文字列長などの設定情報を更新します
1308         /// </summary>
1309         /// <exception cref="WebApiException"/>
1310         public async Task RefreshConfiguration()
1311         {
1312             this.Configuration = await this.Api.Configuration()
1313                 .ConfigureAwait(false);
1314
1315             // TextConfiguration 相当の JSON を得る API が存在しないため、TransformedURLLength のみ help/configuration.json に合わせて更新する
1316             this.TextConfiguration.TransformedURLLength = this.Configuration.ShortUrlLengthHttps;
1317         }
1318
1319         public async Task GetListsApi()
1320         {
1321             this.CheckAccountState();
1322
1323             var ownedLists = await TwitterLists.GetAllItemsAsync(x =>
1324                 this.Api.ListsOwnerships(this.Username, cursor: x, count: 1000))
1325                     .ConfigureAwait(false);
1326
1327             var subscribedLists = await TwitterLists.GetAllItemsAsync(x =>
1328                 this.Api.ListsSubscriptions(this.Username, cursor: x, count: 1000))
1329                     .ConfigureAwait(false);
1330
1331             TabInformations.GetInstance().SubscribableLists = Enumerable.Concat(ownedLists, subscribedLists)
1332                 .Select(x => new ListElement(x, this))
1333                 .ToList();
1334         }
1335
1336         public async Task DeleteList(long listId)
1337         {
1338             await this.Api.ListsDestroy(listId)
1339                 .IgnoreResponse()
1340                 .ConfigureAwait(false);
1341
1342             var tabinfo = TabInformations.GetInstance();
1343
1344             tabinfo.SubscribableLists = tabinfo.SubscribableLists
1345                 .Where(x => x.Id != listId)
1346                 .ToList();
1347         }
1348
1349         public async Task<ListElement> EditList(long listId, string new_name, bool isPrivate, string description)
1350         {
1351             using var response = await this.Api.ListsUpdate(listId, new_name, description, isPrivate)
1352                 .ConfigureAwait(false);
1353
1354             var list = await response.LoadJsonAsync()
1355                 .ConfigureAwait(false);
1356
1357             return new ListElement(list, this);
1358         }
1359
1360         public async Task<long> GetListMembers(long listId, List<UserInfo> lists, long cursor)
1361         {
1362             this.CheckAccountState();
1363
1364             var users = await this.Api.ListsMembers(listId, cursor)
1365                 .ConfigureAwait(false);
1366
1367             Array.ForEach(users.Users, u => lists.Add(new UserInfo(u)));
1368
1369             return users.NextCursor;
1370         }
1371
1372         public async Task CreateListApi(string listName, bool isPrivate, string description)
1373         {
1374             this.CheckAccountState();
1375
1376             using var response = await this.Api.ListsCreate(listName, description, isPrivate)
1377                 .ConfigureAwait(false);
1378
1379             var list = await response.LoadJsonAsync()
1380                 .ConfigureAwait(false);
1381
1382             TabInformations.GetInstance().SubscribableLists.Add(new ListElement(list, this));
1383         }
1384
1385         public async Task<bool> ContainsUserAtList(long listId, string user)
1386         {
1387             this.CheckAccountState();
1388
1389             try
1390             {
1391                 await this.Api.ListsMembersShow(listId, user)
1392                     .ConfigureAwait(false);
1393
1394                 return true;
1395             }
1396             catch (TwitterApiException ex)
1397                 when (ex.Errors.Any(x => x.Code == TwitterErrorCode.NotFound))
1398             {
1399                 return false;
1400             }
1401         }
1402
1403         public async Task<TwitterApiStatus?> GetInfoApi()
1404         {
1405             if (Twitter.AccountState != MyCommon.ACCOUNT_STATE.Valid) return null;
1406
1407             if (MyCommon.EndingFlag) return null;
1408
1409             var limits = await this.Api.ApplicationRateLimitStatus()
1410                 .ConfigureAwait(false);
1411
1412             MyCommon.TwitterApiInfo.UpdateFromJson(limits);
1413
1414             return MyCommon.TwitterApiInfo;
1415         }
1416
1417         /// <summary>
1418         /// ブロック中のユーザーを更新します
1419         /// </summary>
1420         /// <exception cref="WebApiException"/>
1421         public async Task RefreshBlockIds()
1422         {
1423             if (MyCommon.EndingFlag) return;
1424
1425             var cursor = -1L;
1426             var newBlockIds = Enumerable.Empty<long>();
1427             do
1428             {
1429                 var ret = await this.Api.BlocksIds(cursor)
1430                     .ConfigureAwait(false);
1431
1432                 newBlockIds = newBlockIds.Concat(ret.Ids);
1433                 cursor = ret.NextCursor;
1434             }
1435             while (cursor != 0);
1436
1437             var blockIdsSet = newBlockIds.ToHashSet();
1438             blockIdsSet.Remove(this.UserId); // 元のソースにあったので一応残しておく
1439
1440             TabInformations.GetInstance().BlockIds = blockIdsSet;
1441         }
1442
1443         /// <summary>
1444         /// ミュート中のユーザーIDを更新します
1445         /// </summary>
1446         /// <exception cref="WebApiException"/>
1447         public async Task RefreshMuteUserIdsAsync()
1448         {
1449             if (MyCommon.EndingFlag) return;
1450
1451             var ids = await TwitterIds.GetAllItemsAsync(x => this.Api.MutesUsersIds(x))
1452                 .ConfigureAwait(false);
1453
1454             TabInformations.GetInstance().MuteUserIds = ids.ToHashSet();
1455         }
1456
1457         public string[] GetHashList()
1458             => this.postFactory.GetReceivedHashtags();
1459
1460         private void CheckAccountState()
1461         {
1462             if (Twitter.AccountState != MyCommon.ACCOUNT_STATE.Valid)
1463                 throw new WebApiException("Auth error. Check your account");
1464         }
1465
1466         private void CheckAccessLevel(TwitterApiAccessLevel accessLevelFlags)
1467         {
1468             if (!this.AccessLevel.HasFlag(accessLevelFlags))
1469                 throw new WebApiException("Auth Err:try to re-authorization.");
1470         }
1471
1472         public int GetTextLengthRemain(string postText)
1473         {
1474             var matchDm = Twitter.DMSendTextRegex.Match(postText);
1475             if (matchDm.Success)
1476                 return this.GetTextLengthRemainDM(matchDm.Groups["body"].Value);
1477
1478             return this.GetTextLengthRemainWeighted(postText);
1479         }
1480
1481         private int GetTextLengthRemainDM(string postText)
1482         {
1483             var textLength = 0;
1484
1485             var pos = 0;
1486             while (pos < postText.Length)
1487             {
1488                 textLength++;
1489
1490                 if (char.IsSurrogatePair(postText, pos))
1491                     pos += 2; // サロゲートペアの場合は2文字分進める
1492                 else
1493                     pos++;
1494             }
1495
1496             var urls = TweetExtractor.ExtractUrls(postText);
1497             foreach (var url in urls)
1498             {
1499                 var shortUrlLength = url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
1500                     ? this.Configuration.ShortUrlLengthHttps
1501                     : this.Configuration.ShortUrlLength;
1502
1503                 textLength += shortUrlLength - url.Length;
1504             }
1505
1506             return this.Configuration.DmTextCharacterLimit - textLength;
1507         }
1508
1509         private int GetTextLengthRemainWeighted(string postText)
1510         {
1511             var config = this.TextConfiguration;
1512             var totalWeight = 0;
1513
1514             int GetWeightFromCodepoint(int codepoint)
1515             {
1516                 foreach (var weightRange in config.Ranges)
1517                 {
1518                     if (codepoint >= weightRange.Start && codepoint <= weightRange.End)
1519                         return weightRange.Weight;
1520                 }
1521
1522                 return config.DefaultWeight;
1523             }
1524
1525             var urls = TweetExtractor.ExtractUrlEntities(postText).ToArray();
1526             var emojis = config.EmojiParsingEnabled
1527                 ? TweetExtractor.ExtractEmojiEntities(postText).ToArray()
1528                 : Array.Empty<TwitterEntityEmoji>();
1529
1530             var codepoints = postText.ToCodepoints().ToArray();
1531             var index = 0;
1532             while (index < codepoints.Length)
1533             {
1534                 var urlEntity = urls.FirstOrDefault(x => x.Indices[0] == index);
1535                 if (urlEntity != null)
1536                 {
1537                     totalWeight += config.TransformedURLLength * config.Scale;
1538                     index = urlEntity.Indices[1];
1539                     continue;
1540                 }
1541
1542                 var emojiEntity = emojis.FirstOrDefault(x => x.Indices[0] == index);
1543                 if (emojiEntity != null)
1544                 {
1545                     totalWeight += GetWeightFromCodepoint(codepoints[index]);
1546                     index = emojiEntity.Indices[1];
1547                     continue;
1548                 }
1549
1550                 var codepoint = codepoints[index];
1551                 totalWeight += GetWeightFromCodepoint(codepoint);
1552
1553                 index++;
1554             }
1555
1556             var remainWeight = config.MaxWeightedTweetLength * config.Scale - totalWeight;
1557
1558             return remainWeight / config.Scale;
1559         }
1560
1561         /// <summary>
1562         /// プロフィール画像のサイズを指定したURLを生成
1563         /// </summary>
1564         /// <remarks>
1565         /// https://developer.twitter.com/en/docs/twitter-api/v1/accounts-and-users/user-profile-images-and-banners を参照
1566         /// </remarks>
1567         public static string CreateProfileImageUrl(string normalUrl, string size)
1568         {
1569             return size switch
1570             {
1571                 "original" => normalUrl.Replace("_normal.", "."),
1572                 "normal" => normalUrl,
1573                 "bigger" or "mini" => normalUrl.Replace("_normal.", $"_{size}."),
1574                 _ => throw new ArgumentException($"Invalid size: ${size}", nameof(size)),
1575             };
1576         }
1577
1578         public static string DecideProfileImageSize(int sizePx)
1579         {
1580             return sizePx switch
1581             {
1582                 <= 24 => "mini",
1583                 <= 48 => "normal",
1584                 <= 73 => "bigger",
1585                 _ => "original",
1586             };
1587         }
1588
1589         public bool IsDisposed { get; private set; } = false;
1590
1591         protected virtual void Dispose(bool disposing)
1592         {
1593             if (this.IsDisposed)
1594                 return;
1595
1596             if (disposing)
1597             {
1598                 this.Api.Dispose();
1599             }
1600
1601             this.IsDisposed = true;
1602         }
1603
1604         public void Dispose()
1605         {
1606             this.Dispose(true);
1607             GC.SuppressFinalize(this);
1608         }
1609     }
1610 }