blog3000/Blog3000/Server/MiddleWares/UpdateTweeter.cs

446 lines
16 KiB
C#

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<string, TweetStatus> tweetStati;
public UpdateTweeter(ILogger<UpdateTweeter> 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<string, bool>();
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<string, TweetStatus> Load()
{
Dictionary<string, TweetStatus> res = null;
try
{
if (System.IO.File.Exists(tweetStatusFn))
{
var text = System.IO.File.ReadAllText(tweetStatusFn);
res = JsonSerializer.Deserialize<Dictionary<string, TweetStatus>>(text);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, $"Error reading tweetstatus file ${tweetStatusFn}");
}
return res ?? new Dictionary<string, TweetStatus>();
}
private bool Save(Dictionary<string, TweetStatus> tweetStati)
{
tweetStati ??= new Dictionary<string, TweetStatus>();
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<ITweet> 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);
}
/// <summary>
///
/// </summary>
/// <param name="tweetId"></param>
/// <returns></returns>
public async Task<bool> 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
}
}