446 lines
16 KiB
C#
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
|
|
}
|
|
}
|