Overseer and HttpsMessageBuilder

This commit is contained in:
2023-02-24 20:29:20 +06:00
parent 327d06b3d0
commit 7243a2b0d3
16 changed files with 488 additions and 66 deletions

View File

@@ -0,0 +1,182 @@
using System.Net.Mime;
using Microsoft.Extensions.Primitives;
namespace InstaFollowersOverseer;
/// <summary>
/// Class that builds selegram message with html tags.
/// It is a state machine.
/// Exapmple:
/// SetUrl("https://x.com").BeginStyle(TextStyle.Url).Text("X").EndStyle()
/// opens html a tag with href=https://x.com and content X, then closes tag
/// </summary>
// supported tags:
// <b>bold</b>
// <i>italic</i>
// <s>crossed</s>
// <u>underline</u>
// <tg-spoiler>spoiler</tg-spoiler>
// <a href="http://www.example.com/">inline URL</a>
// <a href="tg://user?id=123456789">inline mention of a user</a>
// <code>inlne code</code>
// <pre language="c++">code block</pre>
public class HtmlMessageBuilder
{
record struct BuilderState(TextStyle Style, string? Url = null, long? UserId = null, string? CodeLang = null)
{
public void Reset()
{
Style = TextStyle.PlainText;
Url = null;
UserId = null;
CodeLang = null;
}
}
private BuilderState _state=new(TextStyle.PlainText);
StringBuilder _plainText=new();
StringBuilder _html=new();
protected void ReplaceHtmlReservedChar(char c) =>
_html.Append(c switch
{
'<'=>"&lt",
'>'=>"&gt",
'&'=>"&apm",
'"'=>"&quot",
'\''=>"&apos",
_ => c
});
protected void ReplaceHtmlReservedChars(ReadOnlySpan<char> text)
{
for (int i = 0; i < text.Length; i++)
ReplaceHtmlReservedChar(text[i]);
}
/// opens html tags enabled in state fields
protected void OpenTags()
{
if(_state.Style==TextStyle.PlainText)
return;
// the order of fields is very importang, it must be in reversed in CloseTags()
if (0!=(_state.Style & TextStyle.Bold)) _html.Append("<b>");
if (0!=(_state.Style & TextStyle.Italic)) _html.Append("<i>");
if (0!=(_state.Style & TextStyle.Crossed)) _html.Append("<s>");
if (0!=(_state.Style & TextStyle.Underline)) _html.Append("<u>");
if (0!=(_state.Style & TextStyle.Spoiler)) _html.Append("<tg-spoiler>");
if (0!=(_state.Style & TextStyle.Link))
{
_html.Append("<a href='");
if (_state.UserId is not null)
_html.Append("tg://user?id=").Append(_state.UserId);
else if (!_state.Url.IsNullOrEmpty())
_html.Append(_state.Url);
else throw new Exception("empty url");
_html.Append("'>");
}
if (0!=(_state.Style & TextStyle.CodeLine)) _html.Append("<code>");
if (0!=(_state.Style & TextStyle.CodeBlock))
{
_html.Append("<pre");
if (!_state.CodeLang.IsNullOrEmpty())
_html.Append(" language='").Append(_state.CodeLang).Append('\'');
_html.Append('>');
}
}
/// closes opened html tags
protected void CloseTags()
{
if(_state.Style==TextStyle.PlainText)
return;
// the order of fields is very importang, it must be in reversed in CloseTags()
if (0!=(_state.Style & TextStyle.CodeBlock)) _html.Append("</pre>");
if (0!=(_state.Style & TextStyle.CodeLine)) _html.Append("</code>");
if (0!=(_state.Style & TextStyle.Link)) _html.Append("</a>");
if (0!=(_state.Style & TextStyle.Spoiler)) _html.Append("</tg-spoiler>");
if (0!=(_state.Style & TextStyle.Underline)) _html.Append("</u>");
if (0!=(_state.Style & TextStyle.Crossed)) _html.Append("</s>");
if (0!=(_state.Style & TextStyle.Italic)) _html.Append("</i>");
if (0!=(_state.Style & TextStyle.Bold)) _html.Append("</b>");
_state.Reset();
}
/// appends text to builder
public HtmlMessageBuilder Text(string text)
{
_plainText.Append(text);
ReplaceHtmlReservedChars(text);
return this;
}
public HtmlMessageBuilder Text(char ch)
{
_plainText.Append(ch);
ReplaceHtmlReservedChar(ch);
return this;
}
public HtmlMessageBuilder Text(int o)
{
string text = o.ToString();
_plainText.Append(text);
ReplaceHtmlReservedChars(text);
return this;
}
public HtmlMessageBuilder Text(long o)
{
string text = o.ToString();
_plainText.Append(text);
ReplaceHtmlReservedChars(text);
return this;
}
public HtmlMessageBuilder Text(object o)
{
if (o is null)
throw new NullReferenceException("object is null");
string text = o.ToString()!;
_plainText.Append(text);
ReplaceHtmlReservedChars(text);
return this;
}
/// enables specified styles
public HtmlMessageBuilder BeginStyle(TextStyle style)
{
if (_state.Style != TextStyle.PlainText)
throw new Exception("can't begin new style before ending previous");
_state.Style = style;
OpenTags();
return this;
}
/// removes all styles
public HtmlMessageBuilder EndStyle()
{
CloseTags();
return this;
}
// use before BeginStyle
public HtmlMessageBuilder SetUrl(string url) { _state.Url = url; return this; }
public HtmlMessageBuilder SetUserMention(long id) { _state.UserId=id ; return this; }
public HtmlMessageBuilder SetCodeLanguage(string codeLang="") { _state.CodeLang=codeLang ; return this; }
public string ToPlainText() => _plainText.ToString();
public string ToHtml() => _html.ToString();
#if DEBUG
public override string ToString() => ToHtml();
#else
public override string ToString() => ToPlainText();
#endif
public void Clear()
{
_plainText.Clear();
_html.Clear();
_state.Reset();
}
}

81
Telegram/Overseer.cs Normal file
View File

@@ -0,0 +1,81 @@
using InstaFollowersOverseer.Instagram;
namespace InstaFollowersOverseer;
public static class Overseer
{
private static ContextLogger ObserverLogger = new("observer",ParentLogger);
private static CancellationTokenSource OverseeCancel = new();
public static async void Start()
{
try
{
ObserverLogger.LogInfo("observer is starting");
while (!OverseeCancel.Token.IsCancellationRequested)
{
ObserverLogger.LogDebug("loop begins");
// parallel diff computation per telegram user
Parallel.ForEach(CurrentUsersData.UsersDict,
tgUserData =>
{
try
{
var instaUsers = tgUserData.Value;
long chatId = tgUserData.Key.ToLong();
// parallel diff computation per instagram user
Parallel.For(0, instaUsers.Count, DiffInstaUser);
async void DiffInstaUser(int i)
{
try
{
HtmlMessageBuilder b = new();
ObserverLogger.LogInfo($"comparing followers lists of user {instaUsers[i]}");
// slow operation
FollowersDiff diff =
await InstagramWrapper.GetFollowersDiffAsync(instaUsers[i].instagramUsername);
b.BeginStyle(TextStyle.Bold | TextStyle.Underline)
.Text(instaUsers[i].instagramUsername)
.EndStyle()
.Text('\n');
diff.AppendDiffMessageTo(b, OverseeCancel.Token);
ObserverLogger.LogInfo($"sending notification to {tgUserData.Key}");
await TelegramWrapper.SendInfo(chatId, b);
}
catch(OperationCanceledException){}
catch (Exception ex)
{
ObserverLogger.LogWarn("ObserveLoop", ex);
}
}
}
catch (OperationCanceledException) {}
catch (Exception ex)
{
ObserverLogger.LogWarn("ObserveLoop", ex);
}
});
ObserverLogger.LogDebug("loop ends");
await Task.Delay(TimeSpan.FromMinutes(CurrentConfig.checksIntervalMinutes));
}
}
catch (OperationCanceledException) {}
catch (Exception ex)
{
ObserverLogger.LogError("ObserveLoop", ex);
}
}
public static void Stop()
{
ObserverLogger.LogInfo("observer is stopping");
OverseeCancel.Cancel();
OverseeCancel = new CancellationTokenSource();
}
}

View File

@@ -1,19 +1,18 @@
using System.Net.Http;
using System.Threading;
using Telegram.Bot;
using Telegram.Bot.Polling;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
using InstaFollowersOverseer.Instagram;
namespace InstaFollowersOverseer.Telegram;
namespace InstaFollowersOverseer;
public static class TelegramWrapper
{
private static ContextLogger TelegramLogger = new("telegram", ParentLogger);
private static TelegramBotClient Bot=null!;
public static async void Init()
public static async Task InitAsync()
{
try
{
@@ -35,7 +34,7 @@ public static class TelegramWrapper
};
TelegramLogger.LogInfo("bot starting recieving long polls");
Bot.StartReceiving(BotApiUpdateHandler, BotApiExceptionHandler, receiverOptions, Program.MainCancelToken);
TelegramLogger.LogInfo("telegram wrapper have initialized successfully");
TelegramLogger.LogInfo("telegram wrapper initialized successfully");
}
catch (OperationCanceledException) {}
catch (Exception ex)
@@ -50,20 +49,34 @@ public static class TelegramWrapper
TelegramLogger.LogError(ex);
return Task.CompletedTask;
}
static async Task SendInfoReply(string text, Message replyToMessage)
/// parses text from markdown to html and sends to telegram chat
public static async Task SendMessage(ChatId chatId, HtmlMessageBuilder message, int? replyToMesId=null)
{
TelegramLogger.LogInfo(text);
await Bot.SendTextMessageAsync(replyToMessage.Chat, text,
replyToMessageId: replyToMessage.MessageId,
parseMode:ParseMode.MarkdownV2);
string html = message.ToHtml();
await Bot.SendTextMessageAsync(chatId, html,
replyToMessageId: replyToMesId,
parseMode: ParseMode.Html);
message.Clear();
}
static async Task SendErrorReply(string text, Message replyToMessage)
public static async Task SendInfo(ChatId chatId, HtmlMessageBuilder message, int? replyToMesId=null)
{
TelegramLogger.LogWarn(text);
await Bot.SendTextMessageAsync(replyToMessage.Chat, "error: "+text,
replyToMessageId: replyToMessage.MessageId,
parseMode:ParseMode.MarkdownV2);
TelegramLogger.LogInfo(message);
await SendMessage(chatId, message, replyToMesId);
}
public static async Task SendError(ChatId chatId, HtmlMessageBuilder message, int? replyToMesId=null)
{
TelegramLogger.LogWarn(message);
await SendMessage(chatId, new HtmlMessageBuilder().BeginStyle(TextStyle.Bold | TextStyle.Italic)
.Text("error: ").EndStyle().Text(message), replyToMesId);
}
public static async Task SendError(ChatId chatId, Exception ex, int? replyToMesId=null)
{
TelegramLogger.LogWarn(ex);
await SendMessage(chatId, new HtmlMessageBuilder().BeginStyle(TextStyle.Bold | TextStyle.Italic)
.Text("error: ").EndStyle().Text(ex.Message), replyToMesId);
}
private static async Task BotApiUpdateHandler(ITelegramBotClient bot, Update update, CancellationToken cls)
@@ -107,28 +120,66 @@ public static class TelegramWrapper
private static async Task ExecCommandAsync(string command, string[] args, Message message)
{
switch (command)
try
{
case "start":
await Bot.SendTextMessageAsync(message.Chat, "hi");
break;
case "oversee":
HtmlMessageBuilder rb = new();
long senderId = message.From?.Id ?? message.Chat.Id;
string senderName = message.From?.FirstName ?? message.Chat.FirstName ??
message.Chat.Username ?? "UnknownUser";
switch (command)
{
string usernameOrUrl = args[0];
await SendInfoReply($"searching for instagram user <{usernameOrUrl}>", message);
var user = await InstagramWrapper.GetUserAsync(usernameOrUrl);
if (user is null)
case "start":
await SendInfo(message.Chat, rb.Text("bot started"));
break;
case "oversee":
{
await SendErrorReply($"user **{usernameOrUrl}** doesnt exist", message);
return;
string usernameOrUrl = args[0];
await SendInfo(message.Chat, rb.Text("searching for instagram user"), message.MessageId);
var user = await InstagramWrapper.TryGetUserAsync(usernameOrUrl);
if (user is null)
{
await SendError(message.Chat, rb.Text("user ").Text(usernameOrUrl).Text(" not found"));
return;
}
await SendInfo(message.Chat, rb.Text("user ").Text(usernameOrUrl).Text(" found"));
// user id or chat id
CurrentUsersData.AddOrSet(senderId, new InstagramObservableParams(usernameOrUrl));
CurrentUsersData.SaveToFile();
break;
}
CurrentUsersData.AddOrSet(message.Chat.Id.ToString(), new InstagramObservableParams(usernameOrUrl));
CurrentUsersData.SaveToFile();
break;
case "list":
{
var userData = CurrentUsersData.Get(senderId);
if (userData is null)
{
await SendError(message.Chat, rb.Text("no data for user"), message.MessageId);
return;
}
rb.Text(userData.Count).Text("instagram users:\n");
foreach (var iuParams in userData)
{
rb.BeginStyle(TextStyle.Bold).Text(iuParams.instagramUsername).EndStyle().Text(" - ");
var iu = await InstagramWrapper.TryGetUserAsync(iuParams.instagramUsername);
rb.Text(iu is null ? "user no longer exists" : iu.FullName);
rb.SetUrl("https://www.instagram.com/"+iuParams.instagramUsername)
.BeginStyle(TextStyle.Link)
.Text(iuParams.instagramUsername)
.EndStyle().Text('\n');
}
await SendInfo(message.Chat, rb, message.MessageId);
break;
}
default:
await SendError(message.Chat, rb.Text("ivalid command"), message.MessageId);
break;
}
default:
await SendErrorReply("ivalid command", message);
break;
}
catch(OperationCanceledException){}
catch (Exception ex)
{
await SendError(message.Chat, ex);
}
}
}

9
Telegram/TextStyle.cs Normal file
View File

@@ -0,0 +1,9 @@
namespace InstaFollowersOverseer;
[Flags]
public enum TextStyle
{
PlainText=0,
Bold=1, Italic=2, Crossed=4, Underline=8,
Spoiler=16, Link=32, CodeLine=64, CodeBlock=128
}