Initial commit
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
bin/
|
||||||
|
.vscode/
|
||||||
|
obj/
|
||||||
|
*.log
|
||||||
|
*.json
|
50
Models/Post.cs
Normal file
50
Models/Post.cs
Normal file
@@ -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<MediaAttachment> 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;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
53
Program.cs
Normal file
53
Program.cs
Normal file
@@ -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<MastodonBot>();
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
32
Services/DbInitializer.cs
Normal file
32
Services/DbInitializer.cs
Normal file
@@ -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<IDocumentCollection<Post>> SetupDb(string _dbname)
|
||||||
|
{
|
||||||
|
// Setup DB
|
||||||
|
IDocumentCollection<Post>? collection = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Initialize DB
|
||||||
|
var store = new DataStore($"{_dbname}.json", minifyJson: false);
|
||||||
|
collection = store.GetCollection<Post>();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return Task.FromResult<IDocumentCollection<Post>>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return collection
|
||||||
|
return Task.FromResult(collection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
97
Services/HandlePostAction.cs
Normal file
97
Services/HandlePostAction.cs
Normal file
@@ -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<Post> _db, TelegramBotClient _bot, ILogger<MastodonBot>? 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 " + $"<a href=\"" + post.Account.Url + "\">" + post.Account.DisplayName + " </a>", 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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
34
Services/PostResolver.cs
Normal file
34
Services/PostResolver.cs
Normal file
@@ -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<List<Post>?> GetPostsAsync(string tag, ILogger<MastodonBot>? 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<List<Post>>(await response.Content.ReadAsStringAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
else return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
44
Services/ProcessPosts.cs
Normal file
44
Services/ProcessPosts.cs
Normal file
@@ -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<List<MediaAttachment>> checkAndInsertPostsAsync(IDocumentCollection<Post> _db, TelegramBotClient _bot, List<Post> fetchedPosts, ILogger<MastodonBot>? 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: $"<a href=\"" + post.Url + "\"> Mastodon </a>", 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
34
Services/RunCheck.cs
Normal file
34
Services/RunCheck.cs
Normal file
@@ -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<bool> runAsync(IDocumentCollection<Post> _db, TelegramBotClient _bot, string _tag, ILogger<MastodonBot>? 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
16
mstdnCats.csproj
Normal file
16
mstdnCats.csproj
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="JsonFlatFileDataStore" Version="2.4.2" />
|
||||||
|
<PackageReference Include="Telegram.Bot" Version="21.11.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
Reference in New Issue
Block a user