commit 481e8e9af8c26a4444ad8c659876854bd947251d Author: Mohammad Mahdi Mohammadi Date: Sat Sep 14 19:38:04 2024 +0330 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d888d21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/ +.vscode/ +obj/ +*.log +*.json \ No newline at end of file diff --git a/Models/Post.cs b/Models/Post.cs new file mode 100644 index 0000000..bedbd43 --- /dev/null +++ b/Models/Post.cs @@ -0,0 +1,50 @@ +using System.Text.Json.Serialization; +using Telegram.Bot.Types; + + +namespace mstdnCats.Models +{ + public class Post + { + [JsonPropertyName("id")] + public required string mstdnPostId { get; set; } + [JsonPropertyName("url")] + public required string Url { get; set; } + [JsonPropertyName("account")] + public required Account Account { get; set; } + [JsonPropertyName("media_attachments")] + public required List MediaAttachments { get; set; } + } + + public class Account + { + [JsonPropertyName("id")] + public required string AccId { get; set; } + [JsonPropertyName("username")] + public required string Username { get; set; } + [JsonPropertyName("acct")] + public required string Acct { get; set; } + [JsonPropertyName("display_name")] + public required string DisplayName { get; set; } + [JsonPropertyName("url")] + public required string Url { get; set; } + [JsonPropertyName("avatar_static")] + public required string AvatarStatic { get; set; } + } + + public class MediaAttachment + { + [JsonPropertyName("id")] + public required string MediaId { get; set; } + [JsonPropertyName("type")] + public required string Type { get; set; } + [JsonPropertyName("url")] + public required string Url { get; set; } + [JsonPropertyName("preview_url")] + public required string PreviewUrl { get; set; } + [JsonPropertyName("remote_url")] + public required string RemoteUrl { get; set; } + public Boolean Approved { get; set; } = false; + + } +} \ No newline at end of file diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..06ec5ed --- /dev/null +++ b/Program.cs @@ -0,0 +1,53 @@ +using Microsoft.Extensions.Logging; +using mstdnCats.Services; +using Telegram.Bot; +using Telegram.Bot.Types; + +public class MastodonBot +{ + + private static Timer _timer; + + private static async Task Main() + { + // Configure logging + using var loggerFactory = LoggerFactory.Create(builder => + { + builder.AddConsole(); + }); + var logger = loggerFactory.CreateLogger(); + + // Setup DB + var db = await DbInitializer.SetupDb(Environment.GetEnvironmentVariable("DB_NAME")); + if (db == null) + { + logger.LogCritical("Unable to setup DB"); + throw new Exception("Unable to setup DB"); + } + logger.LogInformation("DB setup done"); + + // Setup bot + var bot = new TelegramBotClient(Environment.GetEnvironmentVariable("BOT_TOKEN")); + var me = await bot.GetMeAsync(); + await bot.DropPendingUpdatesAsync(); + logger.LogInformation($"Bot is running as {me.FirstName}."); + + bot.OnUpdate += OnUpdate; + + // Handle bot updates + async Task OnUpdate(Update update) + { + switch (update) + { + case { CallbackQuery: { } callbackQuery }: await HandlePostAction.HandleCallbackQuery(callbackQuery,db,bot, logger); break; + default: logger.LogInformation($"Received unhandled update {update.Type}"); break; + }; + } + + // Set a timer to fetch and process posts every 15 minutes + _timer = new Timer(async _ => await RunCheck.runAsync(db, bot, Environment.GetEnvironmentVariable("TAG"),logger,Environment.GetEnvironmentVariable("CUSTOM_INSTANCE")), null, TimeSpan.Zero, TimeSpan.FromMinutes(15)); + Console.ReadLine(); + + } + +} diff --git a/Services/DbInitializer.cs b/Services/DbInitializer.cs new file mode 100644 index 0000000..12c21f8 --- /dev/null +++ b/Services/DbInitializer.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using JsonFlatFileDataStore; +using mstdnCats.Models; + +namespace mstdnCats.Services +{ + public class DbInitializer + { + public static Task> SetupDb(string _dbname) + { + // Setup DB + IDocumentCollection? collection = null; + + try + { + // Initialize DB + var store = new DataStore($"{_dbname}.json", minifyJson: false); + collection = store.GetCollection(); + } + catch + { + return Task.FromResult>(null); + } + + // Return collection + return Task.FromResult(collection); + } + } +} \ No newline at end of file diff --git a/Services/HandlePostAction.cs b/Services/HandlePostAction.cs new file mode 100644 index 0000000..7dce80e --- /dev/null +++ b/Services/HandlePostAction.cs @@ -0,0 +1,97 @@ +using JsonFlatFileDataStore; +using Microsoft.Extensions.Logging; +using mstdnCats.Models; +using Telegram.Bot; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Enums; +using Telegram.Bot.Types.ReplyMarkups; + +namespace mstdnCats.Services +{ + public class HandlePostAction + { + public static async Task HandleCallbackQuery(CallbackQuery callbackQuery, IDocumentCollection _db, TelegramBotClient _bot, ILogger? logger) + { + // Extract media ID from callback query data + string[] parts = callbackQuery.Data.Split('-'); + if (parts.Length != 2) + { + logger?.LogError("Invalid callback query data format."); + return; + } + + string action = parts[0]; + string mediaId = parts[1]; + + var post = _db.AsQueryable().FirstOrDefault(p => p.MediaAttachments.Any(m => m.MediaId == mediaId)); + + if (post == null) + { + logger?.LogInformation("No matching post found."); + return; + } + + // Approve the media attachment + if (action == "approve") + { + var mediaAttachment = post.MediaAttachments.FirstOrDefault(m => m.MediaId == mediaId); + if (mediaAttachment != null) + { + mediaAttachment.Approved = true; + + bool updated = await _db.UpdateOneAsync(p => p.mstdnPostId == post.mstdnPostId, post); + + if (updated) + { + // Send the media attachment to the channel + await _bot.SendPhotoAsync(Environment.GetEnvironmentVariable("CHANNEL_NUMID"), post.MediaAttachments.First().Url, caption: $"Message from " + $"" + post.Account.DisplayName + " ", parseMode: ParseMode.Html + , replyMarkup: new InlineKeyboardMarkup(InlineKeyboardButton.WithUrl("View on Mastodon", post.Url))); + + await _bot.AnswerCallbackQueryAsync(callbackQuery.Id, "Media attachment approved and sent to channel."); + await _bot.DeleteMessageAsync(callbackQuery.Message.Chat.Id, callbackQuery.Message.MessageId); + + logger?.LogTrace($"Media attachment {mediaId} approved."); + + } + else + { + logger?.LogError($"Failed to update the media attachment {mediaId}. Record might not exist or was not found."); + } + + + } + else + { + logger?.LogError($"No media attachment found with MediaId {mediaId}."); + } + } + + // Reject the media attachment + else if (action == "reject") + { + // Check if the post has only one attachment, if so, do not delete it, else delete the associated attachment + if (post.MediaAttachments.Count == 1 && post.MediaAttachments.First().MediaId == mediaId) + { + await _bot.AnswerCallbackQueryAsync(callbackQuery.Id, "Post has only one attachment. No deletion performed."); + await _bot.DeleteMessageAsync(callbackQuery.Message.Chat.Id, callbackQuery.Message.MessageId); + + logger?.LogTrace($"Post {post.mstdnPostId} has only one attachment. No deletion performed."); + } + else + { + post.MediaAttachments.RemoveAll(m => m.MediaId == mediaId); + await _db.UpdateOneAsync(p => p.mstdnPostId == post.mstdnPostId, post); + await _bot.AnswerCallbackQueryAsync(callbackQuery.Id, "Media attachment rejected."); + await _bot.DeleteMessageAsync(callbackQuery.Message.Chat.Id, callbackQuery.Message.MessageId); + + logger?.LogTrace($"Media attachment {mediaId} removed from post {post.mstdnPostId}."); + } + } + else + { + logger?.LogError("Invalid action specified."); + } + + } + } +} \ No newline at end of file diff --git a/Services/PostResolver.cs b/Services/PostResolver.cs new file mode 100644 index 0000000..ded92a4 --- /dev/null +++ b/Services/PostResolver.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using mstdnCats.Models; + +namespace mstdnCats.Services +{ + public sealed class PostResolver + { + + public static async Task?> GetPostsAsync(string tag, ILogger? logger, string instance = "https://haminoa.net") + { + // Get posts + HttpClient _httpClient = new HttpClient(); + // Get posts from mastodon api (40 latest posts) + var response = await _httpClient.GetAsync($"{instance}/api/v1/timelines/tag/{tag}?limit=40"); + + // Print out ratelimit info + logger?.LogInformation("Remaining requests: " + response.Headers.GetValues("X-RateLimit-Remaining").First() + "time to reset: " + response.Headers.GetValues("X-RateLimit-Reset").First()); + + // Check if response is ok + if ( + response.StatusCode == System.Net.HttpStatusCode.OK || + response.Content.Headers.ContentType.MediaType.Contains("application/json") || + response.Headers.TryGetValues("X-RateLimit-Remaining", out var remaining) && int.Parse(remaining.First()) != 0 + ) + { + // Deserialize response based on 'Post' model + return JsonSerializer.Deserialize>(await response.Content.ReadAsStringAsync()); + } + + else return null; + } + } +} \ No newline at end of file diff --git a/Services/ProcessPosts.cs b/Services/ProcessPosts.cs new file mode 100644 index 0000000..f6dbb34 --- /dev/null +++ b/Services/ProcessPosts.cs @@ -0,0 +1,44 @@ +using JsonFlatFileDataStore; +using Microsoft.Extensions.Logging; +using mstdnCats.Models; +using Telegram.Bot; +using Telegram.Bot.Types.Enums; +using Telegram.Bot.Types.ReplyMarkups; + +namespace mstdnCats.Services +{ + public class ProcessPosts + { + public static async Task> checkAndInsertPostsAsync(IDocumentCollection _db, TelegramBotClient _bot, List fetchedPosts, ILogger? logger) + { + // Get existing posts + var existingPosts = _db.AsQueryable().Select(x => x.mstdnPostId).ToArray(); + logger?.LogInformation($"Recieved posts to proccess: {fetchedPosts.Count} - total existing posts: {existingPosts.Length}"); + int newPosts = 0; + // Process posts + foreach (Post post in fetchedPosts) + { + // Check if post already exists + if (!existingPosts.Contains(post.mstdnPostId) && post.MediaAttachments.Count > 0) + { + // Send approve or reject message to admin + foreach (var media in post.MediaAttachments) + { + await _bot.SendPhotoAsync(Environment.GetEnvironmentVariable("ADMIN_NUMID"), media.PreviewUrl, caption: $" Mastodon ", parseMode: ParseMode.Html + , replyMarkup: new InlineKeyboardMarkup().AddButton("Approve", $"approve-{media.MediaId}").AddButton("Reject", $"reject-{media.MediaId}")); + + } + // Insert post + await _db.InsertOneAsync(post); + newPosts++; + } + } + + logger?.LogInformation($"Proccesing done, stats: received {fetchedPosts.Count} posts, inserted {newPosts} new posts."); + + // Return list of media attachments + var alldbpostsattachmentlist = _db.AsQueryable().SelectMany(x => x.MediaAttachments).ToList(); + return alldbpostsattachmentlist; + } + } +} \ No newline at end of file diff --git a/Services/RunCheck.cs b/Services/RunCheck.cs new file mode 100644 index 0000000..087886c --- /dev/null +++ b/Services/RunCheck.cs @@ -0,0 +1,34 @@ + +using JsonFlatFileDataStore; +using Microsoft.Extensions.Logging; +using mstdnCats.Models; +using Telegram.Bot; + +namespace mstdnCats.Services +{ + public class RunCheck + { + public static async Task runAsync(IDocumentCollection _db, TelegramBotClient _bot, string _tag, ILogger? logger, string _instance = "https://haminoa.net") + { + // Run check + try + { + // First get posts + var posts = await PostResolver.GetPostsAsync(_tag, logger, _instance); + + if (posts == null) + { + logger?.LogCritical("Unable to get posts"); + } + + // Then process them + await ProcessPosts.checkAndInsertPostsAsync(_db, _bot, posts, logger); + } + catch (Exception ex) + { + logger?.LogCritical("Error while running check: " + ex.Message); + } + return true; + } + } +} \ No newline at end of file diff --git a/mstdnCats.csproj b/mstdnCats.csproj new file mode 100644 index 0000000..817f94d --- /dev/null +++ b/mstdnCats.csproj @@ -0,0 +1,16 @@ + + + + Exe + net8.0 + enable + enable + true + + + + + + + +