mirror of
https://github.com/Jetsparrow/jetherald.git
synced 2026-01-20 23:56:08 +03:00
.
This commit is contained in:
parent
0c74bc4cea
commit
05c491ff0d
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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}";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
27
JetHerald/Contracts/IDb.cs
Normal file
27
JetHerald/Contracts/IDb.cs
Normal 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; }
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
JetHerald/DapperExtensions.cs
Normal file
22
JetHerald/DapperExtensions.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
174
JetHerald/Db.cs
174
JetHerald/Db.cs
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
8
JetHerald/GlobalUsings.cs
Normal file
8
JetHerald/GlobalUsings.cs
Normal 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;
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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}"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
23
JetHerald/Options.cs
Normal 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; }
|
||||||
|
}
|
||||||
@ -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
138
JetHerald/Services/Db.cs
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
117
JetHerald/Services/DiscordCommands.cs
Normal file
117
JetHerald/Services/DiscordCommands.cs
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
46
JetHerald/Services/HeartMonitor.cs
Normal file
46
JetHerald/Services/HeartMonitor.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
JetHerald/Services/JetHeraldBot.Discord.cs
Normal file
36
JetHerald/Services/JetHeraldBot.Discord.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
JetHerald/Services/JetHeraldBot.Telegram.cs
Normal file
79
JetHerald/Services/JetHeraldBot.Telegram.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
JetHerald/Services/JetHeraldBot.cs
Normal file
75
JetHerald/Services/JetHeraldBot.cs
Normal 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}"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
37
JetHerald/Services/LeakyBucket.cs
Normal file
37
JetHerald/Services/LeakyBucket.cs
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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('/', '_');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
17
JetHerald/Utils/TokenHelper.cs
Normal file
17
JetHerald/Utils/TokenHelper.cs
Normal 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('/', '_');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user