OSDN Git Service

Cookieを使用してAPI v1.1のユーザータイムラインにアクセスする際に必要だった制限を削除
[opentween/open-tween.git] / OpenTween / Twitter.cs
1 // OpenTween - Client of Twitter
2 // Copyright (c) 2007-2011 kiri_feather (@kiri_feather) <kiri.feather@gmail.com>
3 //           (c) 2008-2011 Moz (@syo68k)
4 //           (c) 2008-2011 takeshik (@takeshik) <http://www.takeshik.org/>
5 //           (c) 2010-2011 anis774 (@anis774) <http://d.hatena.ne.jp/anis774/>
6 //           (c) 2010-2011 fantasticswallow (@f_swallow) <http://twitter.com/f_swallow>
7 //           (c) 2011      Egtra (@egtra) <http://dev.activebasic.com/egtra/>
8 //           (c) 2013      kim_upsilon (@kim_upsilon) <https://upsilo.net/~upsilon/>
9 // All rights reserved.
10 //
11 // This file is part of OpenTween.
12 //
13 // This program is free software; you can redistribute it and/or modify it
14 // under the terms of the GNU General Public License as published by the Free
15 // Software Foundation; either version 3 of the License, or (at your option)
16 // any later version.
17 //
18 // This program is distributed in the hope that it will be useful, but
19 // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
20 // or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
21 // for more details.
22 //
23 // You should have received a copy of the GNU General Public License along
24 // with this program. If not, see <http://www.gnu.org/licenses/>, or write to
25 // the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
26 // Boston, MA 02110-1301, USA.
27
28 #nullable enable
29
30 using System;
31 using System.Collections.Generic;
32 using System.Diagnostics;
33 using System.IO;
34 using System.Linq;
35 using System.Net;
36 using System.Net.Http;
37 using System.Reflection;
38 using System.Runtime.CompilerServices;
39 using System.Text;
40 using System.Text.RegularExpressions;
41 using System.Threading;
42 using System.Threading.Tasks;
43 using System.Windows.Forms;
44 using OpenTween.Api;
45 using OpenTween.Api.DataModel;
46 using OpenTween.Api.GraphQL;
47 using OpenTween.Api.TwitterV2;
48 using OpenTween.Connection;
49 using OpenTween.Models;
50 using OpenTween.Setting;
51
52 namespace OpenTween
53 {
54     public class Twitter : IDisposable
55     {
56         #region Regexp from twitter-text-js
57
58         // The code in this region code block incorporates works covered by
59         // the following copyright and permission notices:
60         //
61         //   Copyright 2011 Twitter, Inc.
62         //
63         //   Licensed under the Apache License, Version 2.0 (the "License"); you
64         //   may not use this work except in compliance with the License. You
65         //   may obtain a copy of the License in the LICENSE file, or at:
66         //
67         //   http://www.apache.org/licenses/LICENSE-2.0
68         //
69         //   Unless required by applicable law or agreed to in writing, software
70         //   distributed under the License is distributed on an "AS IS" BASIS,
71         //   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
72         //   implied. See the License for the specific language governing
73         //   permissions and limitations under the License.
74
75         // Hashtag用正規表現
76         private const string LatinAccents = @"\u00c0-\u00d6\u00d8-\u00f6\u00f8-\u00ff\u0100-\u024f\u0253\u0254\u0256\u0257\u0259\u025b\u0263\u0268\u026f\u0272\u0289\u028b\u02bb\u1e00-\u1eff";
77         private const string NonLatinHashtagChars = @"\u0400-\u04ff\u0500-\u0527\u1100-\u11ff\u3130-\u3185\uA960-\uA97F\uAC00-\uD7AF\uD7B0-\uD7FF";
78         private const string CJHashtagCharacters = @"\u30A1-\u30FA\u30FC\u3005\uFF66-\uFF9F\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\u3041-\u309A\u3400-\u4DBF\p{IsCJKUnifiedIdeographs}";
79         private const string HashtagBoundary = @"^|$|\s|「|」|。|\.|!";
80         private const string HashtagAlpha = $"[A-Za-z_{LatinAccents}{NonLatinHashtagChars}{CJHashtagCharacters}]";
81         private const string HashtagAlphanumeric = $"[A-Za-z0-9_{LatinAccents}{NonLatinHashtagChars}{CJHashtagCharacters}]";
82         private const string HashtagTerminator = $"[^A-Za-z0-9_{LatinAccents}{NonLatinHashtagChars}{CJHashtagCharacters}]";
83         public const string Hashtag = $"({HashtagBoundary})(#|#)({HashtagAlphanumeric}*{HashtagAlpha}{HashtagAlphanumeric}*)(?={HashtagTerminator}|{HashtagBoundary})";
84         // URL正規表現
85         private const string UrlValidPrecedingChars = @"(?:[^A-Za-z0-9@@$##\ufffe\ufeff\uffff\u202a-\u202e]|^)";
86         public const string UrlInvalidWithoutProtocolPrecedingChars = @"[-_./]$";
87         private const string UrlInvalidDomainChars = @"\!'#%&'\(\)*\+,\\\-\.\/:;<=>\?@\[\]\^_{|}~\$\u2000-\u200a\u0009-\u000d\u0020\u0085\u00a0\u1680\u180e\u2028\u2029\u202f\u205f\u3000\ufffe\ufeff\uffff\u202a-\u202e";
88         private const string UrlValidDomainChars = $@"[^{UrlInvalidDomainChars}]";
89         private const string UrlValidSubdomain = $@"(?:(?:{UrlValidDomainChars}(?:[_-]|{UrlValidDomainChars})*)?{UrlValidDomainChars}\.)";
90         private const string UrlValidDomainName = $@"(?:(?:{UrlValidDomainChars}(?:-|{UrlValidDomainChars})*)?{UrlValidDomainChars}\.)";
91         private const string UrlValidGTLD = @"(?:(?:aero|asia|biz|cat|com|coop|edu|gov|info|int|jobs|mil|mobi|museum|name|net|org|pro|tel|travel|xxx)(?=[^0-9a-zA-Z]|$))";
92         private const string UrlValidCCTLD = @"(?:(?:ac|ad|ae|af|ag|ai|al|am|an|ao|aq|ar|as|at|au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|bi|bj|bm|bn|bo|br|bs|bt|bv|bw|by|bz|ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|co|cr|cs|cu|cv|cx|cy|cz|dd|de|dj|dk|dm|do|dz|ec|ee|eg|eh|er|es|et|eu|fi|fj|fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|in|io|iq|ir|is|it|je|jm|jo|jp|ke|kg|kh|ki|km|kn|kp|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|me|mg|mh|mk|ml|mm|mn|mo|mp|mq|mr|ms|mt|mu|mv|mw|mx|my|mz|na|nc|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|pa|pe|pf|pg|ph|pk|pl|pm|pn|pr|ps|pt|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|sk|sl|sm|sn|so|sr|ss|st|su|sv|sy|sz|tc|td|tf|tg|th|tj|tk|tl|tm|tn|to|tp|tr|tt|tv|tw|tz|ua|ug|uk|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|ye|yt|za|zm|zw)(?=[^0-9a-zA-Z]|$))";
93         private const string UrlValidPunycode = @"(?:xn--[0-9a-z]+)";
94         private const string UrlValidDomain = $@"(?<domain>{UrlValidSubdomain}*{UrlValidDomainName}(?:{UrlValidGTLD}|{UrlValidCCTLD})|{UrlValidPunycode})";
95         public const string UrlValidAsciiDomain = $@"(?:(?:[a-z0-9{LatinAccents}]+)\.)+(?:{UrlValidGTLD}|{UrlValidCCTLD}|{UrlValidPunycode})";
96         public const string UrlInvalidShortDomain = $"^{UrlValidDomainName}{UrlValidCCTLD}$";
97         private const string UrlValidPortNumber = @"[0-9]+";
98
99         private const string UrlValidGeneralPathChars = $@"[a-z0-9!*';:=+,.$/%#\[\]\-_~|&{LatinAccents}]";
100         private const string UrlBalanceParens = $@"(?:\({UrlValidGeneralPathChars}+\))";
101         private const string UrlValidPathEndingChars = $@"(?:[+\-a-z0-9=_#/{LatinAccents}]|{UrlBalanceParens})";
102         private const string Pth = "(?:" +
103             "(?:" +
104                 $"{UrlValidGeneralPathChars}*" +
105                 $"(?:{UrlBalanceParens}{UrlValidGeneralPathChars}*)*" +
106                 UrlValidPathEndingChars +
107                 $")|(?:@{UrlValidGeneralPathChars}+/)" +
108             ")";
109
110         private const string Qry = @"(?<query>\?[a-z0-9!?*'();:&=+$/%#\[\]\-_.,~|]*[a-z0-9_&=#/])?";
111         public const string RgUrl = $@"(?<before>{UrlValidPrecedingChars})" +
112                                     "(?<url>(?<protocol>https?://)?" +
113                                     $"(?<domain>{UrlValidDomain})" +
114                                     $"(?::{UrlValidPortNumber})?" +
115                                     $"(?<path>/{Pth}*)?" +
116                                     Qry +
117                                     ")";
118
119         #endregion
120
121         /// <summary>
122         /// Twitter API のステータスページのURL
123         /// </summary>
124         public const string ServiceAvailabilityStatusUrl = "https://api.twitterstat.us/";
125
126         /// <summary>
127         /// ツイートへのパーマリンクURLを判定する正規表現
128         /// </summary>
129         public static readonly Regex StatusUrlRegex = new(@"https?://([^.]+\.)?twitter\.com/(#!/)?(?<ScreenName>[a-zA-Z0-9_]+)/status(es)?/(?<StatusId>[0-9]+)(/photo)?", RegexOptions.IgnoreCase);
130
131         /// <summary>
132         /// attachment_url に指定可能な URL を判定する正規表現
133         /// </summary>
134         public static readonly Regex AttachmentUrlRegex = new(
135             @"https?://(
136    twitter\.com/[0-9A-Za-z_]+/status/[0-9]+
137  | mobile\.twitter\.com/[0-9A-Za-z_]+/status/[0-9]+
138  | twitter\.com/messages/compose\?recipient_id=[0-9]+(&.+)?
139 )$",
140             RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace);
141
142         /// <summary>
143         /// FavstarやaclogなどTwitter関連サービスのパーマリンクURLからステータスIDを抽出する正規表現
144         /// </summary>
145         public static readonly Regex ThirdPartyStatusUrlRegex = new(
146             @"https?://(?:[^.]+\.)?(?:
147   favstar\.fm/users/[a-zA-Z0-9_]+/status/       # Favstar
148 | favstar\.fm/t/                                # Favstar (short)
149 | aclog\.koba789\.com/i/                        # aclog
150 | frtrt\.net/solo_status\.php\?status=          # RtRT
151 )(?<StatusId>[0-9]+)",
152             RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace);
153
154         /// <summary>
155         /// DM送信かどうかを判定する正規表現
156         /// </summary>
157         public static readonly Regex DMSendTextRegex = new(@"^DM? +(?<id>[a-zA-Z0-9_]+) +(?<body>.*)", RegexOptions.IgnoreCase | RegexOptions.Singleline);
158
159         public TwitterApi Api { get; }
160
161         public TwitterConfiguration Configuration { get; private set; }
162
163         public TwitterTextConfiguration TextConfiguration { get; private set; }
164
165         public bool GetFollowersSuccess { get; private set; } = false;
166
167         public bool GetNoRetweetSuccess { get; private set; } = false;
168
169         private delegate void GetIconImageDelegate(PostClass post);
170
171         private readonly object lockObj = new();
172         private ISet<long> followerId = new HashSet<long>();
173         private long[] noRTId = Array.Empty<long>();
174
175         private readonly TwitterPostFactory postFactory;
176
177         private string? previousStatusId = null;
178
179         public Twitter(TwitterApi api)
180         {
181             this.postFactory = new(TabInformations.GetInstance());
182
183             this.Api = api;
184             this.Configuration = TwitterConfiguration.DefaultConfiguration();
185             this.TextConfiguration = TwitterTextConfiguration.DefaultConfiguration();
186         }
187
188         public TwitterApiAccessLevel AccessLevel
189             => MyCommon.TwitterApiInfo.AccessLevel;
190
191         protected void ResetApiStatus()
192             => MyCommon.TwitterApiInfo.Reset();
193
194         public void ClearAuthInfo()
195         {
196             Twitter.AccountState = MyCommon.ACCOUNT_STATE.Invalid;
197             this.ResetApiStatus();
198         }
199
200         public void VerifyCredentials()
201         {
202             try
203             {
204                 this.VerifyCredentialsAsync().Wait();
205             }
206             catch (AggregateException ex) when (ex.InnerException is WebApiException)
207             {
208                 throw new WebApiException(ex.InnerException.Message, ex);
209             }
210         }
211
212         public async Task VerifyCredentialsAsync()
213         {
214             var user = await this.Api.AccountVerifyCredentials()
215                 .ConfigureAwait(false);
216
217             this.UpdateUserStats(user);
218         }
219
220         public void Initialize(string token, string tokenSecret, string username, long userId)
221         {
222             // OAuth認証
223             if (MyCommon.IsNullOrEmpty(token) || MyCommon.IsNullOrEmpty(tokenSecret) || MyCommon.IsNullOrEmpty(username))
224             {
225                 Twitter.AccountState = MyCommon.ACCOUNT_STATE.Invalid;
226             }
227             this.ResetApiStatus();
228             this.Api.Initialize(token, tokenSecret, userId, username);
229         }
230
231         public void Initialize(TwitterAppToken appToken, string token, string tokenSecret, string username, long userId)
232         {
233             // OAuth認証
234             if (MyCommon.IsNullOrEmpty(token) || MyCommon.IsNullOrEmpty(tokenSecret) || MyCommon.IsNullOrEmpty(username))
235             {
236                 Twitter.AccountState = MyCommon.ACCOUNT_STATE.Invalid;
237             }
238             this.ResetApiStatus();
239             this.Api.Initialize(appToken, token, tokenSecret, userId, username);
240         }
241
242         public async Task<PostClass?> PostStatus(PostStatusParams param)
243         {
244             this.CheckAccountState();
245
246             if (Twitter.DMSendTextRegex.IsMatch(param.Text))
247             {
248                 var mediaId = param.MediaIds != null && param.MediaIds.Any() ? param.MediaIds[0] : (long?)null;
249
250                 await this.SendDirectMessage(param.Text, mediaId)
251                     .ConfigureAwait(false);
252                 return null;
253             }
254
255             TwitterStatus status;
256
257             if (this.Api.AppToken.AuthType == APIAuthType.TwitterComCookie)
258             {
259                 var request = new CreateTweetRequest
260                 {
261                     TweetText = param.Text,
262                     InReplyToTweetId = param.InReplyToStatusId?.ToTwitterStatusId(),
263                     ExcludeReplyUserIds = param.ExcludeReplyUserIds.Select(x => x.ToString()).ToArray(),
264                     MediaIds = param.MediaIds.Select(x => x.ToString()).ToArray(),
265                     AttachmentUrl = param.AttachmentUrl,
266                 };
267
268                 status = await request.Send(this.Api.Connection)
269                     .ConfigureAwait(false);
270             }
271             else
272             {
273                 var response = await this.Api.StatusesUpdate(
274                         param.Text,
275                         param.InReplyToStatusId?.ToTwitterStatusId(),
276                         param.MediaIds,
277                         param.AutoPopulateReplyMetadata,
278                         param.ExcludeReplyUserIds,
279                         param.AttachmentUrl
280                     )
281                     .ConfigureAwait(false);
282
283                 status = await response.LoadJsonAsync()
284                     .ConfigureAwait(false);
285             }
286
287             this.UpdateUserStats(status.User);
288
289             if (status.IdStr == this.previousStatusId)
290                 throw new WebApiException("OK:Delaying?");
291
292             this.previousStatusId = status.IdStr;
293
294             // 投稿したものを返す
295             var post = this.CreatePostsFromStatusData(status);
296             if (this.ReadOwnPost) post.IsRead = true;
297             return post;
298         }
299
300         public async Task DeleteTweet(TwitterStatusId tweetId)
301         {
302             if (this.Api.AppToken.AuthType == APIAuthType.TwitterComCookie)
303             {
304                 var request = new DeleteTweetRequest
305                 {
306                     TweetId = tweetId,
307                 };
308                 await request.Send(this.Api.Connection);
309             }
310             else
311             {
312                 await this.Api.StatusesDestroy(tweetId)
313                     .IgnoreResponse();
314             }
315         }
316
317         public async Task<long> UploadMedia(IMediaItem item, string? mediaCategory = null)
318         {
319             this.CheckAccountState();
320
321             var mediaType = item.Extension switch
322             {
323                 ".png" => "image/png",
324                 ".jpg" => "image/jpeg",
325                 ".jpeg" => "image/jpeg",
326                 ".gif" => "image/gif",
327                 _ => "application/octet-stream",
328             };
329
330             var initResponse = await this.Api.MediaUploadInit(item.Size, mediaType, mediaCategory)
331                 .ConfigureAwait(false);
332
333             var initMedia = await initResponse.LoadJsonAsync()
334                 .ConfigureAwait(false);
335
336             var mediaId = initMedia.MediaId;
337
338             await this.Api.MediaUploadAppend(mediaId, 0, item)
339                 .ConfigureAwait(false);
340
341             var response = await this.Api.MediaUploadFinalize(mediaId)
342                 .ConfigureAwait(false);
343
344             var media = await response.LoadJsonAsync()
345                 .ConfigureAwait(false);
346
347             while (media.ProcessingInfo is TwitterUploadMediaResult.MediaProcessingInfo processingInfo)
348             {
349                 switch (processingInfo.State)
350                 {
351                     case "pending":
352                         break;
353                     case "in_progress":
354                         break;
355                     case "succeeded":
356                         goto succeeded;
357                     case "failed":
358                         throw new WebApiException($"Err:Upload failed ({processingInfo.Error?.Name})");
359                     default:
360                         throw new WebApiException($"Err:Invalid state ({processingInfo.State})");
361                 }
362
363                 await Task.Delay(TimeSpan.FromSeconds(processingInfo.CheckAfterSecs ?? 5))
364                     .ConfigureAwait(false);
365
366                 media = await this.Api.MediaUploadStatus(mediaId)
367                     .ConfigureAwait(false);
368             }
369
370             succeeded:
371             return media.MediaId;
372         }
373
374         public async Task SendDirectMessage(string postStr, long? mediaId = null)
375         {
376             this.CheckAccountState();
377             this.CheckAccessLevel(TwitterApiAccessLevel.ReadWriteAndDirectMessage);
378
379             var mc = Twitter.DMSendTextRegex.Match(postStr);
380
381             var body = mc.Groups["body"].Value;
382             var recipientName = mc.Groups["id"].Value;
383
384             var recipient = await this.GetUserInfo(recipientName)
385                 .ConfigureAwait(false);
386
387             var response = await this.Api.DirectMessagesEventsNew(recipient.Id, body, mediaId)
388                 .ConfigureAwait(false);
389
390             var messageEventSingle = await response.LoadJsonAsync()
391                 .ConfigureAwait(false);
392
393             await this.CreateDirectMessagesEventFromJson(messageEventSingle, read: true)
394                 .ConfigureAwait(false);
395         }
396
397         public async Task<PostClass?> PostRetweet(PostId id, bool read)
398         {
399             this.CheckAccountState();
400
401             // データ部分の生成
402             var post = TabInformations.GetInstance()[id];
403             if (post == null)
404                 throw new WebApiException("Err:Target isn't found.");
405
406             var target = post.RetweetedId ?? id;  // 再RTの場合は元発言をRT
407
408             if (this.Api.AppToken.AuthType == APIAuthType.TwitterComCookie)
409             {
410                 var request = new CreateRetweetRequest
411                 {
412                     TweetId = target.ToTwitterStatusId(),
413                 };
414                 await request.Send(this.Api.Connection).ConfigureAwait(false);
415                 return null;
416             }
417
418             var response = await this.Api.StatusesRetweet(target.ToTwitterStatusId())
419                 .ConfigureAwait(false);
420
421             var status = await response.LoadJsonAsync()
422                 .ConfigureAwait(false);
423
424             // 二重取得回避
425             lock (this.lockObj)
426             {
427                 var statusId = new TwitterStatusId(status.IdStr);
428                 if (TabInformations.GetInstance().ContainsKey(statusId))
429                     return null;
430             }
431
432             // Retweet判定
433             if (status.RetweetedStatus == null)
434                 throw new WebApiException("Invalid Json!");
435
436             // Retweetしたものを返す
437             return this.CreatePostsFromStatusData(status) with
438             {
439                 IsMe = true,
440                 IsRead = this.ReadOwnPost ? true : read,
441                 IsOwl = false,
442             };
443         }
444
445         public async Task DeleteRetweet(PostClass post)
446         {
447             if (post.RetweetedId == null)
448                 throw new ArgumentException("post is not retweeted status", nameof(post));
449
450             if (this.Api.AppToken.AuthType == APIAuthType.TwitterComCookie)
451             {
452                 var request = new DeleteRetweetRequest
453                 {
454                     SourceTweetId = post.RetweetedId.ToTwitterStatusId(),
455                 };
456                 await request.Send(this.Api.Connection).ConfigureAwait(false);
457             }
458             else
459             {
460                 await this.Api.StatusesDestroy(post.StatusId.ToTwitterStatusId())
461                     .IgnoreResponse();
462             }
463         }
464
465         public async Task<TwitterUser> GetUserInfo(string screenName)
466         {
467             if (this.Api.AppToken.AuthType == APIAuthType.TwitterComCookie)
468             {
469                 var request = new UserByScreenNameRequest
470                 {
471                     ScreenName = screenName,
472                 };
473                 var response = await request.Send(this.Api.Connection)
474                     .ConfigureAwait(false);
475
476                 return response.ToTwitterUser();
477             }
478             else
479             {
480                 var user = await this.Api.UsersShow(screenName)
481                     .ConfigureAwait(false);
482
483                 return user;
484             }
485         }
486
487         public string Username
488             => this.Api.CurrentScreenName;
489
490         public long UserId
491             => this.Api.CurrentUserId;
492
493         public static MyCommon.ACCOUNT_STATE AccountState { get; set; } = MyCommon.ACCOUNT_STATE.Valid;
494
495         public bool RestrictFavCheck { get; set; }
496
497         public bool ReadOwnPost { get; set; }
498
499         public int FollowersCount { get; private set; }
500
501         public int FriendsCount { get; private set; }
502
503         public int StatusesCount { get; private set; }
504
505         public string Location { get; private set; } = "";
506
507         public string Bio { get; private set; } = "";
508
509         /// <summary>ユーザーのフォロワー数などの情報を更新します</summary>
510         private void UpdateUserStats(TwitterUser self)
511         {
512             this.FollowersCount = self.FollowersCount;
513             this.FriendsCount = self.FriendsCount;
514             this.StatusesCount = self.StatusesCount;
515             this.Location = self.Location ?? "";
516             this.Bio = self.Description ?? "";
517         }
518
519         /// <summary>
520         /// 渡された取得件数がWORKERTYPEに応じた取得可能範囲に収まっているか検証する
521         /// </summary>
522         public static bool VerifyApiResultCount(MyCommon.WORKERTYPE type, int count)
523             => count >= 20 && count <= GetMaxApiResultCount(type);
524
525         /// <summary>
526         /// 渡された取得件数が更新時の取得可能範囲に収まっているか検証する
527         /// </summary>
528         public static bool VerifyMoreApiResultCount(int count)
529             => count >= 20 && count <= 200;
530
531         /// <summary>
532         /// 渡された取得件数が起動時の取得可能範囲に収まっているか検証する
533         /// </summary>
534         public static bool VerifyFirstApiResultCount(int count)
535             => count >= 20 && count <= 200;
536
537         /// <summary>
538         /// WORKERTYPEに応じた取得可能な最大件数を取得する
539         /// </summary>
540         public static int GetMaxApiResultCount(MyCommon.WORKERTYPE type)
541         {
542             // 参照: REST APIs - 各endpointのcountパラメータ
543             // https://dev.twitter.com/rest/public
544             return type switch
545             {
546                 MyCommon.WORKERTYPE.Timeline => 100,
547                 MyCommon.WORKERTYPE.Reply => 200,
548                 MyCommon.WORKERTYPE.UserTimeline => 200,
549                 MyCommon.WORKERTYPE.Favorites => 200,
550                 MyCommon.WORKERTYPE.List => 200, // 不明
551                 MyCommon.WORKERTYPE.PublicSearch => 100,
552                 _ => throw new InvalidOperationException("Invalid type: " + type),
553             };
554         }
555
556         /// <summary>
557         /// WORKERTYPEに応じた取得件数を取得する
558         /// </summary>
559         public static int GetApiResultCount(MyCommon.WORKERTYPE type, bool more, bool startup)
560         {
561             if (SettingManager.Instance.Common.UseAdditionalCount)
562             {
563                 switch (type)
564                 {
565                     case MyCommon.WORKERTYPE.Favorites:
566                         if (SettingManager.Instance.Common.FavoritesCountApi != 0)
567                             return SettingManager.Instance.Common.FavoritesCountApi;
568                         break;
569                     case MyCommon.WORKERTYPE.List:
570                         if (SettingManager.Instance.Common.ListCountApi != 0)
571                             return SettingManager.Instance.Common.ListCountApi;
572                         break;
573                     case MyCommon.WORKERTYPE.PublicSearch:
574                         if (SettingManager.Instance.Common.SearchCountApi != 0)
575                             return SettingManager.Instance.Common.SearchCountApi;
576                         break;
577                     case MyCommon.WORKERTYPE.UserTimeline:
578                         if (SettingManager.Instance.Common.UserTimelineCountApi != 0)
579                             return SettingManager.Instance.Common.UserTimelineCountApi;
580                         break;
581                 }
582                 if (more && SettingManager.Instance.Common.MoreCountApi != 0)
583                 {
584                     return Math.Min(SettingManager.Instance.Common.MoreCountApi, GetMaxApiResultCount(type));
585                 }
586                 if (startup && SettingManager.Instance.Common.FirstCountApi != 0 && type != MyCommon.WORKERTYPE.Reply)
587                 {
588                     return Math.Min(SettingManager.Instance.Common.FirstCountApi, GetMaxApiResultCount(type));
589                 }
590             }
591
592             // 上記に当てはまらない場合の共通処理
593             var count = SettingManager.Instance.Common.CountApi;
594
595             if (type == MyCommon.WORKERTYPE.Reply)
596                 count = SettingManager.Instance.Common.CountApiReply;
597
598             return Math.Min(count, GetMaxApiResultCount(type));
599         }
600
601         public async Task GetHomeTimelineApi(bool read, HomeTabModel tab, bool more, bool startup)
602         {
603             this.CheckAccountState();
604
605             var count = GetApiResultCount(MyCommon.WORKERTYPE.Timeline, more, startup);
606
607             TwitterStatus[] statuses;
608             if (SettingManager.Instance.Common.EnableTwitterV2Api)
609             {
610                 var request = new GetTimelineRequest(this.UserId)
611                 {
612                     MaxResults = count,
613                     UntilId = more ? tab.OldestId as TwitterStatusId : null,
614                 };
615
616                 var response = await request.Send(this.Api.Connection)
617                     .ConfigureAwait(false);
618
619                 if (response.Data == null || response.Data.Length == 0)
620                     return;
621
622                 var tweetIds = response.Data.Select(x => x.Id).ToList();
623
624                 statuses = await this.Api.StatusesLookup(tweetIds)
625                     .ConfigureAwait(false);
626             }
627             else
628             {
629                 var maxId = more ? tab.OldestId : null;
630
631                 statuses = await this.Api.StatusesHomeTimeline(count, maxId as TwitterStatusId)
632                     .ConfigureAwait(false);
633             }
634
635             var minimumId = this.CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.Timeline, tab, read);
636             if (minimumId != null)
637                 tab.OldestId = minimumId;
638         }
639
640         public async Task GetMentionsTimelineApi(bool read, MentionsTabModel tab, bool more, bool startup)
641         {
642             this.CheckAccountState();
643
644             var count = GetApiResultCount(MyCommon.WORKERTYPE.Reply, more, startup);
645
646             TwitterStatus[] statuses;
647             if (more)
648             {
649                 statuses = await this.Api.StatusesMentionsTimeline(count, maxId: tab.OldestId as TwitterStatusId)
650                     .ConfigureAwait(false);
651             }
652             else
653             {
654                 statuses = await this.Api.StatusesMentionsTimeline(count)
655                     .ConfigureAwait(false);
656             }
657
658             var minimumId = this.CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.Reply, tab, read);
659             if (minimumId != null)
660                 tab.OldestId = minimumId;
661         }
662
663         public async Task GetUserTimelineApi(bool read, UserTimelineTabModel tab, bool more)
664         {
665             this.CheckAccountState();
666
667             var count = GetApiResultCount(MyCommon.WORKERTYPE.UserTimeline, more, false);
668
669             TwitterStatus[] statuses;
670             if (this.Api.AppToken.AuthType == APIAuthType.TwitterComCookie)
671             {
672                 var userId = tab.UserId;
673                 if (MyCommon.IsNullOrEmpty(userId))
674                 {
675                     var user = await this.GetUserInfo(tab.ScreenName)
676                         .ConfigureAwait(false);
677
678                     userId = user.IdStr;
679                     tab.UserId = user.IdStr;
680                 }
681
682                 var request = new UserTweetsRequest(userId)
683                 {
684                     Count = count,
685                     Cursor = more ? tab.CursorBottom : null,
686                 };
687                 var response = await request.Send(this.Api.Connection)
688                     .ConfigureAwait(false);
689
690                 statuses = response.Tweets.Select(x => x.ToTwitterStatus()).ToArray();
691                 tab.CursorBottom = response.CursorBottom;
692             }
693             else
694             {
695                 if (more)
696                 {
697                     statuses = await this.Api.StatusesUserTimeline(tab.ScreenName, count, maxId: tab.OldestId as TwitterStatusId)
698                         .ConfigureAwait(false);
699                 }
700                 else
701                 {
702                     statuses = await this.Api.StatusesUserTimeline(tab.ScreenName, count)
703                         .ConfigureAwait(false);
704                 }
705             }
706
707             var minimumId = this.CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.UserTimeline, tab, read);
708
709             if (minimumId != null)
710                 tab.OldestId = minimumId;
711         }
712
713         public async Task<PostClass> GetStatusApi(bool read, TwitterStatusId id)
714         {
715             this.CheckAccountState();
716
717             TwitterStatus status;
718             if (this.Api.AppToken.AuthType == APIAuthType.TwitterComCookie)
719             {
720                 var request = new TweetDetailRequest
721                 {
722                     FocalTweetId = id,
723                 };
724                 var tweets = await request.Send(this.Api.Connection).ConfigureAwait(false);
725                 status = tweets.Select(x => x.ToTwitterStatus())
726                     .Where(x => x.IdStr == id.Id)
727                     .FirstOrDefault() ?? throw new WebApiException("Empty result set");
728             }
729             else
730             {
731                 status = await this.Api.StatusesShow(id)
732                     .ConfigureAwait(false);
733             }
734
735             var item = this.CreatePostsFromStatusData(status);
736
737             item.IsRead = read;
738             if (item.IsMe && !read && this.ReadOwnPost) item.IsRead = true;
739
740             return item;
741         }
742
743         public async Task GetStatusApi(bool read, TwitterStatusId id, TabModel tab)
744         {
745             var post = await this.GetStatusApi(read, id)
746                 .ConfigureAwait(false);
747
748             // 非同期アイコン取得&StatusDictionaryに追加
749             if (tab != null && tab.IsInnerStorageTabType)
750                 tab.AddPostQueue(post);
751             else
752                 TabInformations.GetInstance().AddPost(post);
753         }
754
755         private PostClass CreatePostsFromStatusData(TwitterStatus status)
756             => this.CreatePostsFromStatusData(status, favTweet: false);
757
758         private PostClass CreatePostsFromStatusData(TwitterStatus status, bool favTweet)
759             => this.postFactory.CreateFromStatus(status, this.UserId, this.followerId, favTweet);
760
761         private PostId? CreatePostsFromJson(TwitterStatus[] items, MyCommon.WORKERTYPE gType, TabModel? tab, bool read)
762         {
763             PostId? minimumId = null;
764
765             var posts = items.Select(x => this.CreatePostsFromStatusData(x)).ToArray();
766
767             TwitterPostFactory.AdjustSortKeyForPromotedPost(posts);
768
769             foreach (var post in posts)
770             {
771                 if (!post.IsPromoted)
772                 {
773                     if (minimumId == null || minimumId > post.StatusId)
774                         minimumId = post.StatusId;
775                 }
776
777                 // 二重取得回避
778                 lock (this.lockObj)
779                 {
780                     var id = post.StatusId;
781                     if (tab == null)
782                     {
783                         if (TabInformations.GetInstance().ContainsKey(id)) continue;
784                     }
785                     else
786                     {
787                         if (tab.Contains(id)) continue;
788                     }
789                 }
790
791                 // RT禁止ユーザーによるもの
792                 if (gType != MyCommon.WORKERTYPE.UserTimeline &&
793                     post.RetweetedByUserId != null && this.noRTId.Contains(post.RetweetedByUserId.Value)) continue;
794
795                 post.IsRead = read;
796                 if (post.IsMe && !read && this.ReadOwnPost) post.IsRead = true;
797
798                 if (tab != null && tab.IsInnerStorageTabType)
799                     tab.AddPostQueue(post);
800                 else
801                     TabInformations.GetInstance().AddPost(post);
802             }
803
804             return minimumId;
805         }
806
807         private PostId? CreatePostsFromSearchJson(TwitterStatus[] statuses, PublicSearchTabModel tab, bool read, bool more)
808         {
809             PostId? minimumId = null;
810
811             var posts = statuses.Select(x => this.CreatePostsFromStatusData(x)).ToArray();
812
813             TwitterPostFactory.AdjustSortKeyForPromotedPost(posts);
814
815             foreach (var post in posts)
816             {
817                 if (!post.IsPromoted)
818                 {
819                     if (minimumId == null || minimumId > post.StatusId)
820                         minimumId = post.StatusId;
821
822                     if (!more && (tab.SinceId == null || post.StatusId > tab.SinceId))
823                         tab.SinceId = post.StatusId;
824                 }
825
826                 // 二重取得回避
827                 lock (this.lockObj)
828                 {
829                     if (tab.Contains(post.StatusId))
830                         continue;
831                 }
832
833                 post.IsRead = read;
834                 if ((post.IsMe && !read) && this.ReadOwnPost) post.IsRead = true;
835
836                 tab.AddPostQueue(post);
837             }
838
839             return minimumId;
840         }
841
842         private long? CreateFavoritePostsFromJson(TwitterStatus[] items, bool read)
843         {
844             var favTab = TabInformations.GetInstance().FavoriteTab;
845             long? minimumId = null;
846
847             foreach (var status in items)
848             {
849                 if (minimumId == null || minimumId.Value > status.Id)
850                     minimumId = status.Id;
851
852                 // 二重取得回避
853                 lock (this.lockObj)
854                 {
855                     if (favTab.Contains(new TwitterStatusId(status.IdStr)))
856                         continue;
857                 }
858
859                 var post = this.CreatePostsFromStatusData(status, true);
860
861                 post.IsRead = read;
862
863                 TabInformations.GetInstance().AddPost(post);
864             }
865
866             return minimumId;
867         }
868
869         public async Task GetListStatus(bool read, ListTimelineTabModel tab, bool more, bool startup)
870         {
871             var count = GetApiResultCount(MyCommon.WORKERTYPE.List, more, startup);
872
873             TwitterStatus[] statuses;
874             if (this.Api.AppToken.AuthType == APIAuthType.TwitterComCookie)
875             {
876                 var request = new ListLatestTweetsTimelineRequest(tab.ListInfo.Id.ToString())
877                 {
878                     Count = count,
879                     Cursor = more ? tab.CursorBottom : null,
880                 };
881                 var response = await request.Send(this.Api.Connection)
882                     .ConfigureAwait(false);
883
884                 var convertedStatuses = response.Tweets.Select(x => x.ToTwitterStatus());
885                 if (!SettingManager.Instance.Common.IsListsIncludeRts)
886                     convertedStatuses = convertedStatuses.Where(x => x.RetweetedStatus == null);
887
888                 statuses = convertedStatuses.ToArray();
889                 tab.CursorBottom = response.CursorBottom;
890             }
891             else if (more)
892             {
893                 statuses = await this.Api.ListsStatuses(tab.ListInfo.Id, count, maxId: tab.OldestId as TwitterStatusId, includeRTs: SettingManager.Instance.Common.IsListsIncludeRts)
894                     .ConfigureAwait(false);
895             }
896             else
897             {
898                 statuses = await this.Api.ListsStatuses(tab.ListInfo.Id, count, includeRTs: SettingManager.Instance.Common.IsListsIncludeRts)
899                     .ConfigureAwait(false);
900             }
901
902             var minimumId = this.CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.List, tab, read);
903
904             if (minimumId != null)
905                 tab.OldestId = minimumId;
906         }
907
908         /// <summary>
909         /// startStatusId からリプライ先の発言を辿る。発言は posts 以外からは検索しない。
910         /// </summary>
911         /// <returns>posts の中から検索されたリプライチェインの末端</returns>
912         internal static PostClass FindTopOfReplyChain(IDictionary<PostId, PostClass> posts, PostId startStatusId)
913         {
914             if (!posts.ContainsKey(startStatusId))
915                 throw new ArgumentException("startStatusId (" + startStatusId.Id + ") が posts の中から見つかりませんでした。", nameof(startStatusId));
916
917             var nextPost = posts[startStatusId];
918             while (nextPost.InReplyToStatusId != null)
919             {
920                 if (!posts.ContainsKey(nextPost.InReplyToStatusId))
921                     break;
922                 nextPost = posts[nextPost.InReplyToStatusId];
923             }
924
925             return nextPost;
926         }
927
928         public async Task GetRelatedResult(bool read, RelatedPostsTabModel tab)
929         {
930             var targetPost = tab.TargetPost;
931
932             if (targetPost.RetweetedId != null)
933             {
934                 var originalPost = targetPost with
935                 {
936                     StatusId = targetPost.RetweetedId,
937                     RetweetedId = null,
938                     RetweetedBy = null,
939                 };
940                 targetPost = originalPost;
941             }
942
943             var relPosts = new Dictionary<PostId, PostClass>();
944             if (targetPost.TextFromApi.Contains("@") && targetPost.InReplyToStatusId == null)
945             {
946                 // 検索結果対応
947                 var p = TabInformations.GetInstance()[targetPost.StatusId];
948                 if (p != null && p.InReplyToStatusId != null)
949                 {
950                     targetPost = p;
951                 }
952                 else
953                 {
954                     p = await this.GetStatusApi(read, targetPost.StatusId.ToTwitterStatusId())
955                         .ConfigureAwait(false);
956                     targetPost = p;
957                 }
958             }
959             relPosts.Add(targetPost.StatusId, targetPost);
960
961             Exception? lastException = null;
962
963             // in_reply_to_status_id を使用してリプライチェインを辿る
964             var nextPost = FindTopOfReplyChain(relPosts, targetPost.StatusId);
965             var loopCount = 1;
966             while (nextPost.InReplyToStatusId != null && loopCount++ <= 20)
967             {
968                 var inReplyToId = nextPost.InReplyToStatusId;
969
970                 var inReplyToPost = TabInformations.GetInstance()[inReplyToId];
971                 if (inReplyToPost == null)
972                 {
973                     try
974                     {
975                         inReplyToPost = await this.GetStatusApi(read, inReplyToId.ToTwitterStatusId())
976                             .ConfigureAwait(false);
977                     }
978                     catch (WebApiException ex)
979                     {
980                         lastException = ex;
981                         break;
982                     }
983                 }
984
985                 relPosts.Add(inReplyToPost.StatusId, inReplyToPost);
986
987                 nextPost = FindTopOfReplyChain(relPosts, nextPost.StatusId);
988             }
989
990             // MRTとかに対応のためツイート内にあるツイートを指すURLを取り込む
991             var text = targetPost.Text;
992             var ma = Twitter.StatusUrlRegex.Matches(text).Cast<Match>()
993                 .Concat(Twitter.ThirdPartyStatusUrlRegex.Matches(text).Cast<Match>());
994             foreach (var match in ma)
995             {
996                 var statusId = new TwitterStatusId(match.Groups["StatusId"].Value);
997                 if (!relPosts.ContainsKey(statusId))
998                 {
999                     var p = TabInformations.GetInstance()[statusId];
1000                     if (p == null)
1001                     {
1002                         try
1003                         {
1004                             p = await this.GetStatusApi(read, statusId)
1005                                 .ConfigureAwait(false);
1006                         }
1007                         catch (WebApiException ex)
1008                         {
1009                             lastException = ex;
1010                             break;
1011                         }
1012                     }
1013
1014                     if (p != null)
1015                         relPosts.Add(p.StatusId, p);
1016                 }
1017             }
1018
1019             try
1020             {
1021                 var firstPost = nextPost;
1022                 var posts = await this.GetConversationPosts(firstPost, targetPost)
1023                     .ConfigureAwait(false);
1024
1025                 foreach (var post in posts.OrderBy(x => x.StatusId))
1026                 {
1027                     if (relPosts.ContainsKey(post.StatusId))
1028                         continue;
1029
1030                     // リプライチェーンが繋がらないツイートは除外
1031                     if (post.InReplyToStatusId == null || !relPosts.ContainsKey(post.InReplyToStatusId))
1032                         continue;
1033
1034                     relPosts.Add(post.StatusId, post);
1035                 }
1036             }
1037             catch (WebException ex)
1038             {
1039                 lastException = ex;
1040             }
1041
1042             relPosts.Values.ToList().ForEach(p =>
1043             {
1044                 var post = p with { };
1045                 if (post.IsMe && !read && this.ReadOwnPost)
1046                     post.IsRead = true;
1047                 else
1048                     post.IsRead = read;
1049
1050                 tab.AddPostQueue(post);
1051             });
1052
1053             if (lastException != null)
1054                 throw new WebApiException(lastException.Message, lastException);
1055         }
1056
1057         private async Task<PostClass[]> GetConversationPosts(PostClass firstPost, PostClass targetPost)
1058         {
1059             var conversationId = firstPost.StatusId;
1060             var query = $"conversation_id:{conversationId.Id}";
1061
1062             if (targetPost.InReplyToUser != null && targetPost.InReplyToUser != targetPost.ScreenName)
1063                 query += $" (from:{targetPost.ScreenName} to:{targetPost.InReplyToUser}) OR (from:{targetPost.InReplyToUser} to:{targetPost.ScreenName})";
1064             else
1065                 query += $" from:{targetPost.ScreenName} to:{targetPost.ScreenName}";
1066
1067             var statuses = await this.Api.SearchTweets(query, count: 100)
1068                 .ConfigureAwait(false);
1069
1070             return statuses.Statuses.Select(x => this.CreatePostsFromStatusData(x)).ToArray();
1071         }
1072
1073         public async Task GetSearch(bool read, PublicSearchTabModel tab, bool more)
1074         {
1075             var count = GetApiResultCount(MyCommon.WORKERTYPE.PublicSearch, more, false);
1076
1077             TwitterStatus[] statuses;
1078             if (this.Api.AppToken.AuthType == APIAuthType.TwitterComCookie)
1079             {
1080                 var request = new SearchTimelineRequest(tab.SearchWords)
1081                 {
1082                     Count = count,
1083                     Cursor = more ? tab.CursorBottom : null,
1084                 };
1085                 var response = await request.Send(this.Api.Connection)
1086                     .ConfigureAwait(false);
1087
1088                 statuses = response.Tweets.Select(x => x.ToTwitterStatus()).ToArray();
1089                 tab.CursorBottom = response.CursorBottom;
1090             }
1091             else
1092             {
1093                 TwitterStatusId? maxId = null;
1094                 TwitterStatusId? sinceId = null;
1095                 if (more)
1096                 {
1097                     maxId = tab.OldestId as TwitterStatusId;
1098                 }
1099                 else
1100                 {
1101                     sinceId = tab.SinceId as TwitterStatusId;
1102                 }
1103
1104                 var searchResult = await this.Api.SearchTweets(tab.SearchWords, tab.SearchLang, count, maxId, sinceId)
1105                     .ConfigureAwait(false);
1106
1107                 statuses = searchResult.Statuses;
1108             }
1109
1110             if (!TabInformations.GetInstance().ContainsTab(tab))
1111                 return;
1112
1113             var minimumId = this.CreatePostsFromSearchJson(statuses, tab, read, more);
1114
1115             if (minimumId != null)
1116                 tab.OldestId = minimumId;
1117         }
1118
1119         public async Task GetDirectMessageEvents(bool read, DirectMessagesTabModel dmTab, bool backward)
1120         {
1121             this.CheckAccountState();
1122             this.CheckAccessLevel(TwitterApiAccessLevel.ReadWriteAndDirectMessage);
1123
1124             var count = 50;
1125
1126             TwitterMessageEventList eventList;
1127             if (backward)
1128             {
1129                 eventList = await this.Api.DirectMessagesEventsList(count, dmTab.NextCursor)
1130                     .ConfigureAwait(false);
1131             }
1132             else
1133             {
1134                 eventList = await this.Api.DirectMessagesEventsList(count)
1135                     .ConfigureAwait(false);
1136             }
1137
1138             dmTab.NextCursor = eventList.NextCursor;
1139
1140             await this.CreateDirectMessagesEventFromJson(eventList, read)
1141                 .ConfigureAwait(false);
1142         }
1143
1144         private async Task CreateDirectMessagesEventFromJson(TwitterMessageEventSingle eventSingle, bool read)
1145         {
1146             var eventList = new TwitterMessageEventList
1147             {
1148                 Apps = new Dictionary<string, TwitterMessageEventList.App>(),
1149                 Events = new[] { eventSingle.Event },
1150             };
1151
1152             await this.CreateDirectMessagesEventFromJson(eventList, read)
1153                 .ConfigureAwait(false);
1154         }
1155
1156         private async Task CreateDirectMessagesEventFromJson(TwitterMessageEventList eventList, bool read)
1157         {
1158             var events = eventList.Events
1159                 .Where(x => x.Type == "message_create")
1160                 .ToArray();
1161
1162             if (events.Length == 0)
1163                 return;
1164
1165             var userIds = Enumerable.Concat(
1166                 events.Select(x => x.MessageCreate.SenderId),
1167                 events.Select(x => x.MessageCreate.Target.RecipientId)
1168             ).Distinct().ToArray();
1169
1170             var users = (await this.Api.UsersLookup(userIds).ConfigureAwait(false))
1171                 .ToDictionary(x => x.IdStr);
1172
1173             var apps = eventList.Apps ?? new Dictionary<string, TwitterMessageEventList.App>();
1174
1175             this.CreateDirectMessagesEventFromJson(events, users, apps, read);
1176         }
1177
1178         private void CreateDirectMessagesEventFromJson(
1179             IEnumerable<TwitterMessageEvent> events,
1180             IReadOnlyDictionary<string, TwitterUser> users,
1181             IReadOnlyDictionary<string, TwitterMessageEventList.App> apps,
1182             bool read)
1183         {
1184             var dmTab = TabInformations.GetInstance().DirectMessageTab;
1185
1186             foreach (var eventItem in events)
1187             {
1188                 var post = this.postFactory.CreateFromDirectMessageEvent(eventItem, users, apps, this.UserId);
1189
1190                 post.IsRead = read;
1191                 if (post.IsMe && !read && this.ReadOwnPost)
1192                     post.IsRead = true;
1193
1194                 dmTab.AddPostQueue(post);
1195             }
1196         }
1197
1198         public async Task GetFavoritesApi(bool read, FavoritesTabModel tab, bool backward)
1199         {
1200             this.CheckAccountState();
1201
1202             var count = GetApiResultCount(MyCommon.WORKERTYPE.Favorites, backward, false);
1203
1204             TwitterStatus[] statuses;
1205             if (backward)
1206             {
1207                 statuses = await this.Api.FavoritesList(count, maxId: tab.OldestId)
1208                     .ConfigureAwait(false);
1209             }
1210             else
1211             {
1212                 statuses = await this.Api.FavoritesList(count)
1213                     .ConfigureAwait(false);
1214             }
1215
1216             var minimumId = this.CreateFavoritePostsFromJson(statuses, read);
1217
1218             if (minimumId != null)
1219                 tab.OldestId = minimumId.Value;
1220         }
1221
1222         /// <summary>
1223         /// フォロワーIDを更新します
1224         /// </summary>
1225         /// <exception cref="WebApiException"/>
1226         public async Task RefreshFollowerIds()
1227         {
1228             if (MyCommon.EndingFlag) return;
1229
1230             var cursor = -1L;
1231             var newFollowerIds = Enumerable.Empty<long>();
1232             do
1233             {
1234                 var ret = await this.Api.FollowersIds(cursor)
1235                     .ConfigureAwait(false);
1236
1237                 if (ret.Ids == null)
1238                     throw new WebApiException("ret.ids == null");
1239
1240                 newFollowerIds = newFollowerIds.Concat(ret.Ids);
1241                 cursor = ret.NextCursor;
1242             }
1243             while (cursor != 0);
1244
1245             this.followerId = newFollowerIds.ToHashSet();
1246             TabInformations.GetInstance().RefreshOwl(this.followerId);
1247
1248             this.GetFollowersSuccess = true;
1249         }
1250
1251         /// <summary>
1252         /// RT 非表示ユーザーを更新します
1253         /// </summary>
1254         /// <exception cref="WebApiException"/>
1255         public async Task RefreshNoRetweetIds()
1256         {
1257             if (MyCommon.EndingFlag) return;
1258
1259             this.noRTId = await this.Api.NoRetweetIds()
1260                 .ConfigureAwait(false);
1261
1262             this.GetNoRetweetSuccess = true;
1263         }
1264
1265         /// <summary>
1266         /// t.co の文字列長などの設定情報を更新します
1267         /// </summary>
1268         /// <exception cref="WebApiException"/>
1269         public async Task RefreshConfiguration()
1270         {
1271             this.Configuration = await this.Api.Configuration()
1272                 .ConfigureAwait(false);
1273
1274             // TextConfiguration 相当の JSON を得る API が存在しないため、TransformedURLLength のみ help/configuration.json に合わせて更新する
1275             this.TextConfiguration.TransformedURLLength = this.Configuration.ShortUrlLengthHttps;
1276         }
1277
1278         public async Task GetListsApi()
1279         {
1280             this.CheckAccountState();
1281
1282             var ownedLists = await TwitterLists.GetAllItemsAsync(x =>
1283                 this.Api.ListsOwnerships(this.Username, cursor: x, count: 1000))
1284                     .ConfigureAwait(false);
1285
1286             var subscribedLists = await TwitterLists.GetAllItemsAsync(x =>
1287                 this.Api.ListsSubscriptions(this.Username, cursor: x, count: 1000))
1288                     .ConfigureAwait(false);
1289
1290             TabInformations.GetInstance().SubscribableLists = Enumerable.Concat(ownedLists, subscribedLists)
1291                 .Select(x => new ListElement(x, this))
1292                 .ToList();
1293         }
1294
1295         public async Task DeleteList(long listId)
1296         {
1297             await this.Api.ListsDestroy(listId)
1298                 .IgnoreResponse()
1299                 .ConfigureAwait(false);
1300
1301             var tabinfo = TabInformations.GetInstance();
1302
1303             tabinfo.SubscribableLists = tabinfo.SubscribableLists
1304                 .Where(x => x.Id != listId)
1305                 .ToList();
1306         }
1307
1308         public async Task<ListElement> EditList(long listId, string new_name, bool isPrivate, string description)
1309         {
1310             var response = await this.Api.ListsUpdate(listId, new_name, description, isPrivate)
1311                 .ConfigureAwait(false);
1312
1313             var list = await response.LoadJsonAsync()
1314                 .ConfigureAwait(false);
1315
1316             return new ListElement(list, this);
1317         }
1318
1319         public async Task<long> GetListMembers(long listId, List<UserInfo> lists, long cursor)
1320         {
1321             this.CheckAccountState();
1322
1323             var users = await this.Api.ListsMembers(listId, cursor)
1324                 .ConfigureAwait(false);
1325
1326             Array.ForEach(users.Users, u => lists.Add(new UserInfo(u)));
1327
1328             return users.NextCursor;
1329         }
1330
1331         public async Task CreateListApi(string listName, bool isPrivate, string description)
1332         {
1333             this.CheckAccountState();
1334
1335             var response = await this.Api.ListsCreate(listName, description, isPrivate)
1336                 .ConfigureAwait(false);
1337
1338             var list = await response.LoadJsonAsync()
1339                 .ConfigureAwait(false);
1340
1341             TabInformations.GetInstance().SubscribableLists.Add(new ListElement(list, this));
1342         }
1343
1344         public async Task<bool> ContainsUserAtList(long listId, string user)
1345         {
1346             this.CheckAccountState();
1347
1348             try
1349             {
1350                 await this.Api.ListsMembersShow(listId, user)
1351                     .ConfigureAwait(false);
1352
1353                 return true;
1354             }
1355             catch (TwitterApiException ex)
1356                 when (ex.Errors.Any(x => x.Code == TwitterErrorCode.NotFound))
1357             {
1358                 return false;
1359             }
1360         }
1361
1362         public async Task<TwitterApiStatus?> GetInfoApi()
1363         {
1364             if (Twitter.AccountState != MyCommon.ACCOUNT_STATE.Valid) return null;
1365
1366             if (MyCommon.EndingFlag) return null;
1367
1368             var limits = await this.Api.ApplicationRateLimitStatus()
1369                 .ConfigureAwait(false);
1370
1371             MyCommon.TwitterApiInfo.UpdateFromJson(limits);
1372
1373             return MyCommon.TwitterApiInfo;
1374         }
1375
1376         /// <summary>
1377         /// ブロック中のユーザーを更新します
1378         /// </summary>
1379         /// <exception cref="WebApiException"/>
1380         public async Task RefreshBlockIds()
1381         {
1382             if (MyCommon.EndingFlag) return;
1383
1384             var cursor = -1L;
1385             var newBlockIds = Enumerable.Empty<long>();
1386             do
1387             {
1388                 var ret = await this.Api.BlocksIds(cursor)
1389                     .ConfigureAwait(false);
1390
1391                 newBlockIds = newBlockIds.Concat(ret.Ids);
1392                 cursor = ret.NextCursor;
1393             }
1394             while (cursor != 0);
1395
1396             var blockIdsSet = newBlockIds.ToHashSet();
1397             blockIdsSet.Remove(this.UserId); // 元のソースにあったので一応残しておく
1398
1399             TabInformations.GetInstance().BlockIds = blockIdsSet;
1400         }
1401
1402         /// <summary>
1403         /// ミュート中のユーザーIDを更新します
1404         /// </summary>
1405         /// <exception cref="WebApiException"/>
1406         public async Task RefreshMuteUserIdsAsync()
1407         {
1408             if (MyCommon.EndingFlag) return;
1409
1410             var ids = await TwitterIds.GetAllItemsAsync(x => this.Api.MutesUsersIds(x))
1411                 .ConfigureAwait(false);
1412
1413             TabInformations.GetInstance().MuteUserIds = ids.ToHashSet();
1414         }
1415
1416         public string[] GetHashList()
1417             => this.postFactory.GetReceivedHashtags();
1418
1419         public string AccessToken
1420             => ((TwitterApiConnection)this.Api.Connection).AccessToken;
1421
1422         public string AccessTokenSecret
1423             => ((TwitterApiConnection)this.Api.Connection).AccessSecret;
1424
1425         private void CheckAccountState()
1426         {
1427             if (Twitter.AccountState != MyCommon.ACCOUNT_STATE.Valid)
1428                 throw new WebApiException("Auth error. Check your account");
1429         }
1430
1431         private void CheckAccessLevel(TwitterApiAccessLevel accessLevelFlags)
1432         {
1433             if (!this.AccessLevel.HasFlag(accessLevelFlags))
1434                 throw new WebApiException("Auth Err:try to re-authorization.");
1435         }
1436
1437         public int GetTextLengthRemain(string postText)
1438         {
1439             var matchDm = Twitter.DMSendTextRegex.Match(postText);
1440             if (matchDm.Success)
1441                 return this.GetTextLengthRemainDM(matchDm.Groups["body"].Value);
1442
1443             return this.GetTextLengthRemainWeighted(postText);
1444         }
1445
1446         private int GetTextLengthRemainDM(string postText)
1447         {
1448             var textLength = 0;
1449
1450             var pos = 0;
1451             while (pos < postText.Length)
1452             {
1453                 textLength++;
1454
1455                 if (char.IsSurrogatePair(postText, pos))
1456                     pos += 2; // サロゲートペアの場合は2文字分進める
1457                 else
1458                     pos++;
1459             }
1460
1461             var urls = TweetExtractor.ExtractUrls(postText);
1462             foreach (var url in urls)
1463             {
1464                 var shortUrlLength = url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
1465                     ? this.Configuration.ShortUrlLengthHttps
1466                     : this.Configuration.ShortUrlLength;
1467
1468                 textLength += shortUrlLength - url.Length;
1469             }
1470
1471             return this.Configuration.DmTextCharacterLimit - textLength;
1472         }
1473
1474         private int GetTextLengthRemainWeighted(string postText)
1475         {
1476             var config = this.TextConfiguration;
1477             var totalWeight = 0;
1478
1479             int GetWeightFromCodepoint(int codepoint)
1480             {
1481                 foreach (var weightRange in config.Ranges)
1482                 {
1483                     if (codepoint >= weightRange.Start && codepoint <= weightRange.End)
1484                         return weightRange.Weight;
1485                 }
1486
1487                 return config.DefaultWeight;
1488             }
1489
1490             var urls = TweetExtractor.ExtractUrlEntities(postText).ToArray();
1491             var emojis = config.EmojiParsingEnabled
1492                 ? TweetExtractor.ExtractEmojiEntities(postText).ToArray()
1493                 : Array.Empty<TwitterEntityEmoji>();
1494
1495             var codepoints = postText.ToCodepoints().ToArray();
1496             var index = 0;
1497             while (index < codepoints.Length)
1498             {
1499                 var urlEntity = urls.FirstOrDefault(x => x.Indices[0] == index);
1500                 if (urlEntity != null)
1501                 {
1502                     totalWeight += config.TransformedURLLength * config.Scale;
1503                     index = urlEntity.Indices[1];
1504                     continue;
1505                 }
1506
1507                 var emojiEntity = emojis.FirstOrDefault(x => x.Indices[0] == index);
1508                 if (emojiEntity != null)
1509                 {
1510                     totalWeight += GetWeightFromCodepoint(codepoints[index]);
1511                     index = emojiEntity.Indices[1];
1512                     continue;
1513                 }
1514
1515                 var codepoint = codepoints[index];
1516                 totalWeight += GetWeightFromCodepoint(codepoint);
1517
1518                 index++;
1519             }
1520
1521             var remainWeight = config.MaxWeightedTweetLength * config.Scale - totalWeight;
1522
1523             return remainWeight / config.Scale;
1524         }
1525
1526         /// <summary>
1527         /// プロフィール画像のサイズを指定したURLを生成
1528         /// </summary>
1529         /// <remarks>
1530         /// https://developer.twitter.com/en/docs/twitter-api/v1/accounts-and-users/user-profile-images-and-banners を参照
1531         /// </remarks>
1532         public static string CreateProfileImageUrl(string normalUrl, string size)
1533         {
1534             return size switch
1535             {
1536                 "original" => normalUrl.Replace("_normal.", "."),
1537                 "normal" => normalUrl,
1538                 "bigger" or "mini" => normalUrl.Replace("_normal.", $"_{size}."),
1539                 _ => throw new ArgumentException($"Invalid size: ${size}", nameof(size)),
1540             };
1541         }
1542
1543         public static string DecideProfileImageSize(int sizePx)
1544         {
1545             return sizePx switch
1546             {
1547                 <= 24 => "mini",
1548                 <= 48 => "normal",
1549                 <= 73 => "bigger",
1550                 _ => "original",
1551             };
1552         }
1553
1554         public bool IsDisposed { get; private set; } = false;
1555
1556         protected virtual void Dispose(bool disposing)
1557         {
1558             if (this.IsDisposed)
1559                 return;
1560
1561             if (disposing)
1562             {
1563                 this.Api.Dispose();
1564             }
1565
1566             this.IsDisposed = true;
1567         }
1568
1569         public void Dispose()
1570         {
1571             this.Dispose(true);
1572             GC.SuppressFinalize(this);
1573         }
1574     }
1575 }