using AngleSharp.Css.Dom; using Blog3000.Server.Utils; using Blog3000.Shared; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using System; using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; using System.Data.Common; using System.Linq; using System.Runtime.InteropServices.ComTypes; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Tweetinvi; using Tweetinvi.Core.Models; using Tweetinvi.Exceptions; using Tweetinvi.Models; namespace Blog3000.Server { internal class UpdateTweeter : IHostedService, IDisposable { private readonly ILogger logger; private readonly BlogPostRepo repo; private readonly string tweetStatusFn; private readonly SemaphoreSlim sema = new SemaphoreSlim(1); private readonly string uriPrefix; private readonly bool enabled; private readonly bool testMode; private readonly string apiKey; private readonly string apiSecret; private readonly string accessToken; private readonly string accessTokenSecret; private bool needsRescan = true; private Timer timer; private Dictionary tweetStati; public UpdateTweeter(ILogger logger, BlogPostRepo repo, BlogEnv blogEnv) { this.logger = logger; this.repo = repo; this.enabled = blogEnv.BlogConfig.TwitterEnabled; this.uriPrefix = blogEnv.BlogConfig.VisibleUrlPrefix; this.testMode = blogEnv.BlogConfig.TwitterTestMode; this.apiKey = DataProtect.Static.Unprotect(blogEnv.BlogConfig.Protected.TwitterAPIKey); this.apiSecret = DataProtect.Static.Unprotect(blogEnv.BlogConfig.Protected.TwitterAPISecret); this.accessToken = DataProtect.Static.Unprotect(blogEnv.BlogConfig.Protected.TwitterAccessToken); this.accessTokenSecret= DataProtect.Static.Unprotect(blogEnv.BlogConfig.Protected.TwitterAccessSecret); tweetStatusFn = System.IO.Path.Combine( (string)AppDomain.CurrentDomain.GetData("AppDataPath"), "tweetstatus.json"); } public void Dispose() { timer?.Dispose(); repo.BlogPostsChanged -= Repo_BlogPostsChanged; } public Task StartAsync(CancellationToken cancellationToken) { if (enabled) { logger.LogInformation("Service starting..."); timer = new Timer(RunSenderAsync, null, TimeSpan.Zero, TimeSpan.FromSeconds(1 * 60 * (testMode ? 2 : 1))); repo.BlogPostsChanged += Repo_BlogPostsChanged; } else { logger.LogInformation("Twitter not enabled..."); } return Task.CompletedTask; } public Task StopAsync(CancellationToken cancellationToken) { logger.LogInformation("Service stopping."); timer?.Dispose(); timer = null; repo.BlogPostsChanged -= Repo_BlogPostsChanged; return Task.CompletedTask; } private void Repo_BlogPostsChanged(object sender, EventArgs e) { try { DoRescan(); } catch (Exception ex) { logger.LogWarning($"Error while rescanning blogpots from event: {ex}"); } } private async void RunSenderAsync(object state) { if (needsRescan) { try { DoRescan(); } catch (Exception ex) { logger.LogWarning($"Error while rescanning blogpots from sender-task: {ex}"); } needsRescan = false; } if (sema.Wait(2000)) { try { foreach (var e in tweetStati.Values.OrderBy(c => c.ErrCount).ThenBy(c => c.PostId)) { if (e.NeedsUpdate || e.NeedsDeletion) { logger.LogInformation($"Tweeting for {e.PostId} on update, new={e.InitialTweetId == null}"); try { if (e.NeedsDeletion) { logger.LogInformation($"Deleting tweets for removed posts {e.PostId}"); if (e.InitialTweetId != null) { await DeleteAsync((long)e.InitialTweetId); } if (e.UpdateTweetId != null) { await DeleteAsync((long)e.UpdateTweetId); } e.InitialTweetId = null; e.UpdateTweetId = null; e.NeedsDeletion = false; tweetStati.Remove(e.PostId); } else if (e.NeedsUpdate) { var bp = repo.GetHeaders().Where(p => Object.Equals(p.Id, e.PostId)).FirstOrDefault(); if (bp == null) { logger.LogInformation($"{e.PostId} not found in repo!"); e.NeedsUpdate = false; } else { if (e.InitialTweetId == null) { string txt = BuildTweet(bp, true); logger.LogInformation($"Tweeting about new post {e.PostId}: {txt}"); // Initial post var tweet = await PostAsync(txt); e.InitialTweetId = tweet.Id; } else { if (e.UpdateTweetId != null) { logger.LogInformation($"Deleting old tweet for for {e.PostId}"); await DeleteAsync((long)e.UpdateTweetId); } string txt = BuildTweet(bp, false); logger.LogInformation($"Tweeting about updated post {e.PostId}: {txt}"); // Update post var tweet = await PostAsync(txt); e.UpdateTweetId = tweet.Id; } e.LastPostChecksum = bp.Checksum; e.TweetTime = DateTime.UtcNow; e.NeedsUpdate = false; } // e.TweetTime = DateTime.UTCNow; } e.ErrCount = 0; e.LastErr = null; } catch(Exception twx) { e.LastErr = twx.ToString(); e.ErrCount++; } Save(tweetStati); break; } } } finally { sema.Release(); } } } private void DoRescan() { needsRescan = true; try { if (sema.Wait(1000)) { try { logger.LogInformation("Rescanning for changed posts"); bool hasChanges = false; tweetStati ??= Load(); var existingPostId = new Dictionary(); foreach (var p in repo.GetHeaders()) { if (p.NoSocialMediaInfo) { continue; } existingPostId[p.Id] = true; TweetStatus ts; if (!tweetStati.TryGetValue(p.Id, out ts)) { ts = new TweetStatus(); ts.PostId = p.Id; ts.NeedsUpdate = true; tweetStati[p.Id] = ts; hasChanges = true; } else { if (ts.LastPostChecksum == null || (String.Compare(ts.LastPostChecksum, p.Checksum) != 0)) { bool lastUpdateLongEnoughAgo = ts.TweetTime.HasValue && ts.TweetTime.Value.AddHours(18).CompareTo(DateTime.UtcNow) < 0; bool newRevisionSinceLastUpdate = (p.Revisions?.Latest()?.ChangedAt?.CompareTo(ts.TweetTime.GetValueOrDefault(DateTime.MinValue)) ?? 0) > 0; if (/* Not yet tweeted yet */ (ts.InitialTweetId == null) || /* Update required because new revision, and long enough since last tweet */ (ts.InitialTweetId != null && lastUpdateLongEnoughAgo && newRevisionSinceLastUpdate) ) { ts.NeedsUpdate = true; hasChanges = true; } } } } foreach (var ts in tweetStati.Values) { if (ts.PostId != null) { if (!existingPostId.ContainsKey(ts.PostId)) { ts.NeedsDeletion = true; hasChanges = true; } } } if (Program.xCleanTweets) { foreach (var ts in tweetStati.Values) { ts.NeedsDeletion = true; hasChanges = true; } } if (hasChanges) { logger.LogInformation("Posts detected for tweet-updates..."); Save(tweetStati); } } finally { sema.Release(); } } } finally { needsRescan = false; } } #region TweetTextBuilder private string BuildTweet(BlogPostHeader bh, bool isNew) { const int maxLen = 280; string hdr = (testMode ? "[TEST!]": "") + (isNew ? "New post: " : "Post updated: "); string url = $"{uriPrefix}/viewer/{bh.Id}"; string tags = ""; string tit = bh.Title; if (bh.Topics != null) { foreach (var t in bh.Topics) { tags += t + " "; if (tags.Length > 80) break; } } int clen = tit.Length + hdr.Length + url.Length + tags.Length + 2 + 2; // spaces + "" int remain = maxLen - clen; if (remain < 0) { tit = tit.Substring(0, tit.Length - remain - "...".Length)+"..."; } return $"{hdr}'{tit}' {tags}{url}"; } #endregion #region StatusData + Persistence protected class TweetStatus { public string PostId { get; set; } public string LastPostChecksum { get; set; } public DateTime? TweetTime { get; set; } public long? InitialTweetId { get; set; } public long? UpdateTweetId { get; set; } public bool NeedsDeletion { get; set; } = false; public bool NeedsUpdate { get; set; } = false; public string LastErr { get; set; } public int ErrCount { get; set; } = 0; } private Dictionary Load() { Dictionary res = null; try { if (System.IO.File.Exists(tweetStatusFn)) { var text = System.IO.File.ReadAllText(tweetStatusFn); res = JsonSerializer.Deserialize>(text); } } catch (Exception ex) { logger.LogWarning(ex, $"Error reading tweetstatus file ${tweetStatusFn}"); } return res ?? new Dictionary(); } private bool Save(Dictionary tweetStati) { tweetStati ??= new Dictionary(); try { var text = JsonSerializer.Serialize(tweetStati); System.IO.File.WriteAllText(tweetStatusFn, text); return true; } catch (Exception ex) { logger.LogWarning(ex, $"Error writing tweetstatus file ${tweetStatusFn}"); } return false; } #endregion #region Auth-Config public async Task PostAsync(string txt) { // Get a AuthenticatedUser from a specific set of credentials var creds = new TwitterCredentials(apiKey, apiSecret, accessToken, accessTokenSecret); var cln = new TwitterClient(creds); return await cln.Tweets.PublishTweetAsync(txt); } /// /// /// /// /// public async Task DeleteAsync(long tweetId) { // Get a AuthenticatedUser from a specific set of credentials try { var creds = new TwitterCredentials(apiKey, apiSecret, accessToken, accessTokenSecret); var cln = new TwitterClient(creds); await cln.Tweets.DestroyTweetAsync(tweetId); return true; } catch(Exception ex) { logger.LogWarning(ex, $"Error deleteing ${tweetId}"); return false; } } #endregion } }