This commit is contained in:
jetsparrow 2022-01-26 01:05:05 +03:00
parent 0c74bc4cea
commit 05c491ff0d
36 changed files with 1110 additions and 1186 deletions

View File

@ -1,66 +1,60 @@
using System; using Telegram.Bot.Types;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Telegram.Bot.Args;
namespace JetHerald namespace JetHerald;
public interface IChatCommand
{ {
public interface IChatCommand Task<string> Execute(CommandString cmd, Update update);
}
public class ChatCommandRouter
{
string Username { get; }
ILogger Log { get; }
Dictionary<string, IChatCommand> Commands { get; }
public ChatCommandRouter(string username, ILogger log)
{ {
Task<string> Execute(CommandString cmd, MessageEventArgs messageEventArgs); Log = log;
Username = username;
Commands = new Dictionary<string, IChatCommand>();
} }
public class ChatCommandRouter public async Task<string> Execute(object sender, Update update)
{ {
string Username { get; } var text = update.Message.Text;
ILogger Log { get; } if (CommandString.TryParse(text, out var cmd))
Dictionary<string, IChatCommand> Commands { get; }
public ChatCommandRouter(string username, ILogger log)
{ {
Log = log; if (cmd.Username != null && cmd.Username != Username)
Username = username;
Commands = new Dictionary<string, IChatCommand>();
}
public async Task<string> Execute(object sender, MessageEventArgs args)
{
var text = args.Message.Text;
if (CommandString.TryParse(text, out var cmd))
{ {
if (cmd.Username != null && cmd.Username != Username) Log.LogDebug("Message not directed at us");
{ return null;
Log.LogDebug("Message not directed at us");
return null;
}
if (Commands.ContainsKey(cmd.Command))
{
try
{
Log.LogDebug($"Handling message via {Commands[cmd.Command].GetType().Name}");
return await Commands[cmd.Command].Execute(cmd, args);
}
catch (Exception e)
{
Log.LogError(e, $"Error while executing command {cmd.Command}!");
}
}
else
Log.LogDebug($"Command {cmd.Command} not found");
} }
return null; if (Commands.ContainsKey(cmd.Command))
}
public void Add(IChatCommand c, params string[] cmds)
{
foreach (var cmd in cmds)
{ {
if (Commands.ContainsKey(cmd)) try
throw new ArgumentException($"collision for {cmd}, commands {Commands[cmd].GetType()} and {c.GetType()}"); {
Commands[cmd] = c; Log.LogDebug($"Handling message via {Commands[cmd.Command].GetType().Name}");
return await Commands[cmd.Command].Execute(cmd, update);
}
catch (Exception e)
{
Log.LogError(e, $"Error while executing command {cmd.Command}!");
}
} }
else
Log.LogDebug($"Command {cmd.Command} not found");
}
return null;
}
public void Add(IChatCommand c, params string[] cmds)
{
foreach (var cmd in cmds)
{
if (Commands.ContainsKey(cmd))
throw new ArgumentException($"collision for {cmd}, commands {Commands[cmd].GetType()} and {c.GetType()}");
Commands[cmd] = c;
} }
} }
} }

View File

@ -1,43 +1,39 @@
using System; using System.Text.RegularExpressions;
using System.Linq;
using System.Text.RegularExpressions;
namespace JetHerald namespace JetHerald;
public class CommandString
{ {
public class CommandString public CommandString(string command, string username, params string[] parameters)
{ {
public CommandString(string command, string username, params string[] parameters) Command = command;
{ Username = username;
Command = command; Parameters = parameters;
Username = username; }
Parameters = parameters;
}
public string Command { get; } public string Command { get; }
public string Username { get; } public string Username { get; }
public string[] Parameters { get; } public string[] Parameters { get; }
static readonly char[] WS_CHARS = new[] { ' ', '\r', '\n', '\n' }; static readonly char[] WS_CHARS = new[] { ' ', '\r', '\n', '\n' };
public static bool TryParse(string s, out CommandString result) public static bool TryParse(string s, out CommandString result)
{ {
result = null; result = null;
if (string.IsNullOrWhiteSpace(s) || s[0] != '/') if (string.IsNullOrWhiteSpace(s) || s[0] != '/')
return false; return false;
string[] words = s.Split(WS_CHARS, StringSplitOptions.RemoveEmptyEntries); string[] words = s.Split(WS_CHARS, StringSplitOptions.RemoveEmptyEntries);
var cmdRegex = new Regex(@"/(?<cmd>\w+)(@(?<name>\w+))?"); var cmdRegex = new Regex(@"/(?<cmd>\w+)(@(?<name>\w+))?");
var match = cmdRegex.Match(words.First()); var match = cmdRegex.Match(words.First());
if (!match.Success) if (!match.Success)
return false; return false;
string cmd = match.Groups["cmd"].Captures[0].Value; string cmd = match.Groups["cmd"].Captures[0].Value;
string username = match.Groups["name"].Captures.Count > 0 ? match.Groups["name"].Captures[0].Value : null; string username = match.Groups["name"].Captures.Count > 0 ? match.Groups["name"].Captures[0].Value : null;
string[] parameters = words.Skip(1).ToArray(); string[] parameters = words.Skip(1).ToArray();
result = new CommandString(cmd, username, parameters); result = new CommandString(cmd, username, parameters);
return true; return true;
}
} }
} }

View File

@ -1,20 +1,17 @@
using System.Threading.Tasks;
using Telegram.Bot; using Telegram.Bot;
using Telegram.Bot.Types; using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums; using Telegram.Bot.Types.Enums;
namespace JetHerald.Commands namespace JetHerald.Commands;
public static class CommandHelper
{ {
public static class CommandHelper public static async Task<bool> CheckAdministrator(TelegramBotClient bot, Message msg)
{ {
public static async Task<bool> CheckAdministrator(TelegramBotClient bot, Message msg) if (msg.Chat.Type != ChatType.Private)
{ {
if (msg.Chat.Type != ChatType.Private) var chatMember = await bot.GetChatMemberAsync(msg.Chat.Id, msg.From.Id);
{ return chatMember.Status is ChatMemberStatus.Administrator or ChatMemberStatus.Creator;
var chatMember = await bot.GetChatMemberAsync(msg.Chat.Id, msg.From.Id);
return chatMember.Status is ChatMemberStatus.Administrator or ChatMemberStatus.Creator;
}
return true;
} }
return true;
} }
} }

View File

@ -1,46 +1,44 @@
using System.Linq; using MySql.Data.MySqlClient;
using System.Threading.Tasks; using Telegram.Bot.Types;
using MySql.Data.MySqlClient;
using Telegram.Bot.Args;
using Telegram.Bot.Types.Enums; using Telegram.Bot.Types.Enums;
using JetHerald.Services;
namespace JetHerald.Commands namespace JetHerald.Commands;
public class CreateTopicCommand : IChatCommand
{ {
public class CreateTopicCommand : IChatCommand Db Db { get; }
public CreateTopicCommand(Db db)
{ {
readonly Db db; Db = db;
}
public CreateTopicCommand(Db db) public async Task<string> Execute(CommandString cmd, Update update)
{
if (cmd.Parameters.Length < 1)
return null;
var msg = update.Message;
if (msg.Chat.Type != ChatType.Private)
return null;
string name = cmd.Parameters[0];
string descr = name;
if (cmd.Parameters.Length > 1)
descr = string.Join(' ', cmd.Parameters.Skip(1));
try
{ {
this.db = db; var topic = await Db.CreateTopic(NamespacedId.Telegram(msg.From.Id), name, descr);
return $"created {topic.Name}\n" +
$"readToken\n{topic.ReadToken}\n" +
$"writeToken\n{topic.WriteToken}\n" +
$"adminToken\n{topic.AdminToken}\n";
} }
catch (MySqlException myDuplicate) when (myDuplicate.Number == 1062)
public async Task<string> Execute(CommandString cmd, MessageEventArgs messageEventArgs)
{ {
if (cmd.Parameters.Length < 1) return $"topic {name} already exists";
return null;
var msg = messageEventArgs.Message;
if (msg.Chat.Type != ChatType.Private)
return null;
string name = cmd.Parameters[0];
string descr = name;
if (cmd.Parameters.Length > 1)
descr = string.Join(' ', cmd.Parameters.Skip(1));
try
{
var topic = await db.CreateTopic(NamespacedId.Telegram(msg.From.Id), name, descr);
return $"created {topic.Name}\n" +
$"readToken\n{topic.ReadToken}\n" +
$"writeToken\n{topic.WriteToken}\n" +
$"adminToken\n{topic.AdminToken}\n";
}
catch (MySqlException myDuplicate) when (myDuplicate.Number == 1062)
{
return $"topic {name} already exists";
}
} }
} }
} }

View File

@ -1,35 +1,34 @@
using System.Threading.Tasks; using Telegram.Bot.Types;
using Telegram.Bot.Args;
using Telegram.Bot.Types.Enums; using Telegram.Bot.Types.Enums;
using JetHerald.Services;
namespace JetHerald.Commands namespace JetHerald.Commands;
public class DeleteTopicCommand : IChatCommand
{ {
public class DeleteTopicCommand : IChatCommand Db Db { get; }
public DeleteTopicCommand(Db db)
{ {
readonly Db db; Db = db;
}
public DeleteTopicCommand(Db db) public async Task<string> Execute(CommandString cmd, Update update)
{ {
this.db = db; if (cmd.Parameters.Length < 2)
} return null;
var msg = update.Message;
public async Task<string> Execute(CommandString cmd, MessageEventArgs messageEventArgs) if (msg.Chat.Type != ChatType.Private)
{ return null;
if (cmd.Parameters.Length < 2)
return null;
var msg = messageEventArgs.Message;
if (msg.Chat.Type != ChatType.Private) string name = cmd.Parameters[0];
return null; string adminToken = cmd.Parameters[1];
string name = cmd.Parameters[0]; var changed = await Db.DeleteTopic(name, adminToken);
string adminToken = cmd.Parameters[1]; if (changed > 0)
return ($"deleted {name} and all its subscriptions");
var changed = await db.DeleteTopic(name, adminToken); else
if (changed > 0) return ($"invalid topic name or admin token");
return ($"deleted {name} and all its subscriptions");
else
return ($"invalid topic name or admin token");
}
} }
} }

View File

@ -1,120 +0,0 @@
using System.Linq;
using System.Threading.Tasks;
using DSharpPlus.CommandsNext;
using DSharpPlus.CommandsNext.Attributes;
using MySql.Data.MySqlClient;
namespace JetHerald.Commands
{
[ModuleLifespan(ModuleLifespan.Transient)]
public class DiscordCommands : BaseCommandModule
{
public Db Db { get; set; }
[Command("createtopic")]
[Description("Creates a topic.")]
[RequireDirectMessage]
public async Task CreateTopic(
CommandContext ctx,
[Description("The unique name of the new topic.")]
string name,
[RemainingText, Description("The name displayed in service messages. Defaults to `name`")]
string description = null)
{
if (description == null)
description = name;
_ = ctx.TriggerTypingAsync();
try
{
var topic = await Db.CreateTopic(NamespacedId.Discord(ctx.User.Id), name, description);
await ctx.RespondAsync($"created {topic.Name}\n" +
$"readToken\n{topic.ReadToken}\n" +
$"writeToken\n{topic.WriteToken}\n" +
$"adminToken\n{topic.AdminToken}\n");
}
catch (MySqlException myDuplicate) when (myDuplicate.Number == 1062)
{
await ctx.RespondAsync($"topic {name} already exists");
}
}
[Command("deletetopic")]
[Description("Deletes a topic.")]
[RequireDirectMessage]
public async Task DeleteTopic(
CommandContext ctx,
[Description("The name of the topic to be deleted.")]
string name,
[Description("The admin token of the topic to be deleted.")]
string adminToken)
{
_ = ctx.TriggerTypingAsync();
var changed = await Db.DeleteTopic(name, adminToken);
if (changed > 0)
await ctx.RespondAsync($"deleted {name} and all its subscriptions");
else
await ctx.RespondAsync($"invalid topic name or admin token");
}
[Command("list")]
[Description("List all subscriptions in this channel.")]
[RequireUserPermissions(DSharpPlus.Permissions.ManageGuild)]
public async Task ListSubscriptions(CommandContext ctx)
{
_ = ctx.TriggerTypingAsync();
var topics = await Db.GetTopicsForChat(NamespacedId.Discord(ctx.Channel.Id));
await ctx.RespondAsync(topics.Any()
? "Topics:\n" + string.Join("\n", topics)
: "No subscriptions active.");
}
[Command("subscribe")]
[Description("Subscribes to a topic.")]
[RequireUserPermissions(DSharpPlus.Permissions.ManageGuild)]
public async Task Subscribe(
CommandContext ctx,
[Description("The read token of the token to subscribe to.")]
string token
)
{
_ = ctx.TriggerTypingAsync();
var chat = NamespacedId.Discord(ctx.Channel.Id);
var topic = await Db.GetTopicForSub(token, chat);
if (topic == null)
await ctx.RespondAsync("topic not found");
else if (topic.Chat.HasValue && topic.Chat.Value == chat)
await ctx.RespondAsync($"already subscribed to {topic.Name}");
else if (topic.ReadToken != token)
await ctx.RespondAsync("token mismatch");
else
{
await Db.CreateSubscription(topic.TopicId, chat);
await ctx.RespondAsync($"subscribed to {topic.Name}");
}
}
[Command("unsubscribe")]
[Description("Unsubscribes from a topic.")]
[RequireUserPermissions(DSharpPlus.Permissions.ManageGuild)]
public async Task Unsubscribe(
CommandContext ctx,
[Description("The name of the topic to unsubscribe from.")]
string name
)
{
_ = ctx.TriggerTypingAsync();
int affected = await Db.RemoveSubscription(name, NamespacedId.Discord(ctx.Channel.Id));
if (affected >= 1)
await ctx.RespondAsync($"unsubscribed from {name}");
else
await ctx.RespondAsync($"could not find subscription for {name}");
}
}
}

View File

@ -1,33 +1,30 @@
using System.Linq; using JetHerald.Services;
using System.Threading.Tasks;
using Telegram.Bot; using Telegram.Bot;
using Telegram.Bot.Args; using Telegram.Bot.Types;
namespace JetHerald.Commands namespace JetHerald.Commands;
public class ListCommand : IChatCommand
{ {
public class ListCommand : IChatCommand Db Db { get; }
TelegramBotClient Bot { get; }
public ListCommand(Db db, TelegramBotClient bot)
{ {
readonly Db db; Db = db;
readonly TelegramBotClient bot; Bot = bot;
public ListCommand(Db db, TelegramBotClient bot)
{
this.db = db;
this.bot = bot;
}
public async Task<string> Execute(CommandString cmd, MessageEventArgs messageEventArgs)
{
if (!await CommandHelper.CheckAdministrator(bot, messageEventArgs.Message))
return null;
var msg = messageEventArgs.Message;
var chatid = msg.Chat.Id;
var topics = await db.GetTopicsForChat(NamespacedId.Telegram(chatid));
return topics.Any()
? "Topics:\n" + string.Join("\n", topics)
: "No subscriptions active.";
}
} }
}
public async Task<string> Execute(CommandString cmd, Update update)
{
if (!await CommandHelper.CheckAdministrator(Bot, update.Message))
return null;
var msg = update.Message;
var chatid = msg.Chat.Id;
var topics = await Db.GetTopicsForSub(NamespacedId.Telegram(chatid));
return topics.Any()
? "Topics:\n" + string.Join("\n", topics)
: "No subscriptions active.";
}
}

View File

@ -1,44 +1,42 @@
using System.Threading.Tasks; using Telegram.Bot;
using Telegram.Bot; using Telegram.Bot.Types;
using Telegram.Bot.Args; using JetHerald.Services;
namespace JetHerald.Commands namespace JetHerald.Commands;
public class SubscribeCommand : IChatCommand
{ {
public class SubscribeCommand : IChatCommand Db Db { get; }
TelegramBotClient Bot { get; }
public SubscribeCommand(Db db, TelegramBotClient bot)
{ {
readonly Db db; Db = db;
readonly TelegramBotClient bot; Bot = bot;
}
public SubscribeCommand(Db db, TelegramBotClient bot) public async Task<string> Execute(CommandString cmd, Update args)
{
if (cmd.Parameters.Length < 1)
return null;
if (!await CommandHelper.CheckAdministrator(Bot, args.Message))
return null;
var chat = NamespacedId.Telegram(args.Message.Chat.Id);
var token = cmd.Parameters[0];
var topic = await Db.GetTopicForSub(token, chat);
if (topic == null)
return "topic not found";
else if (topic.Sub == chat)
return $"already subscribed to {topic.Name}";
else if (topic.ReadToken != token)
return "token mismatch";
else
{ {
this.db = db; await Db.CreateSubscription(topic.TopicId, chat);
this.bot = bot; return $"subscribed to {topic.Name}";
}
public async Task<string> Execute(CommandString cmd, MessageEventArgs args)
{
if (cmd.Parameters.Length < 1)
return null;
if (!await CommandHelper.CheckAdministrator(bot, args.Message))
return null;
var chat = NamespacedId.Telegram(args.Message.Chat.Id);
var token = cmd.Parameters[0];
var topic = await db.GetTopicForSub(token, chat);
if (topic == null)
return "topic not found";
else if (topic.Chat == chat)
return $"already subscribed to {topic.Name}";
else if (topic.ReadToken != token)
return "token mismatch";
else
{
await db.CreateSubscription(topic.TopicId, chat);
return $"subscribed to {topic.Name}";
}
} }
} }
} }

View File

@ -1,37 +1,35 @@
using System.Threading.Tasks; using Telegram.Bot;
using Telegram.Bot; using Telegram.Bot.Types;
using Telegram.Bot.Args; using JetHerald.Services;
namespace JetHerald.Commands namespace JetHerald.Commands;
public class UnsubscribeCommand : IChatCommand
{ {
public class UnsubscribeCommand : IChatCommand Db Db { get; }
TelegramBotClient Bot { get; }
public UnsubscribeCommand(Db db, TelegramBotClient bot)
{ {
readonly Db db; Db = db;
readonly TelegramBotClient bot; Bot = bot;
public UnsubscribeCommand(Db db, TelegramBotClient bot)
{
this.db = db;
this.bot = bot;
}
public async Task<string> Execute(CommandString cmd, MessageEventArgs messageEventArgs)
{
if (cmd.Parameters.Length < 1)
return null;
if (!await CommandHelper.CheckAdministrator(bot, messageEventArgs.Message))
return null;
var msg = messageEventArgs.Message;
var chat = NamespacedId.Telegram(msg.Chat.Id);
var topicName = cmd.Parameters[0];
int affected = await db.RemoveSubscription(topicName, chat);
if (affected >= 1)
return $"unsubscribed from {topicName}";
else
return $"could not find subscription for {topicName}";
}
} }
}
public async Task<string> Execute(CommandString cmd, Update update)
{
if (cmd.Parameters.Length < 1)
return null;
if (!await CommandHelper.CheckAdministrator(Bot, update.Message))
return null;
var msg = update.Message;
var chat = NamespacedId.Telegram(msg.Chat.Id);
var topicName = cmd.Parameters[0];
int affected = await Db.RemoveSubscription(topicName, chat);
if (affected >= 1)
return $"unsubscribed from {topicName}";
else
return $"could not find subscription for {topicName}";
}
}

View File

@ -1,29 +0,0 @@
namespace JetHerald.Options
{
public class ConnectionStrings
{
public string DefaultConnection { get; set; }
}
public class Telegram
{
public string ApiKey { get; set; }
public bool UseProxy { get; set; }
public string ProxyUrl { get; set; }
public string ProxyPassword { get; set; }
public string ProxyLogin { get; set; }
}
public class Discord
{
public string Token { get; set; }
}
public class Timeout
{
public int DebtLimitSeconds { get; set; }
public int HeartbeatCost { get; set; }
public int ReportCost { get; set; }
}
}

View File

@ -0,0 +1,27 @@
namespace JetHerald.Contracts;
public class Topic
{
public uint TopicId { get; set; }
public NamespacedId Creator { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public string ReadToken { get; set; }
public string WriteToken { get; set; }
public string AdminToken { get; set; }
public NamespacedId? Sub { get; set; }
public override string ToString()
=> Name == Description ? Name : $"{Name}: {Description}";
}
public class HeartEvent
{
public ulong HeartEventId { get; set; }
public uint TopicId { get; set; }
public string Heart { get; set; }
public DateTime CreateTs { get; set; }
public string Description { get; set; }
}

View File

@ -1,97 +1,94 @@
using System; using System.Text.Json;
using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options; using JetHerald.Services;
namespace JetHerald.Controllers namespace JetHerald.Controllers;
[ApiController]
public class HeartbeatController : ControllerBase
{ {
[ApiController] Db Db { get; }
public class HeartbeatController : ControllerBase JetHeraldBot Herald { get; }
LeakyBucket Timeouts { get; }
Options.TimeoutConfig Config { get; }
public HeartbeatController(Db db, JetHeraldBot herald, LeakyBucket timeouts, IOptions<Options.TimeoutConfig> cfgOptions)
{ {
Db Db { get; } Herald = herald;
JetHeraldBot Herald { get; } Timeouts = timeouts;
LeakyBucket Timeouts { get; } Db = db;
Options.Timeout Config { get; } Config = cfgOptions.Value;
}
public HeartbeatController(Db db, JetHeraldBot herald, LeakyBucket timeouts, IOptions<Options.Timeout> cfgOptions) // tfw when you need to manually parse body and query
[Route("api/heartbeat")]
[HttpPost]
public async Task<IActionResult> Heartbeat()
{
var q = Request.Query;
if (q.ContainsKey("Topic")
&& q.ContainsKey("ExpiryTimeout")
&& q.ContainsKey("WriteToken"))
{ {
Herald = herald; HeartbeatArgs args = new();
Timeouts = timeouts; args.Topic = q["Topic"];
Db = db; args.WriteToken = q["WriteToken"];
Config = cfgOptions.Value; if (!int.TryParse(q["ExpiryTimeout"], out var expTimeout))
}
// tfw when you need to manually parse body and query
[Route("api/heartbeat")]
[HttpPost]
public async Task<IActionResult> Heartbeat()
{
var q = Request.Query;
if (q.ContainsKey("Topic")
&& q.ContainsKey("ExpiryTimeout")
&& q.ContainsKey("WriteToken"))
{
HeartbeatArgs args = new();
args.Topic = q["Topic"];
args.WriteToken = q["WriteToken"];
if (!int.TryParse(q["ExpiryTimeout"], out var expTimeout))
{
return BadRequest();
}
args.ExpiryTimeout = expTimeout;
return await DoHeartbeat(args);
}
try
{
var args = await JsonSerializer.DeserializeAsync<HeartbeatArgs>(HttpContext.Request.Body, new JsonSerializerOptions()
{
IncludeFields = true
});
return await DoHeartbeat(args);
}
catch (JsonException)
{ {
return BadRequest(); return BadRequest();
} }
args.ExpiryTimeout = expTimeout;
return await DoHeartbeat(args);
} }
[Route("api/heartbeat")] try
[HttpGet]
public Task<IActionResult> HeartbeatGet([FromQuery] HeartbeatArgs args) => DoHeartbeat(args);
private async Task<IActionResult> DoHeartbeat(HeartbeatArgs args)
{ {
var heart = args.Heart ?? "General"; var args = await JsonSerializer.DeserializeAsync<HeartbeatArgs>(HttpContext.Request.Body, new JsonSerializerOptions()
{
var t = await Db.GetTopic(args.Topic); IncludeFields = true
if (t == null) });
return new NotFoundResult(); return await DoHeartbeat(args);
else if (!t.WriteToken.Equals(args.WriteToken, StringComparison.Ordinal))
return StatusCode(403);
if (Timeouts.IsTimedOut(t.TopicId))
return StatusCode(StatusCodes.Status429TooManyRequests);
var affected = await Db.ReportHeartbeat(t.TopicId, heart, args.ExpiryTimeout);
if (affected == 1)
await Herald.HeartbeatSent(t, heart);
Timeouts.ApplyCost(t.TopicId, Config.HeartbeatCost);
return new OkResult();
} }
catch (JsonException)
public class HeartbeatArgs
{ {
[JsonPropertyName("Topic")] public string Topic { get; set; } return BadRequest();
[JsonPropertyName("Heart")] public string Heart { get; set; }
[JsonPropertyName("ExpiryTimeout")] public int ExpiryTimeout { get; set; }
[JsonPropertyName("WriteToken")] public string WriteToken { get; set; }
} }
} }
}
[Route("api/heartbeat")]
[HttpGet]
public Task<IActionResult> HeartbeatGet([FromQuery] HeartbeatArgs args) => DoHeartbeat(args);
private async Task<IActionResult> DoHeartbeat(HeartbeatArgs args)
{
var heart = args.Heart ?? "General";
var t = await Db.GetTopic(args.Topic);
if (t == null)
return new NotFoundResult();
else if (!t.WriteToken.Equals(args.WriteToken, StringComparison.Ordinal))
return StatusCode(403);
if (Timeouts.IsTimedOut(t.TopicId))
return StatusCode(StatusCodes.Status429TooManyRequests);
var affected = await Db.ReportHeartbeat(t.TopicId, heart, args.ExpiryTimeout);
if (affected == 1)
await Herald.HeartbeatReceived(t, heart);
Timeouts.ApplyCost(t.TopicId, Config.HeartbeatCost);
return new OkResult();
}
public class HeartbeatArgs
{
[JsonPropertyName("Topic")] public string Topic { get; set; }
[JsonPropertyName("Heart")] public string Heart { get; set; }
[JsonPropertyName("ExpiryTimeout")] public int ExpiryTimeout { get; set; }
[JsonPropertyName("WriteToken")] public string WriteToken { get; set; }
}
}

View File

@ -1,21 +1,20 @@
using System.Reflection; using System.Reflection;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace JetHerald.Controllers namespace JetHerald.Controllers;
[ApiController]
public class HelloController : ControllerBase
{ {
[ApiController] [Route("api/hello")]
public class HelloController : ControllerBase [HttpGet]
public object Hello()
{ {
[Route("api/hello")] return new
[HttpGet]
public object Hello()
{ {
return new status = "OK",
{ server_name = "JetHerald",
status = "OK", server_version = Assembly.GetExecutingAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>().InformationalVersion
server_name = "JetHerald", };
server_version = Assembly.GetExecutingAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>().InformationalVersion
};
}
} }
} }

View File

@ -1,81 +1,77 @@
using System; using System.Text.Json;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options; using JetHerald.Services;
namespace JetHerald.Controllers namespace JetHerald.Controllers;
[ApiController]
public class ReportController : ControllerBase
{ {
[ApiController] Db Db { get; }
public class ReportController : ControllerBase JetHeraldBot Herald { get; }
LeakyBucket Timeouts { get; }
Options.TimeoutConfig Config { get; }
public ReportController(Db db, JetHeraldBot herald, LeakyBucket timeouts, IOptions<Options.TimeoutConfig> cfgOptions)
{ {
Db Db { get; } Herald = herald;
JetHeraldBot Herald { get; } Timeouts = timeouts;
LeakyBucket Timeouts { get; } Db = db;
Options.Timeout Config { get; } Config = cfgOptions.Value;
}
public ReportController(Db db, JetHeraldBot herald, LeakyBucket timeouts, IOptions<Options.Timeout> cfgOptions) [Route("api/report")]
[HttpPost]
public async Task<IActionResult> Report()
{
var q = Request.Query;
if (q.ContainsKey("Topic")
&& q.ContainsKey("Message")
&& q.ContainsKey("WriteToken"))
{ {
Herald = herald; ReportArgs args = new();
Timeouts = timeouts; args.Topic = q["Topic"];
Db = db; args.Message = q["Message"];
Config = cfgOptions.Value; args.WriteToken = q["WriteToken"];
return await DoReport(args);
} }
[Route("api/report")] try
[HttpPost]
public async Task<IActionResult> Report()
{ {
var q = Request.Query; var args = await JsonSerializer.DeserializeAsync<ReportArgs>(HttpContext.Request.Body, new JsonSerializerOptions()
if (q.ContainsKey("Topic")
&& q.ContainsKey("Message")
&& q.ContainsKey("WriteToken"))
{ {
ReportArgs args = new(); IncludeFields = true
args.Topic = q["Topic"]; });
args.Message = q["Message"]; return await DoReport(args);
args.WriteToken = q["WriteToken"];
return await DoReport(args);
}
try
{
var args = await JsonSerializer.DeserializeAsync<ReportArgs>(HttpContext.Request.Body, new()
{
IncludeFields = true
});
return await DoReport(args);
}
catch (JsonException)
{
return BadRequest();
}
} }
catch (JsonException)
private async Task<IActionResult> DoReport(ReportArgs args)
{ {
var t = await Db.GetTopic(args.Topic); return BadRequest();
if (t == null)
return new NotFoundResult();
else if (!t.WriteToken.Equals(args.WriteToken, StringComparison.OrdinalIgnoreCase))
return StatusCode(403);
if (Timeouts.IsTimedOut(t.TopicId))
return StatusCode(StatusCodes.Status429TooManyRequests);
await Herald.PublishMessage(t, args.Message);
Timeouts.ApplyCost(t.TopicId, Config.ReportCost);
return new OkResult();
}
public class ReportArgs
{
public string Topic { get; set; }
public string Message { get; set; }
public string WriteToken { get; set; }
} }
} }
private async Task<IActionResult> DoReport(ReportArgs args)
{
var t = await Db.GetTopic(args.Topic);
if (t == null)
return new NotFoundResult();
else if (!t.WriteToken.Equals(args.WriteToken, StringComparison.OrdinalIgnoreCase))
return StatusCode(403);
if (Timeouts.IsTimedOut(t.TopicId))
return StatusCode(StatusCodes.Status429TooManyRequests);
await Herald.PublishMessage(t, args.Message);
Timeouts.ApplyCost(t.TopicId, Config.ReportCost);
return new OkResult();
}
public class ReportArgs
{
public string Topic { get; set; }
public string Message { get; set; }
public string WriteToken { get; set; }
}
} }

View File

@ -0,0 +1,22 @@
using System.Data;
using Dapper;
namespace JetHerald;
public static class DapperConverters
{
static bool registered = false;
public static void Register()
{
if (registered)
return;
registered = true;
SqlMapper.AddTypeHandler(new NamespacedIdHandler());
}
class NamespacedIdHandler : SqlMapper.TypeHandler<NamespacedId>
{
public override void SetValue(IDbDataParameter parameter, NamespacedId value) => parameter.Value = value.ToString();
public override NamespacedId Parse(object value) => new((string)value);
}
}

View File

@ -1,25 +0,0 @@
using System.Data;
using Dapper;
namespace JetHerald
{
public static class DapperConverters
{
static bool registered = false;
public static void Register()
{
if (registered)
return;
registered = true;
SqlMapper.AddTypeHandler(new NamespacedIdHandler());
}
class NamespacedIdHandler : SqlMapper.TypeHandler<NamespacedId>
{
public override void SetValue(IDbDataParameter parameter, NamespacedId value) => parameter.Value = value.ToString();
public override NamespacedId Parse(object value) => new((string)value);
}
}
}

View File

@ -1,174 +0,0 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Options;
using MySql.Data.MySqlClient;
using Dapper;
using System.Threading.Tasks;
using System.Threading;
namespace JetHerald
{
public class Db
{
public class Topic
{
public uint TopicId { get; set; }
public NamespacedId Creator { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public string ReadToken { get; set; }
public string WriteToken { get; set; }
public string AdminToken { get; set; }
public DateTime? ExpiryTime { get; set; }
public bool ExpiryMessageSent { get; set; }
public NamespacedId? Chat { get; set; }
public override string ToString()
=> Name == Description ? Name : $"{Name}: {Description}";
}
public class HeartAttack
{
public ulong HeartattackId { get; set; }
public uint TopicId { get; set; }
public string Heart { get; set; }
public DateTime ExpiryTime { get; set; }
public string Description { get; set; }
}
public async Task<int> DeleteTopic(string name, string adminToken)
{
using var c = GetConnection();
return await c.ExecuteAsync(
" DELETE" +
" FROM topic" +
" WHERE Name = @name AND AdminToken = @adminToken",
new { name, adminToken });
}
public async Task<Topic> GetTopic(string name)
{
using var c = GetConnection();
return await c.QuerySingleOrDefaultAsync<Topic>(
"SELECT *" +
" FROM topic" +
" WHERE Name = @name",
new { name });
}
public async Task<Topic> GetTopicForSub(string token, NamespacedId chat)
{
using var c = GetConnection();
return await c.QuerySingleOrDefaultAsync<Topic>(
" SELECT t.*, tc.Chat " +
" FROM topic t " +
" LEFT JOIN topic_chat tc ON t.TopicId = tc.TopicId AND tc.Chat = @chat " +
" WHERE ReadToken = @token",
new { token, chat });
}
public async Task<Topic> CreateTopic(NamespacedId user, string name, string descr)
{
var t = new Topic
{
Creator = user,
Name = name,
Description = descr,
ReadToken = TokenHelper.GetToken(),
WriteToken = TokenHelper.GetToken(),
AdminToken = TokenHelper.GetToken()
};
using var c = GetConnection();
return await c.QuerySingleOrDefaultAsync<Topic>(
" INSERT INTO herald.topic " +
" ( Creator, Name, Description, ReadToken, WriteToken, AdminToken) " +
" VALUES " +
" (@Creator, @Name, @Description, @ReadToken, @WriteToken, @AdminToken); " +
" SELECT * FROM topic WHERE TopicId = LAST_INSERT_ID(); ",
t);
}
public async Task<IEnumerable<NamespacedId>> GetChatsForTopic(uint topicid)
{
using var c = GetConnection();
return await c.QueryAsync<NamespacedId>(
" SELECT Chat " +
" FROM topic_chat " +
" WHERE TopicId = @topicid",
new { topicid });
}
public async Task<IEnumerable<Topic>> GetTopicsForChat(NamespacedId chat)
{
using var c = GetConnection();
return await c.QueryAsync<Topic>(
" SELECT t.*" +
" FROM topic_chat ct" +
" JOIN topic t on t.TopicId = ct.TopicId" +
" WHERE ct.Chat = @chat",
new { chat });
}
public async Task CreateSubscription(uint topicId, NamespacedId chat)
{
using var c = GetConnection();
await c.ExecuteAsync(
" INSERT INTO topic_chat" +
" (Chat, TopicId)" +
" VALUES" +
" (@chat, @topicId)",
new { topicId, chat });
}
public async Task<int> RemoveSubscription(string topicName, NamespacedId chat)
{
using var c = GetConnection();
return await c.ExecuteAsync(
" DELETE tc " +
" FROM topic_chat tc" +
" JOIN topic t ON tc.TopicId = t.TopicId " +
" WHERE t.Name = @topicName AND tc.Chat = @chat;",
new { topicName, chat });
}
public async Task<int> ReportHeartbeat(uint topicId, string heart, int timeoutSeconds)
{
using var c = GetConnection();
return await c.ExecuteAsync(
@"
INSERT INTO heartbeat
(TopicId, Heart, ExpiryTime)
VALUES
(@topicId, @heart, CURRENT_TIMESTAMP() + INTERVAL @timeoutSeconds SECOND)
ON DUPLICATE KEY UPDATE
ExpiryTime = CURRENT_TIMESTAMP() + INTERVAL @timeoutSeconds SECOND;
",
new { topicId, heart, @timeoutSeconds });
}
public async Task<IEnumerable<HeartAttack>> ProcessHeartAttacks()
{
using var c = GetConnection();
return await c.QueryAsync<HeartAttack>("CALL process_heartattacks();");
}
public async Task MarkHeartAttackReported(ulong id, byte status = 1)
{
using var c = GetConnection();
await c.ExecuteAsync("UPDATE heartattack SET Status = @status WHERE HeartattackId = @id", new { id, status });
}
public Db(IOptions<Options.ConnectionStrings> cfg)
{
Config = cfg.Value;
}
Options.ConnectionStrings Config { get; }
MySqlConnection GetConnection() => new(Config.DefaultConnection);
}
}

View File

@ -0,0 +1,8 @@
global using System;
global using System.Collections.Generic;
global using System.IO;
global using System.Linq;
global using System.Threading.Tasks;
global using Microsoft.Extensions.Logging;
global using Microsoft.Extensions.Options;

View File

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net5.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@ -9,13 +9,13 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Dapper" Version="1.60.6" /> <PackageReference Include="Dapper" Version="2.0.123" />
<PackageReference Include="DSharpPlus" Version="4.0.0" /> <PackageReference Include="DSharpPlus" Version="4.1.0" />
<PackageReference Include="DSharpPlus.CommandsNext" Version="4.0.0" /> <PackageReference Include="DSharpPlus.CommandsNext" Version="4.1.0" />
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="2.1.1" /> <PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="6.0.1" />
<PackageReference Include="MySql.Data" Version="8.0.17" /> <PackageReference Include="MySql.Data" Version="8.0.28" />
<PackageReference Include="NLog.Web.AspNetCore" Version="4.8.4" /> <PackageReference Include="NLog.Web.AspNetCore" Version="5.0.0-rc2" />
<PackageReference Include="Telegram.Bot" Version="14.12.0" /> <PackageReference Include="Telegram.Bot.Extensions.Polling" Version="1.0.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -1,40 +0,0 @@
using System.Threading.Tasks;
using DSharpPlus;
using DSharpPlus.CommandsNext;
using JetHerald.Commands;
namespace JetHerald
{
public partial class JetHeraldBot
{
DiscordClient DiscordBot { get; set; }
async Task InitDiscord()
{
DiscordBot = new DiscordClient(new()
{
Token = DiscordConfig.Token,
TokenType = TokenType.Bot,
Intents = DiscordIntents.AllUnprivileged,
LoggerFactory = LoggerFactory
});
var commands = DiscordBot.UseCommandsNext(new CommandsNextConfiguration()
{
StringPrefixes = new[] { "!" },
Services = ServiceProvider
});
commands.RegisterCommands<DiscordCommands>();
await DiscordBot.ConnectAsync();
}
async Task SendMessageToDiscordChannel(NamespacedId chat, string formatted)
{
var id = ulong.Parse(chat.Id);
await DiscordBot.SendMessageAsync(await DiscordBot.GetChannelAsync(id), formatted);
}
}
}

View File

@ -1,73 +0,0 @@
using System;
using System.Net;
using System.Threading.Tasks;
using JetHerald.Commands;
using Microsoft.Extensions.Logging;
using Telegram.Bot;
using Telegram.Bot.Args;
using Telegram.Bot.Types.Enums;
namespace JetHerald
{
public partial class JetHeraldBot
{
TelegramBotClient TelegramBot { get; set; }
Telegram.Bot.Types.User Me { get; set; }
ChatCommandRouter Commands;
async Task InitTelegram()
{
if (TelegramConfig.UseProxy)
{
var httpProxy = new WebProxy(TelegramConfig.ProxyUrl)
{ Credentials = new NetworkCredential(TelegramConfig.ProxyLogin, TelegramConfig.ProxyPassword) };
TelegramBot = new TelegramBotClient(TelegramConfig.ApiKey, httpProxy);
}
else
{
TelegramBot = new TelegramBotClient(TelegramConfig.ApiKey);
}
Me = await TelegramBot.GetMeAsync();
Commands = new ChatCommandRouter(Me.Username, Log);
Commands.Add(new CreateTopicCommand(Db), "createtopic");
Commands.Add(new DeleteTopicCommand(Db), "deletetopic");
Commands.Add(new SubscribeCommand(Db, TelegramBot), "subscribe", "sub");
Commands.Add(new UnsubscribeCommand(Db, TelegramBot), "unsubscribe", "unsub");
Commands.Add(new ListCommand(Db, TelegramBot), "list");
HeartbeatCancellation = new();
HeartbeatTask = CheckHeartbeats(HeartbeatCancellation.Token);
TelegramBot.OnMessage += TelegramMessageReceived;
TelegramBot.StartReceiving();
}
Task SendMessageToTelegramChannel(NamespacedId chat, string formatted)
{
var id = long.Parse(chat.Id);
return TelegramBot.SendTextMessageAsync(id, formatted);
}
async void TelegramMessageReceived(object sender, MessageEventArgs messageEventArgs)
{
var msg = messageEventArgs.Message;
if (msg == null || msg.Type != MessageType.Text)
return;
try
{
var reply = await Commands.Execute(sender, messageEventArgs);
if (reply != null)
await TelegramBot.SendTextMessageAsync(
chatId: msg.Chat.Id,
text: reply,
replyToMessageId: msg.MessageId);
}
catch (Exception e)
{
Log.LogError(e, "Exception occured during handling of command: " + msg.Text);
}
}
}
}

View File

@ -1,109 +0,0 @@
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Threading;
using Microsoft.Extensions.Hosting;
namespace JetHerald
{
public partial class JetHeraldBot : IHostedService
{
Db Db { get; set; }
Options.Telegram TelegramConfig { get; }
Options.Discord DiscordConfig { get; }
ILogger<JetHeraldBot> Log { get; }
ILoggerFactory LoggerFactory { get; }
IServiceProvider ServiceProvider { get; }
public JetHeraldBot(Db db, IOptions<Options.Telegram> telegramCfg, IOptions<Options.Discord> discordCfg, ILogger<JetHeraldBot> log, ILoggerFactory loggerFactory, IServiceProvider serviceProvider)
{
Db = db;
TelegramConfig = telegramCfg.Value;
DiscordConfig = discordCfg.Value;
Log = log;
LoggerFactory = loggerFactory;
ServiceProvider = serviceProvider;
}
CancellationTokenSource HeartbeatCancellation;
Task HeartbeatTask;
public async Task StartAsync(CancellationToken token)
{
await InitTelegram();
await InitDiscord();
}
public async Task StopAsync(CancellationToken token)
{
await DiscordBot.DisconnectAsync();
TelegramBot.StopReceiving();
HeartbeatCancellation.Cancel();
try
{
await HeartbeatTask;
}
catch (TaskCanceledException)
{
}
}
public async Task CheckHeartbeats(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
await Task.Delay(1000 * 10, token);
try
{
var attacks = await Db.ProcessHeartAttacks();
foreach (var attack in attacks)
{
var chats = await Db.GetChatsForTopic(attack.TopicId);
foreach (var chat in chats)
await SendMessageImpl(chat, $"!{attack.Description}!:\nHeart \"{attack.Heart}\" stopped beating at {attack.ExpiryTime}");
await Db.MarkHeartAttackReported(attack.HeartattackId);
if (token.IsCancellationRequested)
return;
}
}
catch (Exception e)
{
Log.LogError(e, "Exception while checking heartbeats");
}
}
}
public Task HeartbeatSent(Db.Topic topic, string heart)
=> BroadcastMessageImpl(topic, $"!{topic.Description}!:\nHeart \"{heart}\" has started beating.");
public Task PublishMessage(Db.Topic topic, string message)
=> BroadcastMessageImpl(topic, $"|{topic.Description}|:\n{message}");
async Task BroadcastMessageImpl(Db.Topic topic, string formatted)
{
var chatIds = await Db.GetChatsForTopic(topic.TopicId);
foreach (var c in chatIds)
await SendMessageImpl(c, formatted);
}
async Task SendMessageImpl(NamespacedId chat, string formatted)
{
try
{
if (chat.Namespace == "telegram")
{
await SendMessageToTelegramChannel(chat, formatted);
}
else if (chat.Namespace == "discord")
{
await SendMessageToDiscordChannel(chat, formatted);
}
}
catch (Exception e) { Log.LogError(e, $"Error while sending message \"{formatted}\" to {chat}"); }
}
}
}

View File

@ -1,44 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace JetHerald
{
public class LeakyBucket
{
private readonly ConcurrentDictionary<uint, DateTime> expiryDates = new();
private readonly Options.Timeout config;
private readonly ILogger log;
public LeakyBucket(IOptions<Options.Timeout> cfgOptions, ILogger<LeakyBucket> log)
{
config = cfgOptions.Value;
this.log = log;
}
public bool IsTimedOut(uint key)
{
var now = DateTime.UtcNow;
var debtLimit = now.AddSeconds(config.DebtLimitSeconds);
var time = expiryDates.GetValueOrDefault(key, now);
log.LogTrace("{key} had current timedebt of {time}", key, time);
return time > debtLimit;
}
public void ApplyCost(uint key, int cost)
{
expiryDates.AddOrUpdate(key,
key => DateTime.UtcNow.AddSeconds(cost),
(key, oldDebt) =>
{
var now = DateTime.UtcNow;
if (oldDebt < now)
return now.AddSeconds(cost);
return oldDebt.AddSeconds(cost);
});
}
}
}

View File

@ -1,47 +1,40 @@
using System; namespace JetHerald;
public struct NamespacedId
namespace JetHerald
{ {
public struct NamespacedId public string Namespace { get; init; }
public string Id { get; init; }
public NamespacedId(string str)
{ {
public string Namespace { get; init; } var ind = str.IndexOf("://");
public string Id { get; init; } if (ind < 0) throw new ArgumentException("Could not parse namespaced id");
Namespace = str[..ind].ToLowerInvariant();
public NamespacedId(string str) Id = str[(ind + 3)..];
{
var ind = str.IndexOf("://");
if (ind < 0) throw new ArgumentException("Could not parse namespaced id");
Namespace = str[..ind].ToLowerInvariant();
Id = str[(ind + 3)..];
}
public NamespacedId(string ns, string id)
{
Namespace = ns;
Id = id;
}
public static NamespacedId Telegram(long id)
=> new("telegram", $"{id}");
public static NamespacedId Discord(ulong id)
=> new("discord", $"{id}");
public override string ToString() => $"{Namespace}://{Id}";
public override int GetHashCode() => HashCode.Combine(Namespace, Id);
public override bool Equals(object obj)
=> obj is NamespacedId nsid && this == nsid;
public static bool operator ==(NamespacedId a, NamespacedId b)
=> a.Namespace == b.Namespace && a.Id == b.Id;
public static bool operator !=(NamespacedId a, NamespacedId b)
=> !(a == b);
} }
public NamespacedId(string ns, string id)
{
Namespace = ns;
Id = id;
}
public static NamespacedId Telegram(long id)
=> new("telegram", $"{id}");
public static NamespacedId Discord(ulong id)
=> new("discord", $"{id}");
public override string ToString() => $"{Namespace}://{Id}";
public override int GetHashCode() => HashCode.Combine(Namespace, Id);
public override bool Equals(object obj)
=> obj is NamespacedId nsid && this == nsid;
public static bool operator ==(NamespacedId a, NamespacedId b)
=> a.Namespace == b.Namespace && a.Id == b.Id;
public static bool operator !=(NamespacedId a, NamespacedId b)
=> !(a == b);
} }

23
JetHerald/Options.cs Normal file
View File

@ -0,0 +1,23 @@
namespace JetHerald.Options;
public class ConnectionStrings
{
public string DefaultConnection { get; set; }
}
public class TelegramConfig
{
public string ApiKey { get; set; }
}
public class DiscordConfig
{
public string Token { get; set; }
}
public class TimeoutConfig
{
public int DebtLimitSeconds { get; set; }
public int HeartbeatCost { get; set; }
public int ReportCost { get; set; }
}

View File

@ -1,51 +1,73 @@
using System; using Microsoft.AspNetCore.Builder;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using NLog.Web; using NLog.Web;
using JetHerald;
using JetHerald.Options;
using JetHerald.Services;
namespace JetHerald var log =
#if DEBUG
NLogBuilder.ConfigureNLog("nlog.debug.config").GetCurrentClassLogger();
#else
NLogBuilder.ConfigureNLog("nlog.config").GetCurrentClassLogger();
#endif
try
{ {
public class Program log.Info("init main");
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureAppConfiguration((hostingContext, config) =>
{ {
public static void Main(string[] args) config.SetBasePath(Directory.GetCurrentDirectory());
{ config.AddIniFile("secrets.ini",
DapperConverters.Register(); optional: true, reloadOnChange: true);
config.AddIniFile($"secrets.{hostingContext.HostingEnvironment.EnvironmentName}.ini",
optional: true, reloadOnChange: true);
config.AddJsonFile("secrets.json", optional: true, reloadOnChange: true);
config.AddJsonFile($"secrets.{hostingContext.HostingEnvironment.EnvironmentName}.json", optional: true, reloadOnChange: true);
});
var logger = NLog.Web.NLogBuilder.ConfigureNLog("NLog.config").GetCurrentClassLogger(); builder.Logging.ClearProviders();
try builder.Logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Trace);
{ builder.Host.UseNLog();
logger.Debug("init main");
CreateWebHostBuilder(args).Build().Run();
}
catch (Exception ex)
{
logger.Error(ex, "Stopped program because of exception");
throw;
}
finally
{
NLog.LogManager.Shutdown();
}
}
public static IWebHostBuilder CreateWebHostBuilder(string[] args) => var cfg = builder.Configuration;
WebHost.CreateDefaultBuilder(args) var services = builder.Services;
.UseStartup<Startup>()
.ConfigureAppConfiguration(config => services.Configure<ConnectionStrings>(cfg.GetSection("ConnectionStrings"));
{ services.Configure<JetHerald.Options.TelegramConfig>(cfg.GetSection("Telegram"));
config.AddIniFile("secrets.ini"); services.Configure<DiscordConfig>(cfg.GetSection("Discord"));
}) services.Configure<TimeoutConfig>(cfg.GetSection("Timeout"));
.ConfigureLogging(logging => services.AddSingleton<Db>();
{ services.AddSingleton<JetHeraldBot>().AddHostedService(s => s.GetService<JetHeraldBot>());
logging.ClearProviders(); services.AddSingleton<LeakyBucket>();
logging.SetMinimumLevel(LogLevel.Trace); services.AddMvc();
})
.UseNLog(); // NLog: setup NLog for Dependency injection
} var app = builder.Build();
app.UsePathBase(cfg.GetValue<string>("PathBase"));
app.UseDeveloperExceptionPage();
app.UseHsts();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
app.Run();
}
catch (Exception exception)
{
log.Error(exception, "Error while starting up");
throw;
}
finally
{
// Ensure to flush and stop internal timers/threads before application-exit (Avoid segmentation fault on Linux)
NLog.LogManager.Shutdown();
} }

138
JetHerald/Services/Db.cs Normal file
View File

@ -0,0 +1,138 @@
using MySql.Data.MySqlClient;
using Dapper;
using JetHerald.Options;
using JetHerald.Contracts;
namespace JetHerald.Services;
public class Db
{
public async Task<int> DeleteTopic(string name, string adminToken)
{
using var c = GetConnection();
return await c.ExecuteAsync(
" DELETE" +
" FROM topic" +
" WHERE Name = @name AND AdminToken = @adminToken",
new { name, adminToken });
}
public async Task<Topic> GetTopic(string name)
{
using var c = GetConnection();
return await c.QuerySingleOrDefaultAsync<Topic>(
" SELECT *" +
" FROM topic" +
" WHERE Name = @name",
new { name });
}
public async Task<Topic> GetTopicForSub(string token, NamespacedId chat)
{
using var c = GetConnection();
return await c.QuerySingleOrDefaultAsync<Topic>(
" SELECT t.*, ts.Sub " +
" FROM topic t " +
" LEFT JOIN topic_sub ts ON t.TopicId = ts.TopicId AND ts.Chat = @chat " +
" WHERE ReadToken = @token",
new { token, chat });
}
public async Task<Topic> CreateTopic(NamespacedId user, string name, string descr)
{
using var c = GetConnection();
return await c.QuerySingleOrDefaultAsync<Topic>(
" INSERT INTO topic " +
" ( Creator, Name, Description, ReadToken, WriteToken, AdminToken) " +
" VALUES " +
" (@Creator, @Name, @Description, @ReadToken, @WriteToken, @AdminToken); " +
" SELECT * FROM topic WHERE TopicId = LAST_INSERT_ID(); ",
new Topic
{
Creator = user,
Name = name,
Description = descr,
ReadToken = TokenHelper.GetToken(),
WriteToken = TokenHelper.GetToken(),
AdminToken = TokenHelper.GetToken()
});
}
public async Task<IEnumerable<NamespacedId>> GetSubsForTopic(uint topicId)
{
using var c = GetConnection();
return await c.QueryAsync<NamespacedId>(
" SELECT Sub " +
" FROM topic_sub " +
" WHERE TopicId = @topicid",
new { topicId });
}
public async Task<IEnumerable<Topic>> GetTopicsForSub(NamespacedId sub)
{
using var c = GetConnection();
return await c.QueryAsync<Topic>(
" SELECT t.*" +
" FROM topic_sub ts" +
" JOIN topic t USING (TopicId)" +
" WHERE ts.Sub = @sub",
new { sub });
}
public async Task CreateSubscription(uint topicId, NamespacedId sub)
{
using var c = GetConnection();
await c.ExecuteAsync(
" INSERT INTO topic_sub" +
" (TopicId, Sub)" +
" VALUES" +
" (@topicId, @sub)",
new { topicId, sub });
}
public async Task<int> RemoveSubscription(string topicName, NamespacedId sub)
{
using var c = GetConnection();
return await c.ExecuteAsync(
" DELETE ts " +
" FROM topic_sub ts" +
" JOIN topic t USING (TopicId) " +
" WHERE t.Name = @topicName AND tc.Sub = @sub;",
new { topicName, sub });
}
public async Task<int> ReportHeartbeat(uint topicId, string heart, int timeoutSeconds)
{
using var c = GetConnection();
return await c.ExecuteAsync(
@"
INSERT INTO heart
(TopicId, Heart, Status, ExpiryTs)
VALUES
(@topicId, @heart, 'beating', CURRENT_TIMESTAMP() + INTERVAL @timeoutSeconds SECOND)
ON DUPLICATE KEY UPDATE
Status = 'beating',
ExpiryTs = CURRENT_TIMESTAMP() + INTERVAL @timeoutSeconds SECOND;
",
new { topicId, heart, timeoutSeconds });
}
public async Task<IEnumerable<HeartEvent>> ProcessHearts()
{
using var c = GetConnection();
return await c.QueryAsync<HeartEvent>("CALL process_hearts();");
}
public async Task MarkHeartAttackReported(ulong id)
{
using var c = GetConnection();
await c.ExecuteAsync("UPDATE heartevent SET Status = 'reported' WHERE HeartattackId = @id", new { id });
}
public Db(IOptionsMonitor<ConnectionStrings> cfg)
{
Config = cfg;
}
IOptionsMonitor<ConnectionStrings> Config { get; }
MySqlConnection GetConnection() => new(Config.CurrentValue.DefaultConnection);
}

View File

@ -0,0 +1,117 @@
using MySql.Data.MySqlClient;
using DSharpPlus.CommandsNext;
using DSharpPlus.CommandsNext.Attributes;
using JetHerald.Services;
namespace JetHerald.Commands;
[ModuleLifespan(ModuleLifespan.Transient)]
public class DiscordCommands : BaseCommandModule
{
public Db Db { get; set; }
[Command("createtopic")]
[Description("Creates a topic.")]
[RequireDirectMessage]
public async Task CreateTopic(
CommandContext ctx,
[Description("The unique name of the new topic.")]
string name,
[RemainingText, Description("The name displayed in service messages. Defaults to `name`")]
string description = null)
{
if (description == null)
description = name;
_ = ctx.TriggerTypingAsync();
try
{
var topic = await Db.CreateTopic(NamespacedId.Discord(ctx.User.Id), name, description);
await ctx.RespondAsync($"created {topic.Name}\n" +
$"readToken\n{topic.ReadToken}\n" +
$"writeToken\n{topic.WriteToken}\n" +
$"adminToken\n{topic.AdminToken}\n");
}
catch (MySqlException myDuplicate) when (myDuplicate.Number == 1062)
{
await ctx.RespondAsync($"topic {name} already exists");
}
}
[Command("deletetopic")]
[Description("Deletes a topic.")]
[RequireDirectMessage]
public async Task DeleteTopic(
CommandContext ctx,
[Description("The name of the topic to be deleted.")]
string name,
[Description("The admin token of the topic to be deleted.")]
string adminToken)
{
_ = ctx.TriggerTypingAsync();
var changed = await Db.DeleteTopic(name, adminToken);
if (changed > 0)
await ctx.RespondAsync($"deleted {name} and all its subscriptions");
else
await ctx.RespondAsync($"invalid topic name or admin token");
}
[Command("list")]
[Description("List all subscriptions in this channel.")]
[RequireUserPermissions(DSharpPlus.Permissions.ManageGuild)]
public async Task ListSubscriptions(CommandContext ctx)
{
_ = ctx.TriggerTypingAsync();
var topics = await Db.GetTopicsForSub(NamespacedId.Discord(ctx.Channel.Id));
await ctx.RespondAsync(topics.Any()
? "Topics:\n" + string.Join("\n", topics)
: "No subscriptions active.");
}
[Command("subscribe")]
[Description("Subscribes to a topic.")]
[RequireUserPermissions(DSharpPlus.Permissions.ManageGuild)]
public async Task Subscribe(
CommandContext ctx,
[Description("The read token of the token to subscribe to.")]
string token
)
{
_ = ctx.TriggerTypingAsync();
var chat = NamespacedId.Discord(ctx.Channel.Id);
var topic = await Db.GetTopicForSub(token, chat);
if (topic == null)
await ctx.RespondAsync("topic not found");
else if (topic.Sub.HasValue && topic.Sub.Value == chat)
await ctx.RespondAsync($"already subscribed to {topic.Name}");
else if (topic.ReadToken != token)
await ctx.RespondAsync("token mismatch");
else
{
await Db.CreateSubscription(topic.TopicId, chat);
await ctx.RespondAsync($"subscribed to {topic.Name}");
}
}
[Command("unsubscribe")]
[Description("Unsubscribes from a topic.")]
[RequireUserPermissions(DSharpPlus.Permissions.ManageGuild)]
public async Task Unsubscribe(
CommandContext ctx,
[Description("The name of the topic to unsubscribe from.")]
string name
)
{
_ = ctx.TriggerTypingAsync();
int affected = await Db.RemoveSubscription(name, NamespacedId.Discord(ctx.Channel.Id));
if (affected >= 1)
await ctx.RespondAsync($"unsubscribed from {name}");
else
await ctx.RespondAsync($"could not find subscription for {name}");
}
}

View File

@ -0,0 +1,46 @@
using System.Threading;
using Microsoft.Extensions.Hosting;
namespace JetHerald.Services;
public class HeartMonitor : BackgroundService
{
public HeartMonitor(
Db db,
JetHeraldBot herald,
ILogger<HeartMonitor> log)
{
Db = db;
Herald = herald;
Log = log;
}
Db Db { get; }
JetHeraldBot Herald { get; }
ILogger<HeartMonitor> Log { get; }
protected override async Task ExecuteAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
await Task.Delay(1000 * 10, token);
try
{
var attacks = await Db.ProcessHearts();
foreach (var a in attacks)
{
await Herald.BroadcastMessageRaw(
a.TopicId,
$"!{a.Description}!:\nHeart \"{a.Heart}\" stopped beating at {a.CreateTs}");
await Db.MarkHeartAttackReported(a.HeartEventId);
if (token.IsCancellationRequested)
return;
}
}
catch (Exception e)
{
Log.LogError(e, "Exception while checking heartbeats");
}
}
}
}

View File

@ -0,0 +1,36 @@
using DSharpPlus;
using DSharpPlus.CommandsNext;
using JetHerald.Commands;
namespace JetHerald.Services;
public partial class JetHeraldBot
{
DiscordClient DiscordBot { get; set; }
async Task StartDiscord()
{
DiscordBot = new DiscordClient(new()
{
Token = DiscordConfig.Token,
TokenType = TokenType.Bot,
Intents = DiscordIntents.AllUnprivileged,
LoggerFactory = LoggerFactory
});
var commands = DiscordBot.UseCommandsNext(new CommandsNextConfiguration()
{
StringPrefixes = new[] { "!" },
Services = ServiceProvider
});
commands.RegisterCommands<DiscordCommands>();
await DiscordBot.ConnectAsync();
}
Task StopDiscord() => DiscordBot.DisconnectAsync();
async Task SendMessageToDiscordChannel(NamespacedId chat, string formatted)
{
var id = ulong.Parse(chat.Id);
await DiscordBot.SendMessageAsync(await DiscordBot.GetChannelAsync(id), formatted);
}
}

View File

@ -0,0 +1,79 @@
using System.Threading;
using Telegram.Bot;
using Telegram.Bot.Types.Enums;
using Telegram.Bot.Extensions.Polling;
using Telegram.Bot.Exceptions;
using Telegram.Bot.Types;
using JetHerald.Commands;
using Telegram.Bot.Types.ReplyMarkups;
namespace JetHerald.Services;
public partial class JetHeraldBot
{
TelegramBotClient TelegramBot { get; set; }
Telegram.Bot.Types.User Me { get; set; }
ChatCommandRouter Commands;
CancellationTokenSource TelegramBotShutdownToken { get; } = new();
async Task StartTelegram()
{
TelegramBot = new TelegramBotClient(TelegramConfig.ApiKey);
Me = await TelegramBot.GetMeAsync();
Commands = new ChatCommandRouter(Me.Username, Log);
Commands.Add(new CreateTopicCommand(Db), "createtopic");
Commands.Add(new DeleteTopicCommand(Db), "deletetopic");
Commands.Add(new SubscribeCommand(Db, TelegramBot), "subscribe", "sub");
Commands.Add(new UnsubscribeCommand(Db, TelegramBot), "unsubscribe", "unsub");
Commands.Add(new ListCommand(Db, TelegramBot), "list");
var receiverOptions = new ReceiverOptions { AllowedUpdates = new[] { UpdateType.Message } };
TelegramBot.StartReceiving(
HandleUpdateAsync,
HandleErrorAsync,
receiverOptions,
TelegramBotShutdownToken.Token);
}
Task StopTelegram()
{
TelegramBotShutdownToken.Cancel();
return Task.CompletedTask;
}
Task SendMessageToTelegramChannel(NamespacedId chat, string formatted)
{
var id = long.Parse(chat.Id);
return TelegramBot.SendTextMessageAsync(id, formatted);
}
Task HandleErrorAsync(ITelegramBotClient botClient, Exception exception, CancellationToken cancellationToken)
{
var ErrorMessage = exception switch
{
ApiRequestException apiRequestException => $"Telegram API Error:\n[{apiRequestException.ErrorCode}]\n{apiRequestException.Message}",
_ => exception.ToString()
};
Log.LogError(ErrorMessage);
return Task.CompletedTask;
}
async Task HandleUpdateAsync(ITelegramBotClient sender, Update update, CancellationToken cancellationToken)
{
if (update.Type != UpdateType.Message || update?.Message?.Type != MessageType.Text)
return;
var msg = update.Message!;
try
{
var reply = await Commands.Execute(sender, update);
if (reply != null)
await TelegramBot.SendTextMessageAsync(
chatId: msg.Chat.Id,
text: reply,
replyToMessageId: msg.MessageId);
}
catch (Exception e)
{
Log.LogError(e, "Exception occured during handling of command: " + msg.Text);
}
}
}

View File

@ -0,0 +1,75 @@
using System.Threading;
using JetHerald.Contracts;
using JetHerald.Options;
using Microsoft.Extensions.Hosting;
namespace JetHerald.Services;
public partial class JetHeraldBot : IHostedService
{
Db Db { get; set; }
TelegramConfig TelegramConfig { get; }
DiscordConfig DiscordConfig { get; }
ILogger<JetHeraldBot> Log { get; }
ILoggerFactory LoggerFactory { get; }
IServiceProvider ServiceProvider { get; }
public JetHeraldBot(
Db db,
IOptions<TelegramConfig> telegramCfg,
IOptions<DiscordConfig> discordCfg,
ILogger<JetHeraldBot> log,
ILoggerFactory loggerFactory,
IServiceProvider serviceProvider)
{
Db = db;
TelegramConfig = telegramCfg.Value;
DiscordConfig = discordCfg.Value;
Log = log;
LoggerFactory = loggerFactory;
ServiceProvider = serviceProvider;
}
public async Task StartAsync(CancellationToken token)
{
await StartTelegram();
await StartDiscord();
}
public async Task StopAsync(CancellationToken token)
{
await StopDiscord();
await StopTelegram();
}
public Task HeartbeatReceived(Topic topic, string heart)
=> BroadcastMessageRaw(topic.TopicId, $"!{topic.Description}!:\nHeart \"{heart}\" has started beating.");
public Task PublishMessage(Topic topic, string message)
=> BroadcastMessageRaw(topic.TopicId, $"|{topic.Description}|:\n{message}");
public async Task BroadcastMessageRaw(uint topicId, string formatted)
{
var chatIds = await Db.GetSubsForTopic(topicId);
foreach (var c in chatIds)
await SendMessageRaw(c, formatted);
}
async Task SendMessageRaw(NamespacedId chat, string formatted)
{
try
{
if (chat.Namespace == "telegram")
{
await SendMessageToTelegramChannel(chat, formatted);
}
else if (chat.Namespace == "discord")
{
await SendMessageToDiscordChannel(chat, formatted);
}
}
catch (Exception e) { Log.LogError(e, $"Error while sending message \"{formatted}\" to {chat}"); }
}
}

View File

@ -0,0 +1,37 @@
using System.Collections.Concurrent;
namespace JetHerald.Services;
public class LeakyBucket
{
private readonly ConcurrentDictionary<uint, DateTime> expiryDates = new();
private readonly Options.TimeoutConfig config;
private readonly ILogger log;
public LeakyBucket(IOptions<Options.TimeoutConfig> cfgOptions, ILogger<LeakyBucket> log)
{
config = cfgOptions.Value;
this.log = log;
}
public bool IsTimedOut(uint key)
{
var now = DateTime.UtcNow;
var debtLimit = now.AddSeconds(config.DebtLimitSeconds);
var time = expiryDates.GetValueOrDefault(key, now);
log.LogTrace("{key} had current timedebt of {time}", key, time);
return time > debtLimit;
}
public void ApplyCost(uint key, int cost)
{
expiryDates.AddOrUpdate(key,
key => DateTime.UtcNow.AddSeconds(cost),
(key, oldDebt) =>
{
var now = DateTime.UtcNow;
if (oldDebt < now)
return now.AddSeconds(cost);
return oldDebt.AddSeconds(cost);
});
}
}

View File

@ -1,50 +0,0 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace JetHerald
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.Configure<Options.ConnectionStrings>(Configuration.GetSection("ConnectionStrings"));
services.Configure<Options.Telegram>(Configuration.GetSection("Telegram"));
services.Configure<Options.Discord>(Configuration.GetSection("Discord"));
services.Configure<Options.Timeout>(Configuration.GetSection("Timeout"));
services.AddSingleton<Db>();
services.AddSingleton<JetHeraldBot>();
services.AddHostedService(s => s.GetService<JetHeraldBot>());
services.AddSingleton<LeakyBucket>();
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_3_0);
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UsePathBase(Configuration.GetValue<string>("PathBase"));
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
}
}

View File

@ -1,21 +0,0 @@
using System;
using System.Security.Cryptography;
namespace JetHerald
{
public static class TokenHelper
{
static readonly RNGCryptoServiceProvider rng = new();
static readonly byte[] buf = new byte[24];
static readonly object SyncLock = new();
public static string GetToken()
{
lock (SyncLock)
{
rng.GetBytes(buf);
return Convert.ToBase64String(buf).Replace('+', '_').Replace('/', '_');
}
}
}
}

View File

@ -0,0 +1,17 @@
using System.Security.Cryptography;
namespace JetHerald;
public static class TokenHelper
{
static readonly byte[] buf = new byte[24];
static readonly object SyncLock = new();
public static string GetToken()
{
lock (SyncLock)
{
RandomNumberGenerator.Fill(buf);
return Convert.ToBase64String(buf).Replace('+', '_').Replace('/', '_');
}
}
}