OSDN Git Service

パターンマッチングを使用する
[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(@"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(
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(
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(@"^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();
171         private ISet<long> followerId = new HashSet<long>();
172         private long[] noRTId = Array.Empty<long>();
173
174         // プロパティからアクセスされる共通情報
175         private readonly List<string> hashList = new();
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                 var post = p.Clone();
1073                 if (post.IsMe && !read && this.ReadOwnPost)
1074                     post.IsRead = true;
1075                 else
1076                     post.IsRead = read;
1077
1078                 tab.AddPostQueue(post);
1079             });
1080
1081             if (lastException != null)
1082                 throw new WebApiException(lastException.Message, lastException);
1083         }
1084
1085         public async Task GetSearch(bool read, PublicSearchTabModel tab, bool more)
1086         {
1087             var count = GetApiResultCount(MyCommon.WORKERTYPE.PublicSearch, more, false);
1088
1089             long? maxId = null;
1090             long? sinceId = null;
1091             if (more)
1092             {
1093                 maxId = tab.OldestId - 1;
1094             }
1095             else
1096             {
1097                 sinceId = tab.SinceId;
1098             }
1099
1100             var searchResult = await this.Api.SearchTweets(tab.SearchWords, tab.SearchLang, count, maxId, sinceId)
1101                 .ConfigureAwait(false);
1102
1103             if (!TabInformations.GetInstance().ContainsTab(tab))
1104                 return;
1105
1106             var minimumId = this.CreatePostsFromSearchJson(searchResult, tab, read, more);
1107
1108             if (minimumId != null)
1109                 tab.OldestId = minimumId.Value;
1110         }
1111
1112         public async Task GetDirectMessageEvents(bool read, bool backward)
1113         {
1114             this.CheckAccountState();
1115             this.CheckAccessLevel(TwitterApiAccessLevel.ReadWriteAndDirectMessage);
1116
1117             var count = 50;
1118
1119             TwitterMessageEventList eventList;
1120             if (backward)
1121             {
1122                 eventList = await this.Api.DirectMessagesEventsList(count, this.nextCursorDirectMessage)
1123                     .ConfigureAwait(false);
1124             }
1125             else
1126             {
1127                 eventList = await this.Api.DirectMessagesEventsList(count)
1128                     .ConfigureAwait(false);
1129             }
1130
1131             this.nextCursorDirectMessage = eventList.NextCursor;
1132
1133             await this.CreateDirectMessagesEventFromJson(eventList, read)
1134                 .ConfigureAwait(false);
1135         }
1136
1137         private async Task CreateDirectMessagesEventFromJson(TwitterMessageEventSingle eventSingle, bool read)
1138         {
1139             var eventList = new TwitterMessageEventList
1140             {
1141                 Apps = new Dictionary<string, TwitterMessageEventList.App>(),
1142                 Events = new[] { eventSingle.Event },
1143             };
1144
1145             await this.CreateDirectMessagesEventFromJson(eventList, read)
1146                 .ConfigureAwait(false);
1147         }
1148
1149         private async Task CreateDirectMessagesEventFromJson(TwitterMessageEventList eventList, bool read)
1150         {
1151             var events = eventList.Events
1152                 .Where(x => x.Type == "message_create")
1153                 .ToArray();
1154
1155             if (events.Length == 0)
1156                 return;
1157
1158             var userIds = Enumerable.Concat(
1159                 events.Select(x => x.MessageCreate.SenderId),
1160                 events.Select(x => x.MessageCreate.Target.RecipientId)
1161             ).Distinct().ToArray();
1162
1163             var users = (await this.Api.UsersLookup(userIds).ConfigureAwait(false))
1164                 .ToDictionary(x => x.IdStr);
1165
1166             var apps = eventList.Apps ?? new Dictionary<string, TwitterMessageEventList.App>();
1167
1168             this.CreateDirectMessagesEventFromJson(events, users, apps, read);
1169         }
1170
1171         private void CreateDirectMessagesEventFromJson(
1172             IEnumerable<TwitterMessageEvent> events,
1173             IReadOnlyDictionary<string, TwitterUser> users,
1174             IReadOnlyDictionary<string, TwitterMessageEventList.App> apps,
1175             bool read)
1176         {
1177             foreach (var eventItem in events)
1178             {
1179                 var post = new PostClass();
1180                 post.StatusId = long.Parse(eventItem.Id);
1181
1182                 var timestamp = long.Parse(eventItem.CreatedTimestamp);
1183                 post.CreatedAt = DateTimeUtc.UnixEpoch + TimeSpan.FromTicks(timestamp * TimeSpan.TicksPerMillisecond);
1184                 // 本文
1185                 var textFromApi = eventItem.MessageCreate.MessageData.Text;
1186
1187                 var entities = eventItem.MessageCreate.MessageData.Entities;
1188                 var mediaEntity = eventItem.MessageCreate.MessageData.Attachment?.Media;
1189
1190                 if (mediaEntity != null)
1191                     entities.Media = new[] { mediaEntity };
1192
1193                 // HTMLに整形
1194                 post.Text = CreateHtmlAnchor(textFromApi, entities, quotedStatusLink: null);
1195                 post.TextFromApi = this.ReplaceTextFromApi(textFromApi, entities, quotedStatusLink: null);
1196                 post.TextFromApi = WebUtility.HtmlDecode(post.TextFromApi);
1197                 post.TextFromApi = post.TextFromApi.Replace("<3", "\u2661");
1198                 post.AccessibleText = CreateAccessibleText(textFromApi, entities, quotedStatus: null, quotedStatusLink: null);
1199                 post.AccessibleText = WebUtility.HtmlDecode(post.AccessibleText);
1200                 post.AccessibleText = post.AccessibleText.Replace("<3", "\u2661");
1201                 post.IsFav = false;
1202
1203                 this.ExtractEntities(entities, post.ReplyToList, post.Media);
1204
1205                 post.QuoteStatusIds = GetQuoteTweetStatusIds(entities, quotedStatusLink: null)
1206                     .Distinct().ToArray();
1207
1208                 post.ExpandedUrls = entities.OfType<TwitterEntityUrl>()
1209                     .Select(x => new PostClass.ExpandedUrlInfo(x.Url, x.ExpandedUrl))
1210                     .ToArray();
1211
1212                 // 以下、ユーザー情報
1213                 string userId;
1214                 if (eventItem.MessageCreate.SenderId != this.Api.CurrentUserId.ToString(CultureInfo.InvariantCulture))
1215                 {
1216                     userId = eventItem.MessageCreate.SenderId;
1217                     post.IsMe = false;
1218                     post.IsOwl = true;
1219                 }
1220                 else
1221                 {
1222                     userId = eventItem.MessageCreate.Target.RecipientId;
1223                     post.IsMe = true;
1224                     post.IsOwl = false;
1225                 }
1226
1227                 if (!users.TryGetValue(userId, out var user))
1228                     continue;
1229
1230                 post.UserId = user.Id;
1231                 post.ScreenName = user.ScreenName;
1232                 post.Nickname = user.Name.Trim();
1233                 post.ImageUrl = user.ProfileImageUrlHttps;
1234                 post.IsProtect = user.Protected;
1235
1236                 // メモリ使用量削減 (同一のテキストであれば同一の string インスタンスを参照させる)
1237                 if (post.Text == post.TextFromApi)
1238                     post.Text = post.TextFromApi;
1239                 if (post.AccessibleText == post.TextFromApi)
1240                     post.AccessibleText = post.TextFromApi;
1241
1242                 // 他の発言と重複しやすい (共通化できる) 文字列は string.Intern を通す
1243                 post.ScreenName = string.Intern(post.ScreenName);
1244                 post.Nickname = string.Intern(post.Nickname);
1245                 post.ImageUrl = string.Intern(post.ImageUrl);
1246
1247                 var appId = eventItem.MessageCreate.SourceAppId;
1248                 if (appId != null && apps.TryGetValue(appId, out var app))
1249                 {
1250                     post.Source = string.Intern(app.Name);
1251
1252                     try
1253                     {
1254                         post.SourceUri = new Uri(SourceUriBase, app.Url);
1255                     }
1256                     catch (UriFormatException)
1257                     {
1258                     }
1259                 }
1260
1261                 post.IsRead = read;
1262                 if (post.IsMe && !read && this.ReadOwnPost)
1263                     post.IsRead = true;
1264                 post.IsReply = false;
1265                 post.IsExcludeReply = false;
1266                 post.IsDm = true;
1267
1268                 var dmTab = TabInformations.GetInstance().DirectMessageTab;
1269                 dmTab.AddPostQueue(post);
1270             }
1271         }
1272
1273         public async Task GetFavoritesApi(bool read, FavoritesTabModel tab, bool backward)
1274         {
1275             this.CheckAccountState();
1276
1277             var count = GetApiResultCount(MyCommon.WORKERTYPE.Favorites, backward, false);
1278
1279             TwitterStatus[] statuses;
1280             if (backward)
1281             {
1282                 statuses = await this.Api.FavoritesList(count, maxId: tab.OldestId)
1283                     .ConfigureAwait(false);
1284             }
1285             else
1286             {
1287                 statuses = await this.Api.FavoritesList(count)
1288                     .ConfigureAwait(false);
1289             }
1290
1291             var minimumId = this.CreateFavoritePostsFromJson(statuses, read);
1292
1293             if (minimumId != null)
1294                 tab.OldestId = minimumId.Value;
1295         }
1296
1297         private string ReplaceTextFromApi(string text, TwitterEntities? entities, TwitterQuotedStatusPermalink? quotedStatusLink)
1298         {
1299             if (entities != null)
1300             {
1301                 if (entities.Urls != null)
1302                 {
1303                     foreach (var m in entities.Urls)
1304                     {
1305                         if (!MyCommon.IsNullOrEmpty(m.DisplayUrl)) text = text.Replace(m.Url, m.DisplayUrl);
1306                     }
1307                 }
1308                 if (entities.Media != null)
1309                 {
1310                     foreach (var m in entities.Media)
1311                     {
1312                         if (!MyCommon.IsNullOrEmpty(m.DisplayUrl)) text = text.Replace(m.Url, m.DisplayUrl);
1313                     }
1314                 }
1315             }
1316
1317             if (quotedStatusLink != null)
1318                 text += " " + quotedStatusLink.Display;
1319
1320             return text;
1321         }
1322
1323         internal static string CreateAccessibleText(string text, TwitterEntities? entities, TwitterStatus? quotedStatus, TwitterQuotedStatusPermalink? quotedStatusLink)
1324         {
1325             if (entities == null)
1326                 return text;
1327
1328             if (entities.Urls != null)
1329             {
1330                 foreach (var entity in entities.Urls)
1331                 {
1332                     if (quotedStatus != null)
1333                     {
1334                         var matchStatusUrl = Twitter.StatusUrlRegex.Match(entity.ExpandedUrl);
1335                         if (matchStatusUrl.Success && matchStatusUrl.Groups["StatusId"].Value == quotedStatus.IdStr)
1336                         {
1337                             var quotedText = CreateAccessibleText(quotedStatus.FullText, quotedStatus.MergedEntities, quotedStatus: null, quotedStatusLink: null);
1338                             text = text.Replace(entity.Url, string.Format(Properties.Resources.QuoteStatus_AccessibleText, quotedStatus.User.ScreenName, quotedText));
1339                             continue;
1340                         }
1341                     }
1342
1343                     if (!MyCommon.IsNullOrEmpty(entity.DisplayUrl))
1344                         text = text.Replace(entity.Url, entity.DisplayUrl);
1345                 }
1346             }
1347
1348             if (entities.Media != null)
1349             {
1350                 foreach (var entity in entities.Media)
1351                 {
1352                     if (!MyCommon.IsNullOrEmpty(entity.AltText))
1353                     {
1354                         text = text.Replace(entity.Url, string.Format(Properties.Resources.ImageAltText, entity.AltText));
1355                     }
1356                     else if (!MyCommon.IsNullOrEmpty(entity.DisplayUrl))
1357                     {
1358                         text = text.Replace(entity.Url, entity.DisplayUrl);
1359                     }
1360                 }
1361             }
1362
1363             if (quotedStatus != null && quotedStatusLink != null)
1364             {
1365                 var quoteText = CreateAccessibleText(quotedStatus.FullText, quotedStatus.MergedEntities, quotedStatus: null, quotedStatusLink: null);
1366                 text += " " + string.Format(Properties.Resources.QuoteStatus_AccessibleText, quotedStatus.User.ScreenName, quoteText);
1367             }
1368
1369             return text;
1370         }
1371
1372         /// <summary>
1373         /// フォロワーIDを更新します
1374         /// </summary>
1375         /// <exception cref="WebApiException"/>
1376         public async Task RefreshFollowerIds()
1377         {
1378             if (MyCommon.EndingFlag) return;
1379
1380             var cursor = -1L;
1381             var newFollowerIds = Enumerable.Empty<long>();
1382             do
1383             {
1384                 var ret = await this.Api.FollowersIds(cursor)
1385                     .ConfigureAwait(false);
1386
1387                 if (ret.Ids == null)
1388                     throw new WebApiException("ret.ids == null");
1389
1390                 newFollowerIds = newFollowerIds.Concat(ret.Ids);
1391                 cursor = ret.NextCursor;
1392             }
1393             while (cursor != 0);
1394
1395             this.followerId = newFollowerIds.ToHashSet();
1396             TabInformations.GetInstance().RefreshOwl(this.followerId);
1397
1398             this.GetFollowersSuccess = true;
1399         }
1400
1401         /// <summary>
1402         /// RT 非表示ユーザーを更新します
1403         /// </summary>
1404         /// <exception cref="WebApiException"/>
1405         public async Task RefreshNoRetweetIds()
1406         {
1407             if (MyCommon.EndingFlag) return;
1408
1409             this.noRTId = await this.Api.NoRetweetIds()
1410                 .ConfigureAwait(false);
1411
1412             this.GetNoRetweetSuccess = true;
1413         }
1414
1415         /// <summary>
1416         /// t.co の文字列長などの設定情報を更新します
1417         /// </summary>
1418         /// <exception cref="WebApiException"/>
1419         public async Task RefreshConfiguration()
1420         {
1421             this.Configuration = await this.Api.Configuration()
1422                 .ConfigureAwait(false);
1423
1424             // TextConfiguration 相当の JSON を得る API が存在しないため、TransformedURLLength のみ help/configuration.json に合わせて更新する
1425             this.TextConfiguration.TransformedURLLength = this.Configuration.ShortUrlLengthHttps;
1426         }
1427
1428         public async Task GetListsApi()
1429         {
1430             this.CheckAccountState();
1431
1432             var ownedLists = await TwitterLists.GetAllItemsAsync(x =>
1433                 this.Api.ListsOwnerships(this.Username, cursor: x, count: 1000))
1434                     .ConfigureAwait(false);
1435
1436             var subscribedLists = await TwitterLists.GetAllItemsAsync(x =>
1437                 this.Api.ListsSubscriptions(this.Username, cursor: x, count: 1000))
1438                     .ConfigureAwait(false);
1439
1440             TabInformations.GetInstance().SubscribableLists = Enumerable.Concat(ownedLists, subscribedLists)
1441                 .Select(x => new ListElement(x, this))
1442                 .ToList();
1443         }
1444
1445         public async Task DeleteList(long listId)
1446         {
1447             await this.Api.ListsDestroy(listId)
1448                 .IgnoreResponse()
1449                 .ConfigureAwait(false);
1450
1451             var tabinfo = TabInformations.GetInstance();
1452
1453             tabinfo.SubscribableLists = tabinfo.SubscribableLists
1454                 .Where(x => x.Id != listId)
1455                 .ToList();
1456         }
1457
1458         public async Task<ListElement> EditList(long listId, string new_name, bool isPrivate, string description)
1459         {
1460             var response = await this.Api.ListsUpdate(listId, new_name, description, isPrivate)
1461                 .ConfigureAwait(false);
1462
1463             var list = await response.LoadJsonAsync()
1464                 .ConfigureAwait(false);
1465
1466             return new ListElement(list, this);
1467         }
1468
1469         public async Task<long> GetListMembers(long listId, List<UserInfo> lists, long cursor)
1470         {
1471             this.CheckAccountState();
1472
1473             var users = await this.Api.ListsMembers(listId, cursor)
1474                 .ConfigureAwait(false);
1475
1476             Array.ForEach(users.Users, u => lists.Add(new UserInfo(u)));
1477
1478             return users.NextCursor;
1479         }
1480
1481         public async Task CreateListApi(string listName, bool isPrivate, string description)
1482         {
1483             this.CheckAccountState();
1484
1485             var response = await this.Api.ListsCreate(listName, description, isPrivate)
1486                 .ConfigureAwait(false);
1487
1488             var list = await response.LoadJsonAsync()
1489                 .ConfigureAwait(false);
1490
1491             TabInformations.GetInstance().SubscribableLists.Add(new ListElement(list, this));
1492         }
1493
1494         public async Task<bool> ContainsUserAtList(long listId, string user)
1495         {
1496             this.CheckAccountState();
1497
1498             try
1499             {
1500                 await this.Api.ListsMembersShow(listId, user)
1501                     .ConfigureAwait(false);
1502
1503                 return true;
1504             }
1505             catch (TwitterApiException ex)
1506                 when (ex.Errors.Any(x => x.Code == TwitterErrorCode.NotFound))
1507             {
1508                 return false;
1509             }
1510         }
1511
1512         private void ExtractEntities(TwitterEntities? entities, List<(long UserId, string ScreenName)> atList, List<MediaInfo> media)
1513         {
1514             if (entities != null)
1515             {
1516                 if (entities.Hashtags != null)
1517                 {
1518                     lock (this.lockObj)
1519                     {
1520                         this.hashList.AddRange(entities.Hashtags.Select(x => "#" + x.Text));
1521                     }
1522                 }
1523                 if (entities.UserMentions != null)
1524                 {
1525                     foreach (var ent in entities.UserMentions)
1526                     {
1527                         atList.Add((ent.Id, ent.ScreenName));
1528                     }
1529                 }
1530                 if (entities.Media != null)
1531                 {
1532                     if (media != null)
1533                     {
1534                         foreach (var ent in entities.Media)
1535                         {
1536                             if (!media.Any(x => x.Url == ent.MediaUrlHttps))
1537                             {
1538                                 if (ent.VideoInfo != null &&
1539                                     ent.Type == "animated_gif" || ent.Type == "video")
1540                                 {
1541                                     media.Add(new MediaInfo(ent.MediaUrlHttps, ent.AltText, ent.ExpandedUrl));
1542                                 }
1543                                 else
1544                                 {
1545                                     media.Add(new MediaInfo(ent.MediaUrlHttps, ent.AltText, videoUrl: null));
1546                                 }
1547                             }
1548                         }
1549                     }
1550                 }
1551             }
1552         }
1553
1554         internal static string CreateHtmlAnchor(string text, TwitterEntities? entities, TwitterQuotedStatusPermalink? quotedStatusLink)
1555         {
1556             var mergedEntities = entities.Concat(TweetExtractor.ExtractEmojiEntities(text));
1557
1558             // PostClass.ExpandedUrlInfo を使用して非同期に URL 展開を行うためここでは expanded_url を使用しない
1559             text = TweetFormatter.AutoLinkHtml(text, mergedEntities, keepTco: true);
1560
1561             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>");
1562             text = PreProcessUrl(text); // IDN置換
1563
1564             if (quotedStatusLink != null)
1565             {
1566                 text += string.Format(" <a href=\"{0}\" title=\"{0}\">{1}</a>",
1567                     WebUtility.HtmlEncode(quotedStatusLink.Url),
1568                     WebUtility.HtmlEncode(quotedStatusLink.Display));
1569             }
1570
1571             return text;
1572         }
1573
1574         private static readonly Uri SourceUriBase = new("https://twitter.com/");
1575
1576         /// <summary>
1577         /// Twitter APIから得たHTML形式のsource文字列を分析し、source名とURLに分離します
1578         /// </summary>
1579         internal static (string SourceText, Uri? SourceUri) ParseSource(string? sourceHtml)
1580         {
1581             if (MyCommon.IsNullOrEmpty(sourceHtml))
1582                 return ("", null);
1583
1584             string sourceText;
1585             Uri? sourceUri;
1586
1587             // sourceHtmlの例: <a href="http://twitter.com" rel="nofollow">Twitter Web Client</a>
1588
1589             var match = Regex.Match(sourceHtml, "^<a href=\"(?<uri>.+?)\".*?>(?<text>.+)</a>$", RegexOptions.IgnoreCase);
1590             if (match.Success)
1591             {
1592                 sourceText = WebUtility.HtmlDecode(match.Groups["text"].Value);
1593                 try
1594                 {
1595                     var uriStr = WebUtility.HtmlDecode(match.Groups["uri"].Value);
1596                     sourceUri = new Uri(SourceUriBase, uriStr);
1597                 }
1598                 catch (UriFormatException)
1599                 {
1600                     sourceUri = null;
1601                 }
1602             }
1603             else
1604             {
1605                 sourceText = WebUtility.HtmlDecode(sourceHtml);
1606                 sourceUri = null;
1607             }
1608
1609             return (sourceText, sourceUri);
1610         }
1611
1612         public async Task<TwitterApiStatus?> GetInfoApi()
1613         {
1614             if (Twitter.AccountState != MyCommon.ACCOUNT_STATE.Valid) return null;
1615
1616             if (MyCommon.EndingFlag) return null;
1617
1618             var limits = await this.Api.ApplicationRateLimitStatus()
1619                 .ConfigureAwait(false);
1620
1621             MyCommon.TwitterApiInfo.UpdateFromJson(limits);
1622
1623             return MyCommon.TwitterApiInfo;
1624         }
1625
1626         /// <summary>
1627         /// ブロック中のユーザーを更新します
1628         /// </summary>
1629         /// <exception cref="WebApiException"/>
1630         public async Task RefreshBlockIds()
1631         {
1632             if (MyCommon.EndingFlag) return;
1633
1634             var cursor = -1L;
1635             var newBlockIds = Enumerable.Empty<long>();
1636             do
1637             {
1638                 var ret = await this.Api.BlocksIds(cursor)
1639                     .ConfigureAwait(false);
1640
1641                 newBlockIds = newBlockIds.Concat(ret.Ids);
1642                 cursor = ret.NextCursor;
1643             }
1644             while (cursor != 0);
1645
1646             var blockIdsSet = newBlockIds.ToHashSet();
1647             blockIdsSet.Remove(this.UserId); // 元のソースにあったので一応残しておく
1648
1649             TabInformations.GetInstance().BlockIds = blockIdsSet;
1650         }
1651
1652         /// <summary>
1653         /// ミュート中のユーザーIDを更新します
1654         /// </summary>
1655         /// <exception cref="WebApiException"/>
1656         public async Task RefreshMuteUserIdsAsync()
1657         {
1658             if (MyCommon.EndingFlag) return;
1659
1660             var ids = await TwitterIds.GetAllItemsAsync(x => this.Api.MutesUsersIds(x))
1661                 .ConfigureAwait(false);
1662
1663             TabInformations.GetInstance().MuteUserIds = ids.ToHashSet();
1664         }
1665
1666         public string[] GetHashList()
1667         {
1668             string[] hashArray;
1669             lock (this.lockObj)
1670             {
1671                 hashArray = this.hashList.ToArray();
1672                 this.hashList.Clear();
1673             }
1674             return hashArray;
1675         }
1676
1677         public string AccessToken
1678             => ((TwitterApiConnection)this.Api.Connection).AccessToken;
1679
1680         public string AccessTokenSecret
1681             => ((TwitterApiConnection)this.Api.Connection).AccessSecret;
1682
1683         private void CheckAccountState()
1684         {
1685             if (Twitter.AccountState != MyCommon.ACCOUNT_STATE.Valid)
1686                 throw new WebApiException("Auth error. Check your account");
1687         }
1688
1689         private void CheckAccessLevel(TwitterApiAccessLevel accessLevelFlags)
1690         {
1691             if (!this.AccessLevel.HasFlag(accessLevelFlags))
1692                 throw new WebApiException("Auth Err:try to re-authorization.");
1693         }
1694
1695         public int GetTextLengthRemain(string postText)
1696         {
1697             var matchDm = Twitter.DMSendTextRegex.Match(postText);
1698             if (matchDm.Success)
1699                 return this.GetTextLengthRemainDM(matchDm.Groups["body"].Value);
1700
1701             return this.GetTextLengthRemainWeighted(postText);
1702         }
1703
1704         private int GetTextLengthRemainDM(string postText)
1705         {
1706             var textLength = 0;
1707
1708             var pos = 0;
1709             while (pos < postText.Length)
1710             {
1711                 textLength++;
1712
1713                 if (char.IsSurrogatePair(postText, pos))
1714                     pos += 2; // サロゲートペアの場合は2文字分進める
1715                 else
1716                     pos++;
1717             }
1718
1719             var urls = TweetExtractor.ExtractUrls(postText);
1720             foreach (var url in urls)
1721             {
1722                 var shortUrlLength = url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
1723                     ? this.Configuration.ShortUrlLengthHttps
1724                     : this.Configuration.ShortUrlLength;
1725
1726                 textLength += shortUrlLength - url.Length;
1727             }
1728
1729             return this.Configuration.DmTextCharacterLimit - textLength;
1730         }
1731
1732         private int GetTextLengthRemainWeighted(string postText)
1733         {
1734             var config = this.TextConfiguration;
1735             var totalWeight = 0;
1736
1737             int GetWeightFromCodepoint(int codepoint)
1738             {
1739                 foreach (var weightRange in config.Ranges)
1740                 {
1741                     if (codepoint >= weightRange.Start && codepoint <= weightRange.End)
1742                         return weightRange.Weight;
1743                 }
1744
1745                 return config.DefaultWeight;
1746             }
1747
1748             var urls = TweetExtractor.ExtractUrlEntities(postText).ToArray();
1749             var emojis = config.EmojiParsingEnabled
1750                 ? TweetExtractor.ExtractEmojiEntities(postText).ToArray()
1751                 : Array.Empty<TwitterEntityEmoji>();
1752
1753             var codepoints = postText.ToCodepoints().ToArray();
1754             var index = 0;
1755             while (index < codepoints.Length)
1756             {
1757                 var urlEntity = urls.FirstOrDefault(x => x.Indices[0] == index);
1758                 if (urlEntity != null)
1759                 {
1760                     totalWeight += config.TransformedURLLength * config.Scale;
1761                     index = urlEntity.Indices[1];
1762                     continue;
1763                 }
1764
1765                 var emojiEntity = emojis.FirstOrDefault(x => x.Indices[0] == index);
1766                 if (emojiEntity != null)
1767                 {
1768                     totalWeight += GetWeightFromCodepoint(codepoints[index]);
1769                     index = emojiEntity.Indices[1];
1770                     continue;
1771                 }
1772
1773                 var codepoint = codepoints[index];
1774                 totalWeight += GetWeightFromCodepoint(codepoint);
1775
1776                 index++;
1777             }
1778
1779             var remainWeight = config.MaxWeightedTweetLength * config.Scale - totalWeight;
1780
1781             return remainWeight / config.Scale;
1782         }
1783
1784         /// <summary>
1785         /// プロフィール画像のサイズを指定したURLを生成
1786         /// </summary>
1787         /// <remarks>
1788         /// https://developer.twitter.com/en/docs/twitter-api/v1/accounts-and-users/user-profile-images-and-banners を参照
1789         /// </remarks>
1790         public static string CreateProfileImageUrl(string normalUrl, string size)
1791         {
1792             return size switch
1793             {
1794                 "original" => normalUrl.Replace("_normal.", "."),
1795                 "normal" => normalUrl,
1796                 "bigger" or "mini" => normalUrl.Replace("_normal.", $"_{size}."),
1797                 _ => throw new ArgumentException($"Invalid size: ${size}", nameof(size)),
1798             };
1799         }
1800
1801         public static string DecideProfileImageSize(int sizePx)
1802         {
1803             return sizePx switch
1804             {
1805                 <= 24 => "mini",
1806                 <= 48 => "normal",
1807                 <= 73 => "bigger",
1808                 _ => "original",
1809             };
1810         }
1811
1812         public bool IsDisposed { get; private set; } = false;
1813
1814         protected virtual void Dispose(bool disposing)
1815         {
1816             if (this.IsDisposed)
1817                 return;
1818
1819             if (disposing)
1820             {
1821                 this.Api.Dispose();
1822             }
1823
1824             this.IsDisposed = true;
1825         }
1826
1827         public void Dispose()
1828         {
1829             this.Dispose(true);
1830             GC.SuppressFinalize(this);
1831         }
1832     }
1833 }