OSDN Git Service

Merge pull request #119 from kzrnm/feature/status_url
[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.Globalization;
34 using System.IO;
35 using System.Linq;
36 using System.Net;
37 using System.Net.Http;
38 using System.Reflection;
39 using System.Runtime.CompilerServices;
40 using System.Text;
41 using System.Text.RegularExpressions;
42 using System.Threading;
43 using System.Threading.Tasks;
44 using System.Windows.Forms;
45 using OpenTween.Api;
46 using OpenTween.Api.DataModel;
47 using OpenTween.Connection;
48 using OpenTween.Models;
49 using OpenTween.Setting;
50
51 namespace OpenTween
52 {
53     public class Twitter : IDisposable
54     {
55         #region Regexp from twitter-text-js
56
57         // The code in this region code block incorporates works covered by
58         // the following copyright and permission notices:
59         //
60         //   Copyright 2011 Twitter, Inc.
61         //
62         //   Licensed under the Apache License, Version 2.0 (the "License"); you
63         //   may not use this work except in compliance with the License. You
64         //   may obtain a copy of the License in the LICENSE file, or at:
65         //
66         //   http://www.apache.org/licenses/LICENSE-2.0
67         //
68         //   Unless required by applicable law or agreed to in writing, software
69         //   distributed under the License is distributed on an "AS IS" BASIS,
70         //   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
71         //   implied. See the License for the specific language governing
72         //   permissions and limitations under the License.
73
74         // Hashtag用正規表現
75         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";
76         private const string NonLatinHashtagChars = @"\u0400-\u04ff\u0500-\u0527\u1100-\u11ff\u3130-\u3185\uA960-\uA97F\uAC00-\uD7AF\uD7B0-\uD7FF";
77         private const string CJHashtagCharacters = @"\u30A1-\u30FA\u30FC\u3005\uFF66-\uFF9F\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\u3041-\u309A\u3400-\u4DBF\p{IsCJKUnifiedIdeographs}";
78         private const string HashtagBoundary = @"^|$|\s|「|」|。|\.|!";
79         private const string HashtagAlpha = "[A-Za-z_" + LatinAccents + NonLatinHashtagChars + CJHashtagCharacters + "]";
80         private const string HashtagAlphanumeric = "[A-Za-z0-9_" + LatinAccents + NonLatinHashtagChars + CJHashtagCharacters + "]";
81         private const string HashtagTerminator = "[^A-Za-z0-9_" + LatinAccents + NonLatinHashtagChars + CJHashtagCharacters + "]";
82         public const string Hashtag = "(" + HashtagBoundary + ")(#|#)(" + HashtagAlphanumeric + "*" + HashtagAlpha + HashtagAlphanumeric + "*)(?=" + HashtagTerminator + "|" + HashtagBoundary + ")";
83         // URL正規表現
84         private const string UrlValidPrecedingChars = @"(?:[^A-Za-z0-9@@$##\ufffe\ufeff\uffff\u202a-\u202e]|^)";
85         public const string UrlInvalidWithoutProtocolPrecedingChars = @"[-_./]$";
86         private const string UrlInvalidDomainChars = @"\!'#%&'\(\)*\+,\\\-\.\/:;<=>\?@\[\]\^_{|}~\$\u2000-\u200a\u0009-\u000d\u0020\u0085\u00a0\u1680\u180e\u2028\u2029\u202f\u205f\u3000\ufffe\ufeff\uffff\u202a-\u202e";
87         private const string UrlValidDomainChars = @"[^" + UrlInvalidDomainChars + "]";
88         private const string UrlValidSubdomain = @"(?:(?:" + UrlValidDomainChars + @"(?:[_-]|" + UrlValidDomainChars + @")*)?" + UrlValidDomainChars + @"\.)";
89         private const string UrlValidDomainName = @"(?:(?:" + UrlValidDomainChars + @"(?:-|" + UrlValidDomainChars + @")*)?" + UrlValidDomainChars + @"\.)";
90         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]|$))";
91         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]|$))";
92         private const string UrlValidPunycode = @"(?:xn--[0-9a-z]+)";
93         private const string UrlValidDomain = @"(?<domain>" + UrlValidSubdomain + "*" + UrlValidDomainName + "(?:" + UrlValidGTLD + "|" + UrlValidCCTLD + ")|" + UrlValidPunycode + ")";
94         public const string UrlValidAsciiDomain = @"(?:(?:[a-z0-9" + LatinAccents + @"]+)\.)+(?:" + UrlValidGTLD + "|" + UrlValidCCTLD + "|" + UrlValidPunycode + ")";
95         public const string UrlInvalidShortDomain = "^" + UrlValidDomainName + UrlValidCCTLD + "$";
96         private const string UrlValidPortNumber = @"[0-9]+";
97
98         private const string UrlValidGeneralPathChars = @"[a-z0-9!*';:=+,.$/%#\[\]\-_~|&" + LatinAccents + "]";
99         private const string UrlBalanceParens = @"(?:\(" + UrlValidGeneralPathChars + @"+\))";
100         private const string UrlValidPathEndingChars = @"(?:[+\-a-z0-9=_#/" + LatinAccents + "]|" + UrlBalanceParens + ")";
101         private const string Pth = "(?:" +
102             "(?:" +
103                 UrlValidGeneralPathChars + "*" +
104                 "(?:" + UrlBalanceParens + UrlValidGeneralPathChars + "*)*" +
105                 UrlValidPathEndingChars +
106                 ")|(?:@" + UrlValidGeneralPathChars + "+/)" +
107             ")";
108
109         private const string Qry = @"(?<query>\?[a-z0-9!?*'();:&=+$/%#\[\]\-_.,~|]*[a-z0-9_&=#/])?";
110         public const string RgUrl = @"(?<before>" + UrlValidPrecedingChars + ")" +
111                                     "(?<url>(?<protocol>https?://)?" +
112                                     "(?<domain>" + UrlValidDomain + ")" +
113                                     "(?::" + UrlValidPortNumber + ")?" +
114                                     "(?<path>/" + Pth + "*)?" +
115                                     Qry +
116                                     ")";
117
118         #endregion
119
120         /// <summary>
121         /// Twitter API のステータスページのURL
122         /// </summary>
123         public const string ServiceAvailabilityStatusUrl = "https://api.twitterstat.us/";
124
125         /// <summary>
126         /// ツイートへのパーマリンクURLを判定する正規表現
127         /// </summary>
128         public static readonly Regex StatusUrlRegex = new Regex(@"https?://([^.]+\.)?twitter\.com/(#!/)?(?<ScreenName>[a-zA-Z0-9_]+)/status(es)?/(?<StatusId>[0-9]+)(/photo)?", RegexOptions.IgnoreCase);
129
130         /// <summary>
131         /// attachment_url に指定可能な URL を判定する正規表現
132         /// </summary>
133         public static readonly Regex AttachmentUrlRegex = new Regex(
134             @"https?://(
135    twitter\.com/[0-9A-Za-z_]+/status/[0-9]+
136  | mobile\.twitter\.com/[0-9A-Za-z_]+/status/[0-9]+
137  | twitter\.com/messages/compose\?recipient_id=[0-9]+(&.+)?
138 )$",
139             RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace);
140
141         /// <summary>
142         /// FavstarやaclogなどTwitter関連サービスのパーマリンクURLからステータスIDを抽出する正規表現
143         /// </summary>
144         public static readonly Regex ThirdPartyStatusUrlRegex = new Regex(
145             @"https?://(?:[^.]+\.)?(?:
146   favstar\.fm/users/[a-zA-Z0-9_]+/status/       # Favstar
147 | favstar\.fm/t/                                # Favstar (short)
148 | aclog\.koba789\.com/i/                        # aclog
149 | frtrt\.net/solo_status\.php\?status=          # RtRT
150 )(?<StatusId>[0-9]+)",
151             RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace);
152
153         /// <summary>
154         /// DM送信かどうかを判定する正規表現
155         /// </summary>
156         public static readonly Regex DMSendTextRegex = new Regex(@"^DM? +(?<id>[a-zA-Z0-9_]+) +(?<body>.*)", RegexOptions.IgnoreCase | RegexOptions.Singleline);
157
158         public TwitterApi Api { get; }
159
160         public TwitterConfiguration Configuration { get; private set; }
161
162         public TwitterTextConfiguration TextConfiguration { get; private set; }
163
164         public bool GetFollowersSuccess { get; private set; } = false;
165
166         public bool GetNoRetweetSuccess { get; private set; } = false;
167
168         private delegate void GetIconImageDelegate(PostClass post);
169
170         private readonly object lockObj = new object();
171         private ISet<long> followerId = new HashSet<long>();
172         private long[] noRTId = Array.Empty<long>();
173
174         // プロパティからアクセスされる共通情報
175         private readonly List<string> hashList = new List<string>();
176
177         private string? nextCursorDirectMessage = null;
178
179         private long previousStatusId = -1L;
180
181         public Twitter(TwitterApi api)
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         internal static string PreProcessUrl(string orgData)
232         {
233             int posl1;
234             var posl2 = 0;
235             var href = "<a href=\"";
236
237             while (true)
238             {
239                 if (orgData.IndexOf(href, posl2, StringComparison.Ordinal) > -1)
240                 {
241                     // IDN展開
242                     posl1 = orgData.IndexOf(href, posl2, StringComparison.Ordinal);
243                     posl1 += href.Length;
244                     posl2 = orgData.IndexOf("\"", posl1, StringComparison.Ordinal);
245                     var urlStr = orgData.Substring(posl1, posl2 - posl1);
246
247                     if (!urlStr.StartsWith("http://", StringComparison.Ordinal)
248                         && !urlStr.StartsWith("https://", StringComparison.Ordinal)
249                         && !urlStr.StartsWith("ftp://", StringComparison.Ordinal))
250                     {
251                         continue;
252                     }
253
254                     var replacedUrl = MyCommon.IDNEncode(urlStr);
255                     if (replacedUrl == null) continue;
256                     if (replacedUrl == urlStr) continue;
257
258                     orgData = orgData.Replace("<a href=\"" + urlStr, "<a href=\"" + replacedUrl);
259                     posl2 = 0;
260                 }
261                 else
262                 {
263                     break;
264                 }
265             }
266             return orgData;
267         }
268
269         public async Task<PostClass?> PostStatus(PostStatusParams param)
270         {
271             this.CheckAccountState();
272
273             if (Twitter.DMSendTextRegex.IsMatch(param.Text))
274             {
275                 var mediaId = param.MediaIds != null && param.MediaIds.Any() ? param.MediaIds[0] : (long?)null;
276
277                 await this.SendDirectMessage(param.Text, mediaId)
278                     .ConfigureAwait(false);
279                 return null;
280             }
281
282             var response = await this.Api.StatusesUpdate(
283                     param.Text,
284                     param.InReplyToStatusId,
285                     param.MediaIds,
286                     param.AutoPopulateReplyMetadata,
287                     param.ExcludeReplyUserIds,
288                     param.AttachmentUrl
289                 )
290                 .ConfigureAwait(false);
291
292             var status = await response.LoadJsonAsync()
293                 .ConfigureAwait(false);
294
295             this.UpdateUserStats(status.User);
296
297             if (status.Id == this.previousStatusId)
298                 throw new WebApiException("OK:Delaying?");
299
300             this.previousStatusId = status.Id;
301
302             // 投稿したものを返す
303             var post = this.CreatePostsFromStatusData(status);
304             if (this.ReadOwnPost) post.IsRead = true;
305             return post;
306         }
307
308         public async Task<long> UploadMedia(IMediaItem item, string? mediaCategory = null)
309         {
310             this.CheckAccountState();
311
312             var mediaType = item.Extension switch
313             {
314                 ".png" => "image/png",
315                 ".jpg" => "image/jpeg",
316                 ".jpeg" => "image/jpeg",
317                 ".gif" => "image/gif",
318                 _ => "application/octet-stream",
319             };
320
321             var initResponse = await this.Api.MediaUploadInit(item.Size, mediaType, mediaCategory)
322                 .ConfigureAwait(false);
323
324             var initMedia = await initResponse.LoadJsonAsync()
325                 .ConfigureAwait(false);
326
327             var mediaId = initMedia.MediaId;
328
329             await this.Api.MediaUploadAppend(mediaId, 0, item)
330                 .ConfigureAwait(false);
331
332             var response = await this.Api.MediaUploadFinalize(mediaId)
333                 .ConfigureAwait(false);
334
335             var media = await response.LoadJsonAsync()
336                 .ConfigureAwait(false);
337
338             while (media.ProcessingInfo is TwitterUploadMediaResult.MediaProcessingInfo processingInfo)
339             {
340                 switch (processingInfo.State)
341                 {
342                     case "pending":
343                         break;
344                     case "in_progress":
345                         break;
346                     case "succeeded":
347                         goto succeeded;
348                     case "failed":
349                         throw new WebApiException($"Err:Upload failed ({processingInfo.Error?.Name})");
350                     default:
351                         throw new WebApiException($"Err:Invalid state ({processingInfo.State})");
352                 }
353
354                 await Task.Delay(TimeSpan.FromSeconds(processingInfo.CheckAfterSecs ?? 5))
355                     .ConfigureAwait(false);
356
357                 media = await this.Api.MediaUploadStatus(mediaId)
358                     .ConfigureAwait(false);
359             }
360
361             succeeded:
362             return media.MediaId;
363         }
364
365         public async Task SendDirectMessage(string postStr, long? mediaId = null)
366         {
367             this.CheckAccountState();
368             this.CheckAccessLevel(TwitterApiAccessLevel.ReadWriteAndDirectMessage);
369
370             var mc = Twitter.DMSendTextRegex.Match(postStr);
371
372             var body = mc.Groups["body"].Value;
373             var recipientName = mc.Groups["id"].Value;
374
375             var recipient = await this.Api.UsersShow(recipientName)
376                 .ConfigureAwait(false);
377
378             var response = await this.Api.DirectMessagesEventsNew(recipient.Id, body, mediaId)
379                 .ConfigureAwait(false);
380
381             var messageEventSingle = await response.LoadJsonAsync()
382                 .ConfigureAwait(false);
383
384             await this.CreateDirectMessagesEventFromJson(messageEventSingle, read: true)
385                 .ConfigureAwait(false);
386         }
387
388         public async Task<PostClass?> PostRetweet(long id, bool read)
389         {
390             this.CheckAccountState();
391
392             // データ部分の生成
393             var post = TabInformations.GetInstance()[id];
394             if (post == null)
395                 throw new WebApiException("Err:Target isn't found.");
396
397             var target = post.RetweetedId ?? id;  // 再RTの場合は元発言をRT
398
399             var response = await this.Api.StatusesRetweet(target)
400                 .ConfigureAwait(false);
401
402             var status = await response.LoadJsonAsync()
403                 .ConfigureAwait(false);
404
405             // 二重取得回避
406             lock (this.lockObj)
407             {
408                 if (TabInformations.GetInstance().ContainsKey(status.Id))
409                     return null;
410             }
411
412             // Retweet判定
413             if (status.RetweetedStatus == null)
414                 throw new WebApiException("Invalid Json!");
415
416             // Retweetしたものを返す
417             post = this.CreatePostsFromStatusData(status);
418
419             // ユーザー情報
420             post.IsMe = true;
421
422             post.IsRead = read;
423             post.IsOwl = false;
424             if (this.ReadOwnPost) post.IsRead = true;
425             post.IsDm = false;
426
427             return post;
428         }
429
430         public string Username
431             => this.Api.CurrentScreenName;
432
433         public long UserId
434             => this.Api.CurrentUserId;
435
436         public static MyCommon.ACCOUNT_STATE AccountState { get; set; } = MyCommon.ACCOUNT_STATE.Valid;
437
438         public bool RestrictFavCheck { get; set; }
439
440         public bool ReadOwnPost { get; set; }
441
442         public int FollowersCount { get; private set; }
443
444         public int FriendsCount { get; private set; }
445
446         public int StatusesCount { get; private set; }
447
448         public string Location { get; private set; } = "";
449
450         public string Bio { get; private set; } = "";
451
452         /// <summary>ユーザーのフォロワー数などの情報を更新します</summary>
453         private void UpdateUserStats(TwitterUser self)
454         {
455             this.FollowersCount = self.FollowersCount;
456             this.FriendsCount = self.FriendsCount;
457             this.StatusesCount = self.StatusesCount;
458             this.Location = self.Location ?? "";
459             this.Bio = self.Description ?? "";
460         }
461
462         /// <summary>
463         /// 渡された取得件数がWORKERTYPEに応じた取得可能範囲に収まっているか検証する
464         /// </summary>
465         public static bool VerifyApiResultCount(MyCommon.WORKERTYPE type, int count)
466             => count >= 20 && count <= GetMaxApiResultCount(type);
467
468         /// <summary>
469         /// 渡された取得件数が更新時の取得可能範囲に収まっているか検証する
470         /// </summary>
471         public static bool VerifyMoreApiResultCount(int count)
472             => count >= 20 && count <= 200;
473
474         /// <summary>
475         /// 渡された取得件数が起動時の取得可能範囲に収まっているか検証する
476         /// </summary>
477         public static bool VerifyFirstApiResultCount(int count)
478             => count >= 20 && count <= 200;
479
480         /// <summary>
481         /// WORKERTYPEに応じた取得可能な最大件数を取得する
482         /// </summary>
483         public static int GetMaxApiResultCount(MyCommon.WORKERTYPE type)
484         {
485             // 参照: REST APIs - 各endpointのcountパラメータ
486             // https://dev.twitter.com/rest/public
487             return type switch
488             {
489                 MyCommon.WORKERTYPE.Timeline => 200,
490                 MyCommon.WORKERTYPE.Reply => 200,
491                 MyCommon.WORKERTYPE.UserTimeline => 200,
492                 MyCommon.WORKERTYPE.Favorites => 200,
493                 MyCommon.WORKERTYPE.List => 200, // 不明
494                 MyCommon.WORKERTYPE.PublicSearch => 100,
495                 _ => throw new InvalidOperationException("Invalid type: " + type),
496             };
497         }
498
499         /// <summary>
500         /// WORKERTYPEに応じた取得件数を取得する
501         /// </summary>
502         public static int GetApiResultCount(MyCommon.WORKERTYPE type, bool more, bool startup)
503         {
504             if (SettingManager.Common.UseAdditionalCount)
505             {
506                 switch (type)
507                 {
508                     case MyCommon.WORKERTYPE.Favorites:
509                         if (SettingManager.Common.FavoritesCountApi != 0)
510                             return SettingManager.Common.FavoritesCountApi;
511                         break;
512                     case MyCommon.WORKERTYPE.List:
513                         if (SettingManager.Common.ListCountApi != 0)
514                             return SettingManager.Common.ListCountApi;
515                         break;
516                     case MyCommon.WORKERTYPE.PublicSearch:
517                         if (SettingManager.Common.SearchCountApi != 0)
518                             return SettingManager.Common.SearchCountApi;
519                         break;
520                     case MyCommon.WORKERTYPE.UserTimeline:
521                         if (SettingManager.Common.UserTimelineCountApi != 0)
522                             return SettingManager.Common.UserTimelineCountApi;
523                         break;
524                 }
525                 if (more && SettingManager.Common.MoreCountApi != 0)
526                 {
527                     return Math.Min(SettingManager.Common.MoreCountApi, GetMaxApiResultCount(type));
528                 }
529                 if (startup && SettingManager.Common.FirstCountApi != 0 && type != MyCommon.WORKERTYPE.Reply)
530                 {
531                     return Math.Min(SettingManager.Common.FirstCountApi, GetMaxApiResultCount(type));
532                 }
533             }
534
535             // 上記に当てはまらない場合の共通処理
536             var count = SettingManager.Common.CountApi;
537
538             if (type == MyCommon.WORKERTYPE.Reply)
539                 count = SettingManager.Common.CountApiReply;
540
541             return Math.Min(count, GetMaxApiResultCount(type));
542         }
543
544         public async Task GetHomeTimelineApi(bool read, HomeTabModel tab, bool more, bool startup)
545         {
546             this.CheckAccountState();
547
548             var count = GetApiResultCount(MyCommon.WORKERTYPE.Timeline, more, startup);
549
550             TwitterStatus[] statuses;
551             if (more)
552             {
553                 statuses = await this.Api.StatusesHomeTimeline(count, maxId: tab.OldestId)
554                     .ConfigureAwait(false);
555             }
556             else
557             {
558                 statuses = await this.Api.StatusesHomeTimeline(count)
559                     .ConfigureAwait(false);
560             }
561
562             var minimumId = this.CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.Timeline, tab, read);
563             if (minimumId != null)
564                 tab.OldestId = minimumId.Value;
565         }
566
567         public async Task GetMentionsTimelineApi(bool read, MentionsTabModel tab, bool more, bool startup)
568         {
569             this.CheckAccountState();
570
571             var count = GetApiResultCount(MyCommon.WORKERTYPE.Reply, more, startup);
572
573             TwitterStatus[] statuses;
574             if (more)
575             {
576                 statuses = await this.Api.StatusesMentionsTimeline(count, maxId: tab.OldestId)
577                     .ConfigureAwait(false);
578             }
579             else
580             {
581                 statuses = await this.Api.StatusesMentionsTimeline(count)
582                     .ConfigureAwait(false);
583             }
584
585             var minimumId = this.CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.Reply, tab, read);
586             if (minimumId != null)
587                 tab.OldestId = minimumId.Value;
588         }
589
590         public async Task GetUserTimelineApi(bool read, string userName, UserTimelineTabModel tab, bool more)
591         {
592             this.CheckAccountState();
593
594             var count = GetApiResultCount(MyCommon.WORKERTYPE.UserTimeline, more, false);
595
596             TwitterStatus[] statuses;
597             if (MyCommon.IsNullOrEmpty(userName))
598             {
599                 var target = tab.ScreenName;
600                 if (MyCommon.IsNullOrEmpty(target)) return;
601                 userName = target;
602                 statuses = await this.Api.StatusesUserTimeline(userName, count)
603                     .ConfigureAwait(false);
604             }
605             else
606             {
607                 if (more)
608                 {
609                     statuses = await this.Api.StatusesUserTimeline(userName, count, maxId: tab.OldestId)
610                         .ConfigureAwait(false);
611                 }
612                 else
613                 {
614                     statuses = await this.Api.StatusesUserTimeline(userName, count)
615                         .ConfigureAwait(false);
616                 }
617             }
618
619             var minimumId = this.CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.UserTimeline, tab, read);
620
621             if (minimumId != null)
622                 tab.OldestId = minimumId.Value;
623         }
624
625         public async Task<PostClass> GetStatusApi(bool read, long id)
626         {
627             this.CheckAccountState();
628
629             var status = await this.Api.StatusesShow(id)
630                 .ConfigureAwait(false);
631
632             var item = this.CreatePostsFromStatusData(status);
633
634             item.IsRead = read;
635             if (item.IsMe && !read && this.ReadOwnPost) item.IsRead = true;
636
637             return item;
638         }
639
640         public async Task GetStatusApi(bool read, long id, TabModel tab)
641         {
642             var post = await this.GetStatusApi(read, id)
643                 .ConfigureAwait(false);
644
645             // 非同期アイコン取得&StatusDictionaryに追加
646             if (tab != null && tab.IsInnerStorageTabType)
647                 tab.AddPostQueue(post);
648             else
649                 TabInformations.GetInstance().AddPost(post);
650         }
651
652         private PostClass CreatePostsFromStatusData(TwitterStatus status)
653             => this.CreatePostsFromStatusData(status, false);
654
655         private PostClass CreatePostsFromStatusData(TwitterStatus status, bool favTweet)
656         {
657             var post = new PostClass();
658             TwitterEntities entities;
659             string sourceHtml;
660
661             post.StatusId = status.Id;
662             if (status.RetweetedStatus != null)
663             {
664                 var retweeted = status.RetweetedStatus;
665
666                 post.CreatedAt = MyCommon.DateTimeParse(retweeted.CreatedAt);
667
668                 // Id
669                 post.RetweetedId = retweeted.Id;
670                 // 本文
671                 post.TextFromApi = retweeted.FullText;
672                 entities = retweeted.MergedEntities;
673                 sourceHtml = retweeted.Source;
674                 // Reply先
675                 post.InReplyToStatusId = retweeted.InReplyToStatusId;
676                 post.InReplyToUser = retweeted.InReplyToScreenName;
677                 post.InReplyToUserId = status.InReplyToUserId;
678
679                 if (favTweet)
680                 {
681                     post.IsFav = true;
682                 }
683                 else
684                 {
685                     // 幻覚fav対策
686                     var tc = TabInformations.GetInstance().FavoriteTab;
687                     post.IsFav = tc.Contains(retweeted.Id);
688                 }
689
690                 if (retweeted.Coordinates != null)
691                     post.PostGeo = new PostClass.StatusGeo(retweeted.Coordinates.Coordinates[0], retweeted.Coordinates.Coordinates[1]);
692
693                 // 以下、ユーザー情報
694                 var user = retweeted.User;
695                 if (user != null)
696                 {
697                     post.UserId = user.Id;
698                     post.ScreenName = user.ScreenName;
699                     post.Nickname = user.Name.Trim();
700                     post.ImageUrl = user.ProfileImageUrlHttps;
701                     post.IsProtect = user.Protected;
702                 }
703                 else
704                 {
705                     post.UserId = 0L;
706                     post.ScreenName = "?????";
707                     post.Nickname = "Unknown User";
708                 }
709
710                 // Retweetした人
711                 if (status.User != null)
712                 {
713                     post.RetweetedBy = status.User.ScreenName;
714                     post.RetweetedByUserId = status.User.Id;
715                     post.IsMe = post.RetweetedByUserId == this.UserId;
716                 }
717                 else
718                 {
719                     post.RetweetedBy = "?????";
720                     post.RetweetedByUserId = 0L;
721                 }
722             }
723             else
724             {
725                 post.CreatedAt = MyCommon.DateTimeParse(status.CreatedAt);
726                 // 本文
727                 post.TextFromApi = status.FullText;
728                 entities = status.MergedEntities;
729                 sourceHtml = status.Source;
730                 post.InReplyToStatusId = status.InReplyToStatusId;
731                 post.InReplyToUser = status.InReplyToScreenName;
732                 post.InReplyToUserId = status.InReplyToUserId;
733
734                 if (favTweet)
735                 {
736                     post.IsFav = true;
737                 }
738                 else
739                 {
740                     // 幻覚fav対策
741                     var tc = TabInformations.GetInstance().FavoriteTab;
742                     post.IsFav = tc.Posts.TryGetValue(post.StatusId, out var tabinfoPost) && tabinfoPost.IsFav;
743                 }
744
745                 if (status.Coordinates != null)
746                     post.PostGeo = new PostClass.StatusGeo(status.Coordinates.Coordinates[0], status.Coordinates.Coordinates[1]);
747
748                 // 以下、ユーザー情報
749                 var user = status.User;
750                 if (user != null)
751                 {
752                     post.UserId = user.Id;
753                     post.ScreenName = user.ScreenName;
754                     post.Nickname = user.Name.Trim();
755                     post.ImageUrl = user.ProfileImageUrlHttps;
756                     post.IsProtect = user.Protected;
757                     post.IsMe = post.UserId == this.UserId;
758                 }
759                 else
760                 {
761                     post.UserId = 0L;
762                     post.ScreenName = "?????";
763                     post.Nickname = "Unknown User";
764                 }
765             }
766             // HTMLに整形
767             var textFromApi = post.TextFromApi;
768
769             var quotedStatusLink = (status.RetweetedStatus ?? status).QuotedStatusPermalink;
770
771             if (quotedStatusLink != null && entities.Urls.Any(x => x.ExpandedUrl == quotedStatusLink.Expanded))
772                 quotedStatusLink = null; // 移行期は entities.urls と quoted_status_permalink の両方に含まれる場合がある
773
774             post.Text = CreateHtmlAnchor(textFromApi, entities, quotedStatusLink);
775             post.TextFromApi = textFromApi;
776             post.TextFromApi = this.ReplaceTextFromApi(post.TextFromApi, entities, quotedStatusLink);
777             post.TextFromApi = WebUtility.HtmlDecode(post.TextFromApi);
778             post.TextFromApi = post.TextFromApi.Replace("<3", "\u2661");
779             post.AccessibleText = CreateAccessibleText(textFromApi, entities, (status.RetweetedStatus ?? status).QuotedStatus, quotedStatusLink);
780             post.AccessibleText = WebUtility.HtmlDecode(post.AccessibleText);
781             post.AccessibleText = post.AccessibleText.Replace("<3", "\u2661");
782
783             this.ExtractEntities(entities, post.ReplyToList, post.Media);
784
785             post.QuoteStatusIds = GetQuoteTweetStatusIds(entities, quotedStatusLink)
786                 .Where(x => x != post.StatusId && x != post.RetweetedId)
787                 .Distinct().ToArray();
788
789             post.ExpandedUrls = entities.OfType<TwitterEntityUrl>()
790                 .Select(x => new PostClass.ExpandedUrlInfo(x.Url, x.ExpandedUrl))
791                 .ToArray();
792
793             // メモリ使用量削減 (同一のテキストであれば同一の string インスタンスを参照させる)
794             if (post.Text == post.TextFromApi)
795                 post.Text = post.TextFromApi;
796             if (post.AccessibleText == post.TextFromApi)
797                 post.AccessibleText = post.TextFromApi;
798
799             // 他の発言と重複しやすい (共通化できる) 文字列は string.Intern を通す
800             post.ScreenName = string.Intern(post.ScreenName);
801             post.Nickname = string.Intern(post.Nickname);
802             post.ImageUrl = string.Intern(post.ImageUrl);
803             post.RetweetedBy = post.RetweetedBy != null ? string.Intern(post.RetweetedBy) : null;
804
805             // Source整形
806             var (sourceText, sourceUri) = ParseSource(sourceHtml);
807             post.Source = string.Intern(sourceText);
808             post.SourceUri = sourceUri;
809
810             post.IsReply = post.RetweetedId == null && post.ReplyToList.Any(x => x.UserId == this.UserId);
811             post.IsExcludeReply = false;
812
813             if (post.IsMe)
814             {
815                 post.IsOwl = false;
816             }
817             else
818             {
819                 if (this.followerId.Count > 0) post.IsOwl = !this.followerId.Contains(post.UserId);
820             }
821
822             post.IsDm = false;
823             return post;
824         }
825
826         /// <summary>
827         /// ツイートに含まれる引用ツイートのURLからステータスIDを抽出
828         /// </summary>
829         public static IEnumerable<long> GetQuoteTweetStatusIds(IEnumerable<TwitterEntity>? entities, TwitterQuotedStatusPermalink? quotedStatusLink)
830         {
831             entities ??= Enumerable.Empty<TwitterEntity>();
832
833             var urls = entities.OfType<TwitterEntityUrl>().Select(x => x.ExpandedUrl);
834
835             if (quotedStatusLink != null)
836                 urls = urls.Append(quotedStatusLink.Expanded);
837
838             return GetQuoteTweetStatusIds(urls);
839         }
840
841         public static IEnumerable<long> GetQuoteTweetStatusIds(IEnumerable<string> urls)
842         {
843             foreach (var url in urls)
844             {
845                 var match = Twitter.StatusUrlRegex.Match(url);
846                 if (match.Success)
847                 {
848                     if (long.TryParse(match.Groups["StatusId"].Value, out var statusId))
849                         yield return statusId;
850                 }
851             }
852         }
853
854         private long? CreatePostsFromJson(TwitterStatus[] items, MyCommon.WORKERTYPE gType, TabModel? tab, bool read)
855         {
856             long? minimumId = null;
857
858             foreach (var status in items)
859             {
860                 if (minimumId == null || minimumId.Value > status.Id)
861                     minimumId = status.Id;
862
863                 // 二重取得回避
864                 lock (this.lockObj)
865                 {
866                     if (tab == null)
867                     {
868                         if (TabInformations.GetInstance().ContainsKey(status.Id)) continue;
869                     }
870                     else
871                     {
872                         if (tab.Contains(status.Id)) continue;
873                     }
874                 }
875
876                 // RT禁止ユーザーによるもの
877                 if (gType != MyCommon.WORKERTYPE.UserTimeline &&
878                     status.RetweetedStatus != null && this.noRTId.Contains(status.User.Id)) continue;
879
880                 var post = this.CreatePostsFromStatusData(status);
881
882                 post.IsRead = read;
883                 if (post.IsMe && !read && this.ReadOwnPost) post.IsRead = true;
884
885                 if (tab != null && tab.IsInnerStorageTabType)
886                     tab.AddPostQueue(post);
887                 else
888                     TabInformations.GetInstance().AddPost(post);
889             }
890
891             return minimumId;
892         }
893
894         private long? CreatePostsFromSearchJson(TwitterSearchResult items, PublicSearchTabModel tab, bool read, bool more)
895         {
896             long? minimumId = null;
897
898             foreach (var status in items.Statuses)
899             {
900                 if (minimumId == null || minimumId.Value > status.Id)
901                     minimumId = status.Id;
902
903                 if (!more && status.Id > tab.SinceId) tab.SinceId = status.Id;
904                 // 二重取得回避
905                 lock (this.lockObj)
906                 {
907                     if (tab.Contains(status.Id)) continue;
908                 }
909
910                 var post = this.CreatePostsFromStatusData(status);
911
912                 post.IsRead = read;
913                 if ((post.IsMe && !read) && this.ReadOwnPost) post.IsRead = true;
914
915                 tab.AddPostQueue(post);
916             }
917
918             return minimumId;
919         }
920
921         private long? CreateFavoritePostsFromJson(TwitterStatus[] items, bool read)
922         {
923             var favTab = TabInformations.GetInstance().FavoriteTab;
924             long? minimumId = null;
925
926             foreach (var status in items)
927             {
928                 if (minimumId == null || minimumId.Value > status.Id)
929                     minimumId = status.Id;
930
931                 // 二重取得回避
932                 lock (this.lockObj)
933                 {
934                     if (favTab.Contains(status.Id)) continue;
935                 }
936
937                 var post = this.CreatePostsFromStatusData(status, true);
938
939                 post.IsRead = read;
940
941                 TabInformations.GetInstance().AddPost(post);
942             }
943
944             return minimumId;
945         }
946
947         public async Task GetListStatus(bool read, ListTimelineTabModel tab, bool more, bool startup)
948         {
949             var count = GetApiResultCount(MyCommon.WORKERTYPE.List, more, startup);
950
951             TwitterStatus[] statuses;
952             if (more)
953             {
954                 statuses = await this.Api.ListsStatuses(tab.ListInfo.Id, count, maxId: tab.OldestId, includeRTs: SettingManager.Common.IsListsIncludeRts)
955                     .ConfigureAwait(false);
956             }
957             else
958             {
959                 statuses = await this.Api.ListsStatuses(tab.ListInfo.Id, count, includeRTs: SettingManager.Common.IsListsIncludeRts)
960                     .ConfigureAwait(false);
961             }
962
963             var minimumId = this.CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.List, tab, read);
964
965             if (minimumId != null)
966                 tab.OldestId = minimumId.Value;
967         }
968
969         /// <summary>
970         /// startStatusId からリプライ先の発言を辿る。発言は posts 以外からは検索しない。
971         /// </summary>
972         /// <returns>posts の中から検索されたリプライチェインの末端</returns>
973         internal static PostClass FindTopOfReplyChain(IDictionary<long, PostClass> posts, long startStatusId)
974         {
975             if (!posts.ContainsKey(startStatusId))
976                 throw new ArgumentException("startStatusId (" + startStatusId + ") が posts の中から見つかりませんでした。", nameof(startStatusId));
977
978             var nextPost = posts[startStatusId];
979             while (nextPost.InReplyToStatusId != null)
980             {
981                 if (!posts.ContainsKey(nextPost.InReplyToStatusId.Value))
982                     break;
983                 nextPost = posts[nextPost.InReplyToStatusId.Value];
984             }
985
986             return nextPost;
987         }
988
989         public async Task GetRelatedResult(bool read, RelatedPostsTabModel tab)
990         {
991             var targetPost = tab.TargetPost;
992             var relPosts = new Dictionary<long, PostClass>();
993             if (targetPost.TextFromApi.Contains("@") && targetPost.InReplyToStatusId == null)
994             {
995                 // 検索結果対応
996                 var p = TabInformations.GetInstance()[targetPost.StatusId];
997                 if (p != null && p.InReplyToStatusId != null)
998                 {
999                     targetPost = p;
1000                 }
1001                 else
1002                 {
1003                     p = await this.GetStatusApi(read, targetPost.StatusId)
1004                         .ConfigureAwait(false);
1005                     targetPost = p;
1006                 }
1007             }
1008             relPosts.Add(targetPost.StatusId, targetPost);
1009
1010             Exception? lastException = null;
1011
1012             // in_reply_to_status_id を使用してリプライチェインを辿る
1013             var nextPost = FindTopOfReplyChain(relPosts, targetPost.StatusId);
1014             var loopCount = 1;
1015             while (nextPost.InReplyToStatusId != null && loopCount++ <= 20)
1016             {
1017                 var inReplyToId = nextPost.InReplyToStatusId.Value;
1018
1019                 var inReplyToPost = TabInformations.GetInstance()[inReplyToId];
1020                 if (inReplyToPost == null)
1021                 {
1022                     try
1023                     {
1024                         inReplyToPost = await this.GetStatusApi(read, inReplyToId)
1025                             .ConfigureAwait(false);
1026                     }
1027                     catch (WebApiException ex)
1028                     {
1029                         lastException = ex;
1030                         break;
1031                     }
1032                 }
1033
1034                 relPosts.Add(inReplyToPost.StatusId, inReplyToPost);
1035
1036                 nextPost = FindTopOfReplyChain(relPosts, nextPost.StatusId);
1037             }
1038
1039             // MRTとかに対応のためツイート内にあるツイートを指すURLを取り込む
1040             var text = targetPost.Text;
1041             var ma = Twitter.StatusUrlRegex.Matches(text).Cast<Match>()
1042                 .Concat(Twitter.ThirdPartyStatusUrlRegex.Matches(text).Cast<Match>());
1043             foreach (var match in ma)
1044             {
1045                 if (long.TryParse(match.Groups["StatusId"].Value, out var statusId))
1046                 {
1047                     if (relPosts.ContainsKey(statusId))
1048                         continue;
1049
1050                     var p = TabInformations.GetInstance()[statusId];
1051                     if (p == null)
1052                     {
1053                         try
1054                         {
1055                             p = await this.GetStatusApi(read, statusId)
1056                                 .ConfigureAwait(false);
1057                         }
1058                         catch (WebApiException ex)
1059                         {
1060                             lastException = ex;
1061                             break;
1062                         }
1063                     }
1064
1065                     if (p != null)
1066                         relPosts.Add(p.StatusId, p);
1067                 }
1068             }
1069
1070             relPosts.Values.ToList().ForEach(p =>
1071             {
1072                 if (p.IsMe && !read && this.ReadOwnPost)
1073                     p.IsRead = true;
1074                 else
1075                     p.IsRead = read;
1076
1077                 tab.AddPostQueue(p);
1078             });
1079
1080             if (lastException != null)
1081                 throw new WebApiException(lastException.Message, lastException);
1082         }
1083
1084         public async Task GetSearch(bool read, PublicSearchTabModel tab, bool more)
1085         {
1086             var count = GetApiResultCount(MyCommon.WORKERTYPE.PublicSearch, more, false);
1087
1088             long? maxId = null;
1089             long? sinceId = null;
1090             if (more)
1091             {
1092                 maxId = tab.OldestId - 1;
1093             }
1094             else
1095             {
1096                 sinceId = tab.SinceId;
1097             }
1098
1099             var searchResult = await this.Api.SearchTweets(tab.SearchWords, tab.SearchLang, count, maxId, sinceId)
1100                 .ConfigureAwait(false);
1101
1102             if (!TabInformations.GetInstance().ContainsTab(tab))
1103                 return;
1104
1105             var minimumId = this.CreatePostsFromSearchJson(searchResult, tab, read, more);
1106
1107             if (minimumId != null)
1108                 tab.OldestId = minimumId.Value;
1109         }
1110
1111         public async Task GetDirectMessageEvents(bool read, bool backward)
1112         {
1113             this.CheckAccountState();
1114             this.CheckAccessLevel(TwitterApiAccessLevel.ReadWriteAndDirectMessage);
1115
1116             var count = 50;
1117
1118             TwitterMessageEventList eventList;
1119             if (backward)
1120             {
1121                 eventList = await this.Api.DirectMessagesEventsList(count, this.nextCursorDirectMessage)
1122                     .ConfigureAwait(false);
1123             }
1124             else
1125             {
1126                 eventList = await this.Api.DirectMessagesEventsList(count)
1127                     .ConfigureAwait(false);
1128             }
1129
1130             this.nextCursorDirectMessage = eventList.NextCursor;
1131
1132             await this.CreateDirectMessagesEventFromJson(eventList, read)
1133                 .ConfigureAwait(false);
1134         }
1135
1136         private async Task CreateDirectMessagesEventFromJson(TwitterMessageEventSingle eventSingle, bool read)
1137         {
1138             var eventList = new TwitterMessageEventList
1139             {
1140                 Apps = new Dictionary<string, TwitterMessageEventList.App>(),
1141                 Events = new[] { eventSingle.Event },
1142             };
1143
1144             await this.CreateDirectMessagesEventFromJson(eventList, read)
1145                 .ConfigureAwait(false);
1146         }
1147
1148         private async Task CreateDirectMessagesEventFromJson(TwitterMessageEventList eventList, bool read)
1149         {
1150             var events = eventList.Events
1151                 .Where(x => x.Type == "message_create")
1152                 .ToArray();
1153
1154             if (events.Length == 0)
1155                 return;
1156
1157             var userIds = Enumerable.Concat(
1158                 events.Select(x => x.MessageCreate.SenderId),
1159                 events.Select(x => x.MessageCreate.Target.RecipientId)
1160             ).Distinct().ToArray();
1161
1162             var users = (await this.Api.UsersLookup(userIds).ConfigureAwait(false))
1163                 .ToDictionary(x => x.IdStr);
1164
1165             var apps = eventList.Apps ?? new Dictionary<string, TwitterMessageEventList.App>();
1166
1167             this.CreateDirectMessagesEventFromJson(events, users, apps, read);
1168         }
1169
1170         private void CreateDirectMessagesEventFromJson(
1171             IEnumerable<TwitterMessageEvent> events,
1172             IReadOnlyDictionary<string, TwitterUser> users,
1173             IReadOnlyDictionary<string, TwitterMessageEventList.App> apps,
1174             bool read)
1175         {
1176             foreach (var eventItem in events)
1177             {
1178                 var post = new PostClass();
1179                 post.StatusId = long.Parse(eventItem.Id);
1180
1181                 var timestamp = long.Parse(eventItem.CreatedTimestamp);
1182                 post.CreatedAt = DateTimeUtc.UnixEpoch + TimeSpan.FromTicks(timestamp * TimeSpan.TicksPerMillisecond);
1183                 // 本文
1184                 var textFromApi = eventItem.MessageCreate.MessageData.Text;
1185
1186                 var entities = eventItem.MessageCreate.MessageData.Entities;
1187                 var mediaEntity = eventItem.MessageCreate.MessageData.Attachment?.Media;
1188
1189                 if (mediaEntity != null)
1190                     entities.Media = new[] { mediaEntity };
1191
1192                 // HTMLに整形
1193                 post.Text = CreateHtmlAnchor(textFromApi, entities, quotedStatusLink: null);
1194                 post.TextFromApi = this.ReplaceTextFromApi(textFromApi, entities, quotedStatusLink: null);
1195                 post.TextFromApi = WebUtility.HtmlDecode(post.TextFromApi);
1196                 post.TextFromApi = post.TextFromApi.Replace("<3", "\u2661");
1197                 post.AccessibleText = CreateAccessibleText(textFromApi, entities, quotedStatus: null, quotedStatusLink: null);
1198                 post.AccessibleText = WebUtility.HtmlDecode(post.AccessibleText);
1199                 post.AccessibleText = post.AccessibleText.Replace("<3", "\u2661");
1200                 post.IsFav = false;
1201
1202                 this.ExtractEntities(entities, post.ReplyToList, post.Media);
1203
1204                 post.QuoteStatusIds = GetQuoteTweetStatusIds(entities, quotedStatusLink: null)
1205                     .Distinct().ToArray();
1206
1207                 post.ExpandedUrls = entities.OfType<TwitterEntityUrl>()
1208                     .Select(x => new PostClass.ExpandedUrlInfo(x.Url, x.ExpandedUrl))
1209                     .ToArray();
1210
1211                 // 以下、ユーザー情報
1212                 string userId;
1213                 if (eventItem.MessageCreate.SenderId != this.Api.CurrentUserId.ToString(CultureInfo.InvariantCulture))
1214                 {
1215                     userId = eventItem.MessageCreate.SenderId;
1216                     post.IsMe = false;
1217                     post.IsOwl = true;
1218                 }
1219                 else
1220                 {
1221                     userId = eventItem.MessageCreate.Target.RecipientId;
1222                     post.IsMe = true;
1223                     post.IsOwl = false;
1224                 }
1225
1226                 if (!users.TryGetValue(userId, out var user))
1227                     continue;
1228
1229                 post.UserId = user.Id;
1230                 post.ScreenName = user.ScreenName;
1231                 post.Nickname = user.Name.Trim();
1232                 post.ImageUrl = user.ProfileImageUrlHttps;
1233                 post.IsProtect = user.Protected;
1234
1235                 // メモリ使用量削減 (同一のテキストであれば同一の string インスタンスを参照させる)
1236                 if (post.Text == post.TextFromApi)
1237                     post.Text = post.TextFromApi;
1238                 if (post.AccessibleText == post.TextFromApi)
1239                     post.AccessibleText = post.TextFromApi;
1240
1241                 // 他の発言と重複しやすい (共通化できる) 文字列は string.Intern を通す
1242                 post.ScreenName = string.Intern(post.ScreenName);
1243                 post.Nickname = string.Intern(post.Nickname);
1244                 post.ImageUrl = string.Intern(post.ImageUrl);
1245
1246                 var appId = eventItem.MessageCreate.SourceAppId;
1247                 if (appId != null && apps.TryGetValue(appId, out var app))
1248                 {
1249                     post.Source = string.Intern(app.Name);
1250
1251                     try
1252                     {
1253                         post.SourceUri = new Uri(SourceUriBase, app.Url);
1254                     }
1255                     catch (UriFormatException)
1256                     {
1257                     }
1258                 }
1259
1260                 post.IsRead = read;
1261                 if (post.IsMe && !read && this.ReadOwnPost)
1262                     post.IsRead = true;
1263                 post.IsReply = false;
1264                 post.IsExcludeReply = false;
1265                 post.IsDm = true;
1266
1267                 var dmTab = TabInformations.GetInstance().DirectMessageTab;
1268                 dmTab.AddPostQueue(post);
1269             }
1270         }
1271
1272         public async Task GetFavoritesApi(bool read, FavoritesTabModel tab, bool backward)
1273         {
1274             this.CheckAccountState();
1275
1276             var count = GetApiResultCount(MyCommon.WORKERTYPE.Favorites, backward, false);
1277
1278             TwitterStatus[] statuses;
1279             if (backward)
1280             {
1281                 statuses = await this.Api.FavoritesList(count, maxId: tab.OldestId)
1282                     .ConfigureAwait(false);
1283             }
1284             else
1285             {
1286                 statuses = await this.Api.FavoritesList(count)
1287                     .ConfigureAwait(false);
1288             }
1289
1290             var minimumId = this.CreateFavoritePostsFromJson(statuses, read);
1291
1292             if (minimumId != null)
1293                 tab.OldestId = minimumId.Value;
1294         }
1295
1296         private string ReplaceTextFromApi(string text, TwitterEntities? entities, TwitterQuotedStatusPermalink? quotedStatusLink)
1297         {
1298             if (entities != null)
1299             {
1300                 if (entities.Urls != null)
1301                 {
1302                     foreach (var m in entities.Urls)
1303                     {
1304                         if (!MyCommon.IsNullOrEmpty(m.DisplayUrl)) text = text.Replace(m.Url, m.DisplayUrl);
1305                     }
1306                 }
1307                 if (entities.Media != null)
1308                 {
1309                     foreach (var m in entities.Media)
1310                     {
1311                         if (!MyCommon.IsNullOrEmpty(m.DisplayUrl)) text = text.Replace(m.Url, m.DisplayUrl);
1312                     }
1313                 }
1314             }
1315
1316             if (quotedStatusLink != null)
1317                 text += " " + quotedStatusLink.Display;
1318
1319             return text;
1320         }
1321
1322         internal static string CreateAccessibleText(string text, TwitterEntities? entities, TwitterStatus? quotedStatus, TwitterQuotedStatusPermalink? quotedStatusLink)
1323         {
1324             if (entities == null)
1325                 return text;
1326
1327             if (entities.Urls != null)
1328             {
1329                 foreach (var entity in entities.Urls)
1330                 {
1331                     if (quotedStatus != null)
1332                     {
1333                         var matchStatusUrl = Twitter.StatusUrlRegex.Match(entity.ExpandedUrl);
1334                         if (matchStatusUrl.Success && matchStatusUrl.Groups["StatusId"].Value == quotedStatus.IdStr)
1335                         {
1336                             var quotedText = CreateAccessibleText(quotedStatus.FullText, quotedStatus.MergedEntities, quotedStatus: null, quotedStatusLink: null);
1337                             text = text.Replace(entity.Url, string.Format(Properties.Resources.QuoteStatus_AccessibleText, quotedStatus.User.ScreenName, quotedText));
1338                             continue;
1339                         }
1340                     }
1341
1342                     if (!MyCommon.IsNullOrEmpty(entity.DisplayUrl))
1343                         text = text.Replace(entity.Url, entity.DisplayUrl);
1344                 }
1345             }
1346
1347             if (entities.Media != null)
1348             {
1349                 foreach (var entity in entities.Media)
1350                 {
1351                     if (!MyCommon.IsNullOrEmpty(entity.AltText))
1352                     {
1353                         text = text.Replace(entity.Url, string.Format(Properties.Resources.ImageAltText, entity.AltText));
1354                     }
1355                     else if (!MyCommon.IsNullOrEmpty(entity.DisplayUrl))
1356                     {
1357                         text = text.Replace(entity.Url, entity.DisplayUrl);
1358                     }
1359                 }
1360             }
1361
1362             if (quotedStatus != null && quotedStatusLink != null)
1363             {
1364                 var quoteText = CreateAccessibleText(quotedStatus.FullText, quotedStatus.MergedEntities, quotedStatus: null, quotedStatusLink: null);
1365                 text += " " + string.Format(Properties.Resources.QuoteStatus_AccessibleText, quotedStatus.User.ScreenName, quoteText);
1366             }
1367
1368             return text;
1369         }
1370
1371         /// <summary>
1372         /// フォロワーIDを更新します
1373         /// </summary>
1374         /// <exception cref="WebApiException"/>
1375         public async Task RefreshFollowerIds()
1376         {
1377             if (MyCommon.EndingFlag) return;
1378
1379             var cursor = -1L;
1380             var newFollowerIds = Enumerable.Empty<long>();
1381             do
1382             {
1383                 var ret = await this.Api.FollowersIds(cursor)
1384                     .ConfigureAwait(false);
1385
1386                 if (ret.Ids == null)
1387                     throw new WebApiException("ret.ids == null");
1388
1389                 newFollowerIds = newFollowerIds.Concat(ret.Ids);
1390                 cursor = ret.NextCursor;
1391             }
1392             while (cursor != 0);
1393
1394             this.followerId = newFollowerIds.ToHashSet();
1395             TabInformations.GetInstance().RefreshOwl(this.followerId);
1396
1397             this.GetFollowersSuccess = true;
1398         }
1399
1400         /// <summary>
1401         /// RT 非表示ユーザーを更新します
1402         /// </summary>
1403         /// <exception cref="WebApiException"/>
1404         public async Task RefreshNoRetweetIds()
1405         {
1406             if (MyCommon.EndingFlag) return;
1407
1408             this.noRTId = await this.Api.NoRetweetIds()
1409                 .ConfigureAwait(false);
1410
1411             this.GetNoRetweetSuccess = true;
1412         }
1413
1414         /// <summary>
1415         /// t.co の文字列長などの設定情報を更新します
1416         /// </summary>
1417         /// <exception cref="WebApiException"/>
1418         public async Task RefreshConfiguration()
1419         {
1420             this.Configuration = await this.Api.Configuration()
1421                 .ConfigureAwait(false);
1422
1423             // TextConfiguration 相当の JSON を得る API が存在しないため、TransformedURLLength のみ help/configuration.json に合わせて更新する
1424             this.TextConfiguration.TransformedURLLength = this.Configuration.ShortUrlLengthHttps;
1425         }
1426
1427         public async Task GetListsApi()
1428         {
1429             this.CheckAccountState();
1430
1431             var ownedLists = await TwitterLists.GetAllItemsAsync(x =>
1432                 this.Api.ListsOwnerships(this.Username, cursor: x, count: 1000))
1433                     .ConfigureAwait(false);
1434
1435             var subscribedLists = await TwitterLists.GetAllItemsAsync(x =>
1436                 this.Api.ListsSubscriptions(this.Username, cursor: x, count: 1000))
1437                     .ConfigureAwait(false);
1438
1439             TabInformations.GetInstance().SubscribableLists = Enumerable.Concat(ownedLists, subscribedLists)
1440                 .Select(x => new ListElement(x, this))
1441                 .ToList();
1442         }
1443
1444         public async Task DeleteList(long listId)
1445         {
1446             await this.Api.ListsDestroy(listId)
1447                 .IgnoreResponse()
1448                 .ConfigureAwait(false);
1449
1450             var tabinfo = TabInformations.GetInstance();
1451
1452             tabinfo.SubscribableLists = tabinfo.SubscribableLists
1453                 .Where(x => x.Id != listId)
1454                 .ToList();
1455         }
1456
1457         public async Task<ListElement> EditList(long listId, string new_name, bool isPrivate, string description)
1458         {
1459             var response = await this.Api.ListsUpdate(listId, new_name, description, isPrivate)
1460                 .ConfigureAwait(false);
1461
1462             var list = await response.LoadJsonAsync()
1463                 .ConfigureAwait(false);
1464
1465             return new ListElement(list, this);
1466         }
1467
1468         public async Task<long> GetListMembers(long listId, List<UserInfo> lists, long cursor)
1469         {
1470             this.CheckAccountState();
1471
1472             var users = await this.Api.ListsMembers(listId, cursor)
1473                 .ConfigureAwait(false);
1474
1475             Array.ForEach(users.Users, u => lists.Add(new UserInfo(u)));
1476
1477             return users.NextCursor;
1478         }
1479
1480         public async Task CreateListApi(string listName, bool isPrivate, string description)
1481         {
1482             this.CheckAccountState();
1483
1484             var response = await this.Api.ListsCreate(listName, description, isPrivate)
1485                 .ConfigureAwait(false);
1486
1487             var list = await response.LoadJsonAsync()
1488                 .ConfigureAwait(false);
1489
1490             TabInformations.GetInstance().SubscribableLists.Add(new ListElement(list, this));
1491         }
1492
1493         public async Task<bool> ContainsUserAtList(long listId, string user)
1494         {
1495             this.CheckAccountState();
1496
1497             try
1498             {
1499                 await this.Api.ListsMembersShow(listId, user)
1500                     .ConfigureAwait(false);
1501
1502                 return true;
1503             }
1504             catch (TwitterApiException ex)
1505                 when (ex.Errors.Any(x => x.Code == TwitterErrorCode.NotFound))
1506             {
1507                 return false;
1508             }
1509         }
1510
1511         private void ExtractEntities(TwitterEntities? entities, List<(long UserId, string ScreenName)> atList, List<MediaInfo> media)
1512         {
1513             if (entities != null)
1514             {
1515                 if (entities.Hashtags != null)
1516                 {
1517                     lock (this.lockObj)
1518                     {
1519                         this.hashList.AddRange(entities.Hashtags.Select(x => "#" + x.Text));
1520                     }
1521                 }
1522                 if (entities.UserMentions != null)
1523                 {
1524                     foreach (var ent in entities.UserMentions)
1525                     {
1526                         atList.Add((ent.Id, ent.ScreenName));
1527                     }
1528                 }
1529                 if (entities.Media != null)
1530                 {
1531                     if (media != null)
1532                     {
1533                         foreach (var ent in entities.Media)
1534                         {
1535                             if (!media.Any(x => x.Url == ent.MediaUrlHttps))
1536                             {
1537                                 if (ent.VideoInfo != null &&
1538                                     ent.Type == "animated_gif" || ent.Type == "video")
1539                                 {
1540                                     media.Add(new MediaInfo(ent.MediaUrlHttps, ent.AltText, ent.ExpandedUrl));
1541                                 }
1542                                 else
1543                                 {
1544                                     media.Add(new MediaInfo(ent.MediaUrlHttps, ent.AltText, videoUrl: null));
1545                                 }
1546                             }
1547                         }
1548                     }
1549                 }
1550             }
1551         }
1552
1553         internal static string CreateHtmlAnchor(string text, TwitterEntities? entities, TwitterQuotedStatusPermalink? quotedStatusLink)
1554         {
1555             var mergedEntities = entities.Concat(TweetExtractor.ExtractEmojiEntities(text));
1556
1557             // PostClass.ExpandedUrlInfo を使用して非同期に URL 展開を行うためここでは expanded_url を使用しない
1558             text = TweetFormatter.AutoLinkHtml(text, mergedEntities, keepTco: true);
1559
1560             text = Regex.Replace(text, "(^|[^a-zA-Z0-9_/&##@@>=.~])(sm|nm)([0-9]{1,10})", "$1<a href=\"https://www.nicovideo.jp/watch/$2$3\">$2$3</a>");
1561             text = PreProcessUrl(text); // IDN置換
1562
1563             if (quotedStatusLink != null)
1564             {
1565                 text += string.Format(" <a href=\"{0}\" title=\"{0}\">{1}</a>",
1566                     WebUtility.HtmlEncode(quotedStatusLink.Url),
1567                     WebUtility.HtmlEncode(quotedStatusLink.Display));
1568             }
1569
1570             return text;
1571         }
1572
1573         private static readonly Uri SourceUriBase = new Uri("https://twitter.com/");
1574
1575         /// <summary>
1576         /// Twitter APIから得たHTML形式のsource文字列を分析し、source名とURLに分離します
1577         /// </summary>
1578         internal static (string SourceText, Uri? SourceUri) ParseSource(string? sourceHtml)
1579         {
1580             if (MyCommon.IsNullOrEmpty(sourceHtml))
1581                 return ("", null);
1582
1583             string sourceText;
1584             Uri? sourceUri;
1585
1586             // sourceHtmlの例: <a href="http://twitter.com" rel="nofollow">Twitter Web Client</a>
1587
1588             var match = Regex.Match(sourceHtml, "^<a href=\"(?<uri>.+?)\".*?>(?<text>.+)</a>$", RegexOptions.IgnoreCase);
1589             if (match.Success)
1590             {
1591                 sourceText = WebUtility.HtmlDecode(match.Groups["text"].Value);
1592                 try
1593                 {
1594                     var uriStr = WebUtility.HtmlDecode(match.Groups["uri"].Value);
1595                     sourceUri = new Uri(SourceUriBase, uriStr);
1596                 }
1597                 catch (UriFormatException)
1598                 {
1599                     sourceUri = null;
1600                 }
1601             }
1602             else
1603             {
1604                 sourceText = WebUtility.HtmlDecode(sourceHtml);
1605                 sourceUri = null;
1606             }
1607
1608             return (sourceText, sourceUri);
1609         }
1610
1611         public async Task<TwitterApiStatus?> GetInfoApi()
1612         {
1613             if (Twitter.AccountState != MyCommon.ACCOUNT_STATE.Valid) return null;
1614
1615             if (MyCommon.EndingFlag) return null;
1616
1617             var limits = await this.Api.ApplicationRateLimitStatus()
1618                 .ConfigureAwait(false);
1619
1620             MyCommon.TwitterApiInfo.UpdateFromJson(limits);
1621
1622             return MyCommon.TwitterApiInfo;
1623         }
1624
1625         /// <summary>
1626         /// ブロック中のユーザーを更新します
1627         /// </summary>
1628         /// <exception cref="WebApiException"/>
1629         public async Task RefreshBlockIds()
1630         {
1631             if (MyCommon.EndingFlag) return;
1632
1633             var cursor = -1L;
1634             var newBlockIds = Enumerable.Empty<long>();
1635             do
1636             {
1637                 var ret = await this.Api.BlocksIds(cursor)
1638                     .ConfigureAwait(false);
1639
1640                 newBlockIds = newBlockIds.Concat(ret.Ids);
1641                 cursor = ret.NextCursor;
1642             }
1643             while (cursor != 0);
1644
1645             var blockIdsSet = newBlockIds.ToHashSet();
1646             blockIdsSet.Remove(this.UserId); // 元のソースにあったので一応残しておく
1647
1648             TabInformations.GetInstance().BlockIds = blockIdsSet;
1649         }
1650
1651         /// <summary>
1652         /// ミュート中のユーザーIDを更新します
1653         /// </summary>
1654         /// <exception cref="WebApiException"/>
1655         public async Task RefreshMuteUserIdsAsync()
1656         {
1657             if (MyCommon.EndingFlag) return;
1658
1659             var ids = await TwitterIds.GetAllItemsAsync(x => this.Api.MutesUsersIds(x))
1660                 .ConfigureAwait(false);
1661
1662             TabInformations.GetInstance().MuteUserIds = ids.ToHashSet();
1663         }
1664
1665         public string[] GetHashList()
1666         {
1667             string[] hashArray;
1668             lock (this.lockObj)
1669             {
1670                 hashArray = this.hashList.ToArray();
1671                 this.hashList.Clear();
1672             }
1673             return hashArray;
1674         }
1675
1676         public string AccessToken
1677             => ((TwitterApiConnection)this.Api.Connection).AccessToken;
1678
1679         public string AccessTokenSecret
1680             => ((TwitterApiConnection)this.Api.Connection).AccessSecret;
1681
1682         private void CheckAccountState()
1683         {
1684             if (Twitter.AccountState != MyCommon.ACCOUNT_STATE.Valid)
1685                 throw new WebApiException("Auth error. Check your account");
1686         }
1687
1688         private void CheckAccessLevel(TwitterApiAccessLevel accessLevelFlags)
1689         {
1690             if (!this.AccessLevel.HasFlag(accessLevelFlags))
1691                 throw new WebApiException("Auth Err:try to re-authorization.");
1692         }
1693
1694         public int GetTextLengthRemain(string postText)
1695         {
1696             var matchDm = Twitter.DMSendTextRegex.Match(postText);
1697             if (matchDm.Success)
1698                 return this.GetTextLengthRemainDM(matchDm.Groups["body"].Value);
1699
1700             return this.GetTextLengthRemainWeighted(postText);
1701         }
1702
1703         private int GetTextLengthRemainDM(string postText)
1704         {
1705             var textLength = 0;
1706
1707             var pos = 0;
1708             while (pos < postText.Length)
1709             {
1710                 textLength++;
1711
1712                 if (char.IsSurrogatePair(postText, pos))
1713                     pos += 2; // サロゲートペアの場合は2文字分進める
1714                 else
1715                     pos++;
1716             }
1717
1718             var urls = TweetExtractor.ExtractUrls(postText);
1719             foreach (var url in urls)
1720             {
1721                 var shortUrlLength = url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
1722                     ? this.Configuration.ShortUrlLengthHttps
1723                     : this.Configuration.ShortUrlLength;
1724
1725                 textLength += shortUrlLength - url.Length;
1726             }
1727
1728             return this.Configuration.DmTextCharacterLimit - textLength;
1729         }
1730
1731         private int GetTextLengthRemainWeighted(string postText)
1732         {
1733             var config = this.TextConfiguration;
1734             var totalWeight = 0;
1735
1736             int GetWeightFromCodepoint(int codepoint)
1737             {
1738                 foreach (var weightRange in config.Ranges)
1739                 {
1740                     if (codepoint >= weightRange.Start && codepoint <= weightRange.End)
1741                         return weightRange.Weight;
1742                 }
1743
1744                 return config.DefaultWeight;
1745             }
1746
1747             var urls = TweetExtractor.ExtractUrlEntities(postText).ToArray();
1748             var emojis = config.EmojiParsingEnabled
1749                 ? TweetExtractor.ExtractEmojiEntities(postText).ToArray()
1750                 : Array.Empty<TwitterEntityEmoji>();
1751
1752             var codepoints = postText.ToCodepoints().ToArray();
1753             var index = 0;
1754             while (index < codepoints.Length)
1755             {
1756                 var urlEntity = urls.FirstOrDefault(x => x.Indices[0] == index);
1757                 if (urlEntity != null)
1758                 {
1759                     totalWeight += config.TransformedURLLength * config.Scale;
1760                     index = urlEntity.Indices[1];
1761                     continue;
1762                 }
1763
1764                 var emojiEntity = emojis.FirstOrDefault(x => x.Indices[0] == index);
1765                 if (emojiEntity != null)
1766                 {
1767                     totalWeight += GetWeightFromCodepoint(codepoints[index]);
1768                     index = emojiEntity.Indices[1];
1769                     continue;
1770                 }
1771
1772                 var codepoint = codepoints[index];
1773                 totalWeight += GetWeightFromCodepoint(codepoint);
1774
1775                 index++;
1776             }
1777
1778             var remainWeight = config.MaxWeightedTweetLength * config.Scale - totalWeight;
1779
1780             return remainWeight / config.Scale;
1781         }
1782
1783         public bool IsDisposed { get; private set; } = false;
1784
1785         protected virtual void Dispose(bool disposing)
1786         {
1787             if (this.IsDisposed)
1788                 return;
1789
1790             if (disposing)
1791             {
1792                 this.Api.Dispose();
1793             }
1794
1795             this.IsDisposed = true;
1796         }
1797
1798         public void Dispose()
1799         {
1800             this.Dispose(true);
1801             GC.SuppressFinalize(this);
1802         }
1803     }
1804 }