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,14 +1,9 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Telegram.Bot.Args;
using Telegram.Bot.Types;
namespace JetHerald
{
namespace JetHerald;
public interface IChatCommand
{
Task<string> Execute(CommandString cmd, MessageEventArgs messageEventArgs);
Task<string> Execute(CommandString cmd, Update update);
}
public class ChatCommandRouter
@ -25,9 +20,9 @@ namespace JetHerald
Commands = new Dictionary<string, IChatCommand>();
}
public async Task<string> Execute(object sender, MessageEventArgs args)
public async Task<string> Execute(object sender, Update update)
{
var text = args.Message.Text;
var text = update.Message.Text;
if (CommandString.TryParse(text, out var cmd))
{
if (cmd.Username != null && cmd.Username != Username)
@ -40,7 +35,7 @@ namespace JetHerald
try
{
Log.LogDebug($"Handling message via {Commands[cmd.Command].GetType().Name}");
return await Commands[cmd.Command].Execute(cmd, args);
return await Commands[cmd.Command].Execute(cmd, update);
}
catch (Exception e)
{
@ -63,4 +58,3 @@ namespace JetHerald
}
}
}
}

View File

@ -1,9 +1,6 @@
using System;
using System.Linq;
using System.Text.RegularExpressions;
using System.Text.RegularExpressions;
namespace JetHerald
{
namespace JetHerald;
public class CommandString
{
public CommandString(string command, string username, params string[] parameters)
@ -40,4 +37,3 @@ namespace JetHerald
return true;
}
}
}

View File

@ -1,10 +1,8 @@
using System.Threading.Tasks;
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
namespace JetHerald.Commands
{
namespace JetHerald.Commands;
public static class CommandHelper
{
public static async Task<bool> CheckAdministrator(TelegramBotClient bot, Message msg)
@ -17,4 +15,3 @@ namespace JetHerald.Commands
return true;
}
}
}

View File

@ -1,25 +1,23 @@
using System.Linq;
using System.Threading.Tasks;
using MySql.Data.MySqlClient;
using Telegram.Bot.Args;
using MySql.Data.MySqlClient;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
using JetHerald.Services;
namespace JetHerald.Commands
{
namespace JetHerald.Commands;
public class CreateTopicCommand : IChatCommand
{
readonly Db db;
Db Db { get; }
public CreateTopicCommand(Db db)
{
this.db = db;
Db = db;
}
public async Task<string> Execute(CommandString cmd, MessageEventArgs messageEventArgs)
public async Task<string> Execute(CommandString cmd, Update update)
{
if (cmd.Parameters.Length < 1)
return null;
var msg = messageEventArgs.Message;
var msg = update.Message;
if (msg.Chat.Type != ChatType.Private)
return null;
@ -31,7 +29,7 @@ namespace JetHerald.Commands
try
{
var topic = await db.CreateTopic(NamespacedId.Telegram(msg.From.Id), name, descr);
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" +
@ -43,4 +41,4 @@ namespace JetHerald.Commands
}
}
}
}

View File

@ -1,23 +1,22 @@
using System.Threading.Tasks;
using Telegram.Bot.Args;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
using JetHerald.Services;
namespace JetHerald.Commands
{
namespace JetHerald.Commands;
public class DeleteTopicCommand : IChatCommand
{
readonly Db db;
Db Db { get; }
public DeleteTopicCommand(Db db)
{
this.db = db;
Db = db;
}
public async Task<string> Execute(CommandString cmd, MessageEventArgs messageEventArgs)
public async Task<string> Execute(CommandString cmd, Update update)
{
if (cmd.Parameters.Length < 2)
return null;
var msg = messageEventArgs.Message;
var msg = update.Message;
if (msg.Chat.Type != ChatType.Private)
return null;
@ -25,11 +24,11 @@ namespace JetHerald.Commands
string name = cmd.Parameters[0];
string adminToken = cmd.Parameters[1];
var changed = await db.DeleteTopic(name, adminToken);
var changed = await Db.DeleteTopic(name, adminToken);
if (changed > 0)
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 System.Threading.Tasks;
using JetHerald.Services;
using Telegram.Bot;
using Telegram.Bot.Args;
using Telegram.Bot.Types;
namespace JetHerald.Commands
{
namespace JetHerald.Commands;
public class ListCommand : IChatCommand
{
readonly Db db;
readonly TelegramBotClient bot;
Db Db { get; }
TelegramBotClient Bot { get; }
public ListCommand(Db db, TelegramBotClient bot)
{
this.db = db;
this.bot = bot;
Db = db;
Bot = bot;
}
public async Task<string> Execute(CommandString cmd, MessageEventArgs messageEventArgs)
public async Task<string> Execute(CommandString cmd, Update update)
{
if (!await CommandHelper.CheckAdministrator(bot, messageEventArgs.Message))
if (!await CommandHelper.CheckAdministrator(Bot, update.Message))
return null;
var msg = messageEventArgs.Message;
var msg = update.Message;
var chatid = msg.Chat.Id;
var topics = await db.GetTopicsForChat(NamespacedId.Telegram(chatid));
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.Args;
using Telegram.Bot;
using Telegram.Bot.Types;
using JetHerald.Services;
namespace JetHerald.Commands
{
namespace JetHerald.Commands;
public class SubscribeCommand : IChatCommand
{
readonly Db db;
readonly TelegramBotClient bot;
Db Db { get; }
TelegramBotClient Bot { get; }
public SubscribeCommand(Db db, TelegramBotClient bot)
{
this.db = db;
this.bot = bot;
Db = db;
Bot = bot;
}
public async Task<string> Execute(CommandString cmd, MessageEventArgs args)
public async Task<string> Execute(CommandString cmd, Update args)
{
if (cmd.Parameters.Length < 1)
return null;
if (!await CommandHelper.CheckAdministrator(bot, args.Message))
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);
var topic = await Db.GetTopicForSub(token, chat);
if (topic == null)
return "topic not found";
else if (topic.Chat == chat)
else if (topic.Sub == chat)
return $"already subscribed to {topic.Name}";
else if (topic.ReadToken != token)
return "token mismatch";
else
{
await db.CreateSubscription(topic.TopicId, chat);
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.Args;
using Telegram.Bot;
using Telegram.Bot.Types;
using JetHerald.Services;
namespace JetHerald.Commands
{
namespace JetHerald.Commands;
public class UnsubscribeCommand : IChatCommand
{
readonly Db db;
readonly TelegramBotClient bot;
Db Db { get; }
TelegramBotClient Bot { get; }
public UnsubscribeCommand(Db db, TelegramBotClient bot)
{
this.db = db;
this.bot = bot;
Db = db;
Bot = bot;
}
public async Task<string> Execute(CommandString cmd, MessageEventArgs messageEventArgs)
public async Task<string> Execute(CommandString cmd, Update update)
{
if (cmd.Parameters.Length < 1)
return null;
if (!await CommandHelper.CheckAdministrator(bot, messageEventArgs.Message))
if (!await CommandHelper.CheckAdministrator(Bot, update.Message))
return null;
var msg = messageEventArgs.Message;
var msg = update.Message;
var chat = NamespacedId.Telegram(msg.Chat.Id);
var topicName = cmd.Parameters[0];
int affected = await db.RemoveSubscription(topicName, chat);
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,22 +1,20 @@
using System;
using System.Text.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using JetHerald.Services;
namespace JetHerald.Controllers;
namespace JetHerald.Controllers
{
[ApiController]
public class HeartbeatController : ControllerBase
{
Db Db { get; }
JetHeraldBot Herald { get; }
LeakyBucket Timeouts { get; }
Options.Timeout Config { get; }
Options.TimeoutConfig Config { get; }
public HeartbeatController(Db db, JetHeraldBot herald, LeakyBucket timeouts, IOptions<Options.Timeout> cfgOptions)
public HeartbeatController(Db db, JetHeraldBot herald, LeakyBucket timeouts, IOptions<Options.TimeoutConfig> cfgOptions)
{
Herald = herald;
Timeouts = timeouts;
@ -79,7 +77,7 @@ namespace JetHerald.Controllers
var affected = await Db.ReportHeartbeat(t.TopicId, heart, args.ExpiryTimeout);
if (affected == 1)
await Herald.HeartbeatSent(t, heart);
await Herald.HeartbeatReceived(t, heart);
Timeouts.ApplyCost(t.TopicId, Config.HeartbeatCost);
@ -94,4 +92,3 @@ namespace JetHerald.Controllers
[JsonPropertyName("WriteToken")] public string WriteToken { get; set; }
}
}
}

View File

@ -1,8 +1,8 @@
using System.Reflection;
using Microsoft.AspNetCore.Mvc;
namespace JetHerald.Controllers
{
namespace JetHerald.Controllers;
[ApiController]
public class HelloController : ControllerBase
{
@ -18,4 +18,3 @@ namespace JetHerald.Controllers
};
}
}
}

View File

@ -1,21 +1,18 @@
using System;
using System.Text.Json;
using System.Threading.Tasks;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using JetHerald.Services;
namespace JetHerald.Controllers
{
namespace JetHerald.Controllers;
[ApiController]
public class ReportController : ControllerBase
{
Db Db { get; }
JetHeraldBot Herald { get; }
LeakyBucket Timeouts { get; }
Options.Timeout Config { get; }
Options.TimeoutConfig Config { get; }
public ReportController(Db db, JetHeraldBot herald, LeakyBucket timeouts, IOptions<Options.Timeout> cfgOptions)
public ReportController(Db db, JetHeraldBot herald, LeakyBucket timeouts, IOptions<Options.TimeoutConfig> cfgOptions)
{
Herald = herald;
Timeouts = timeouts;
@ -41,7 +38,7 @@ namespace JetHerald.Controllers
try
{
var args = await JsonSerializer.DeserializeAsync<ReportArgs>(HttpContext.Request.Body, new()
var args = await JsonSerializer.DeserializeAsync<ReportArgs>(HttpContext.Request.Body, new JsonSerializerOptions()
{
IncludeFields = true
});
@ -78,4 +75,3 @@ namespace JetHerald.Controllers
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">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
@ -9,13 +9,13 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="1.60.6" />
<PackageReference Include="DSharpPlus" Version="4.0.0" />
<PackageReference Include="DSharpPlus.CommandsNext" Version="4.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="2.1.1" />
<PackageReference Include="MySql.Data" Version="8.0.17" />
<PackageReference Include="NLog.Web.AspNetCore" Version="4.8.4" />
<PackageReference Include="Telegram.Bot" Version="14.12.0" />
<PackageReference Include="Dapper" Version="2.0.123" />
<PackageReference Include="DSharpPlus" Version="4.1.0" />
<PackageReference Include="DSharpPlus.CommandsNext" Version="4.1.0" />
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="6.0.1" />
<PackageReference Include="MySql.Data" Version="8.0.28" />
<PackageReference Include="NLog.Web.AspNetCore" Version="5.0.0-rc2" />
<PackageReference Include="Telegram.Bot.Extensions.Polling" Version="1.0.2" />
</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,7 +1,4 @@
using System;
namespace JetHerald
{
namespace JetHerald;
public struct NamespacedId
{
public string Namespace { get; init; }
@ -41,7 +38,3 @@ namespace JetHerald
=> !(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 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.AspNetCore.Builder;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using NLog.Web;
using JetHerald;
using JetHerald.Options;
using JetHerald.Services;
namespace JetHerald
{
public class Program
{
public static void Main(string[] args)
{
DapperConverters.Register();
var log =
#if DEBUG
NLogBuilder.ConfigureNLog("nlog.debug.config").GetCurrentClassLogger();
#else
NLogBuilder.ConfigureNLog("nlog.config").GetCurrentClassLogger();
#endif
var logger = NLog.Web.NLogBuilder.ConfigureNLog("NLog.config").GetCurrentClassLogger();
try
{
logger.Debug("init main");
CreateWebHostBuilder(args).Build().Run();
}
catch (Exception ex)
log.Info("init main");
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureAppConfiguration((hostingContext, config) =>
{
logger.Error(ex, "Stopped program because of exception");
config.SetBasePath(Directory.GetCurrentDirectory());
config.AddIniFile("secrets.ini",
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);
});
builder.Logging.ClearProviders();
builder.Logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Trace);
builder.Host.UseNLog();
var cfg = builder.Configuration;
var services = builder.Services;
services.Configure<ConnectionStrings>(cfg.GetSection("ConnectionStrings"));
services.Configure<JetHerald.Options.TelegramConfig>(cfg.GetSection("Telegram"));
services.Configure<DiscordConfig>(cfg.GetSection("Discord"));
services.Configure<TimeoutConfig>(cfg.GetSection("Timeout"));
services.AddSingleton<Db>();
services.AddSingleton<JetHeraldBot>().AddHostedService(s => s.GetService<JetHeraldBot>());
services.AddSingleton<LeakyBucket>();
services.AddMvc();
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();
}
}
public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
WebHost.CreateDefaultBuilder(args)
.UseStartup<Startup>()
.ConfigureAppConfiguration(config =>
{
config.AddIniFile("secrets.ini");
})
.ConfigureLogging(logging =>
{
logging.ClearProviders();
logging.SetMinimumLevel(LogLevel.Trace);
})
.UseNLog(); // NLog: setup NLog for Dependency injection
}
}

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('/', '_');
}
}
}