Initial release

This commit is contained in:
jetsparrow 2019-08-08 22:19:31 +03:00
commit a8485edcf9
20 changed files with 816 additions and 0 deletions

45
.gitignore vendored Normal file
View File

@ -0,0 +1,45 @@
# Autosave files
*~
# build
[Oo]bj/
[Bb]in/
packages/
TestResults/
# globs
Makefile.in
*.DS_Store
*.sln.cache
*.suo
*.cache
*.pidb
*.userprefs
*.usertasks
config.log
config.make
config.status
aclocal.m4
install-sh
autom4te.cache/
*.user
*.tar.gz
tarballs/
test-results/
Thumbs.db
.vs/
# Mac bundle stuff
*.dmg
*.app
# resharper
*_Resharper.*
*.Resharper
# dotCover
*.dotCover
#secret config
karma.cfg.json
*secrets.ini

25
JetHerald.sln Normal file
View File

@ -0,0 +1,25 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 15
VisualStudioVersion = 15.0.28307.539
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JetHerald", "JetHerald\JetHerald.csproj", "{B48207B2-F0A8-4BD8-AF92-906D128EF152}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{B48207B2-F0A8-4BD8-AF92-906D128EF152}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B48207B2-F0A8-4BD8-AF92-906D128EF152}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B48207B2-F0A8-4BD8-AF92-906D128EF152}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B48207B2-F0A8-4BD8-AF92-906D128EF152}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {A8B3E56D-CF82-4D94-B2CD-396A1AF6718B}
EndGlobalSection
EndGlobal

View File

@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Telegram.Bot.Args;
namespace JetHerald
{
public interface IChatCommand
{
Task<string> Execute(CommandString cmd, MessageEventArgs messageEventArgs);
}
public class ChatCommandRouter
{
string Username { get; }
ILogger Log { get; }
public ChatCommandRouter(string username, ILogger log)
{
Log = log;
Username = username;
}
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;
}
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;
}
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;
}
}
Dictionary<string, IChatCommand> commands = new Dictionary<string, IChatCommand>();
}
}

View File

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

View File

@ -0,0 +1,47 @@
using System.Linq;
using System.Threading.Tasks;
using MySql.Data.MySqlClient;
using Telegram.Bot.Args;
using Telegram.Bot.Types.Enums;
namespace JetHerald.Commands
{
public class CreateTopicCommand : IChatCommand
{
Db db;
public CreateTopicCommand(Db db)
{
this.db = db;
}
public async Task<string> Execute(CommandString cmd, MessageEventArgs messageEventArgs)
{
if (cmd.Parameters.Length < 1)
return null;
var msg = messageEventArgs.Message;
var chatid = msg.Chat.Id;
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(msg.From.Id, name, descr);
return $"created {topic.Name}\n" +
$"readToken\n{topic.ReadToken}\n" +
$"writeToken\n{topic.WriteToken}\n" +
$"adminToken\n{topic.AdminToken}\n";
}
catch (MySqlException myDuplicate) when (myDuplicate.Number == 1062)
{
return $"topic {name} already exists";
}
}
}
}

View File

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

View File

@ -0,0 +1,31 @@
using System.Linq;
using System.Threading.Tasks;
using Telegram.Bot.Args;
namespace JetHerald
{
public class ListCommand : IChatCommand
{
Db db;
public ListCommand(Db db)
{
this.db = db;
}
public async Task<string> Execute(CommandString cmd, MessageEventArgs messageEventArgs)
{
var msg = messageEventArgs.Message;
var chatid = msg.Chat.Id;
var topics = await db.GetTopicsForChat(chatid);
return topics.Any()
? "Topics:\n" + string.Join("\n", topics.Select(GetTopicListing))
: "No subscriptions active.";
}
static string GetTopicListing(Db.Topic t)
=> t.Name == t.Description ? t.Name : $"{t.Name}: {t.Description}";
}
}

View File

@ -0,0 +1,38 @@
using System.Threading.Tasks;
using Telegram.Bot.Args;
namespace JetHerald
{
public class SubscribeCommand : IChatCommand
{
Db db;
public SubscribeCommand(Db db)
{
this.db = db;
}
public async Task<string> Execute(CommandString cmd, MessageEventArgs args)
{
if (cmd.Parameters.Length < 1)
return null;
var chatid = args.Message.Chat.Id;
var token = cmd.Parameters[0];
var topic = await db.GetTopic(token, chatid);
if (topic == null)
return "topic not found";
else if (topic.ChatId == chatid)
return $"already subscribed to {topic.Name}";
else if (topic.ReadToken != token)
return "token mismatch";
else
{
await db.CreateSubscription(topic.TopicId, chatid);
return $"subscribed to {topic.Name}";
}
}
}
}

View File

@ -0,0 +1,30 @@
using System.Threading.Tasks;
using Telegram.Bot.Args;
namespace JetHerald
{
public class UnsubscribeCommand : IChatCommand
{
Db db;
public UnsubscribeCommand(Db db)
{
this.db = db;
}
public async Task<string> Execute(CommandString cmd, MessageEventArgs messageEventArgs)
{
if (cmd.Parameters.Length < 1)
return null;
var msg = messageEventArgs.Message;
var chatid = msg.Chat.Id;
var topicName = cmd.Parameters[0];
int affected = await db.RemoveSubscription(topicName, chatid);
if (affected >= 1)
return $"unsubscribed from {topicName}";
else
return $"could not find subscription for {topicName}";
}
}
}

17
JetHerald/Configs.cs Normal file
View File

@ -0,0 +1,17 @@
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; }
}
}

View File

@ -0,0 +1,42 @@
using System;
using System.Runtime.Serialization;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
namespace JetHerald.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class ReportController : ControllerBase
{
Db Db { get; }
JetHeraldBot Herald { get; }
public ReportController(Db db, JetHeraldBot herald)
{
Herald = herald;
Db = db;
}
[HttpPost]
public async Task<IActionResult> Post([FromBody] 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);
await Herald.PublishMessage(t, args.Message);
return new OkResult();
}
[DataContract]
public class ReportArgs
{
[DataMember] public string Topic { get; set; }
[DataMember] public string Message { get; set; }
[DataMember] public string WriteToken { get; set; }
}
}
}

120
JetHerald/Db.cs Normal file
View File

@ -0,0 +1,120 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using MySql.Data.MySqlClient;
using Dapper;
using Microsoft.Extensions.Options;
using System;
using System.Security.Cryptography;
using System.Transactions;
namespace JetHerald
{
public static class TokenHelper
{
static RNGCryptoServiceProvider rng = new RNGCryptoServiceProvider();
static byte[] buf = new byte[24];
public static string GetToken()
{
rng.GetBytes(buf);
return Convert.ToBase64String(buf).Replace('+', '_').Replace('/','_');
}
}
public class Db
{
public class Topic
{
public uint TopicId { get; set; }
public long CreatorId { 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 long? ChatId { 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> GetTopic(string token, long chatId)
{
using (var c = GetConnection())
return await c.QuerySingleOrDefaultAsync<Topic>(
"SELECT t.*, tc.ChatId " +
"FROM topic t LEFT JOIN topic_chat tc ON t.TopicId = tc.TopicId AND tc.ChatId = @chatId " +
"WHERE ReadToken = @token", new { token, chatId});
}
public async Task<Topic> CreateTopic(long userId, string name, string descr)
{
var t = new Topic
{
CreatorId = userId,
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 " +
" (TopicId, CreatorId, Name, Description, ReadToken, WriteToken, AdminToken) " +
" VALUES " +
" (NULL, @CreatorId, @Name, @Description, @ReadToken, @WriteToken, @AdminToken); " +
" SELECT * FROM topic WHERE TopicId = LAST_INSERT_ID(); ",
t);
}
}
public async Task<IEnumerable<long>> GetChatIdsForTopic(uint topicid)
{
using (var c = GetConnection())
return await c.QueryAsync<long>("SELECT ChatId FROM topic_chat WHERE TopicId = @topicid", new { topicid });
}
public async Task<IEnumerable<Topic>> GetTopicsForChat(long chatid)
{
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.ChatId = @chatid", new { chatid });
}
public async Task CreateSubscription(uint topicId, long chatId)
{
using (var c = GetConnection())
await c.ExecuteAsync("INSERT INTO topic_chat (ChatId, TopicId ) VALUES (@chatId, @topicId)", new { topicId, chatId });
}
public async Task<int> RemoveSubscription(string topicName, long chatId)
{
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.ChatId = @chatId;",
new { topicName, chatId });
}
public Db(IOptions<Options.ConnectionStrings> cfg)
{
Config = cfg;
}
IOptions<Options.ConnectionStrings> Config { get; }
MySqlConnection GetConnection() => new MySqlConnection(Config.Value.DefaultConnection);
}
}

View File

@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>netcoreapp2.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Folder Include="wwwroot\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="1.60.6" />
<PackageReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.AspNetCore.Razor.Design" Version="2.1.2" PrivateAssets="All" />
<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" />
</ItemGroup>
<ItemGroup>
<Content Update="appsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

85
JetHerald/JetHeraldBot.cs Normal file
View File

@ -0,0 +1,85 @@
using System;
using System.Net;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Telegram.Bot;
using Telegram.Bot.Args;
using Telegram.Bot.Types.Enums;
using JetHerald.Commands;
namespace JetHerald
{
public class JetHeraldBot
{
Db Db { get; set; }
Options.Telegram Config { get; }
ILogger<JetHeraldBot> Log { get; }
public JetHeraldBot(Db db, IOptions<Options.Telegram> cfg, ILogger<JetHeraldBot> log)
{
Db = db;
Config = cfg.Value;
Log = log;
}
TelegramBotClient Client { get; set; }
ChatCommandRouter Commands;
Telegram.Bot.Types.User Me { get; set; }
public async Task Init()
{
if (Config.UseProxy)
{
Client = new TelegramBotClient(Config.ApiKey);
}
else
{
var httpProxy = new WebProxy(Config.ProxyUrl)
{ Credentials = new NetworkCredential(Config.ProxyLogin, Config.ProxyPassword) };
Client = new TelegramBotClient(Config.ApiKey, httpProxy);
}
Me = await Client.GetMeAsync();
Commands = new ChatCommandRouter(Me.Username, Log);
Commands.Add(new CreateTopicCommand(Db), "createtopic");
Commands.Add(new DeleteTopicCommand(Db), "deletetopic");
Commands.Add(new SubscribeCommand(Db), "subscribe", "sub");
Commands.Add(new UnsubscribeCommand(Db), "unsubscribe", "unsub");
Commands.Add(new ListCommand(Db), "list");
Client.OnMessage += BotOnMessageReceived;
Client.StartReceiving();
}
public async Task PublishMessage(Db.Topic topic, string message)
{
var chatIds = await Db.GetChatIdsForTopic(topic.TopicId);
var formatted = $"|{topic.Description}|:\n{message}";
foreach (var c in chatIds)
await Client.SendTextMessageAsync(c, formatted);
}
async void BotOnMessageReceived(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 Client.SendTextMessageAsync(
chatId: msg.Chat.Id,
text: reply,
replyToMessageId: msg.MessageId);
}
catch (Exception e)
{
Log.LogError(e, "Exception occured during handling of command: "+ msg.Text);
}
}
}
}

27
JetHerald/NLog.config Normal file
View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
autoReload="true">
<!-- enable asp.net core layout renderers -->
<extensions>
<add assembly="NLog.Web.AspNetCore"/>
</extensions>
<targets>
<target xsi:type="File" name="allfile" fileName="logs\nlog-all-${date:format=yyyy-MM-dd-HH-mm-ss}.log"
layout="${longdate}|${event-properties:item=EventId_Id}|${uppercase:${level}}|${logger}|${message} ${exception:format=tostring}" />
<target name="logconsole" xsi:type="Console" />
</targets>
<!-- rules to map from logger name to target -->
<rules>
<!--All logs, including from Microsoft-->
<logger name="*" minlevel="Trace" writeTo="allfile" />
<logger name="*" minlevel="Trace" writeTo="logconsole" />
<!--Skip non-critical Microsoft logs and so log only own logs-->
<logger name="Microsoft.*" maxlevel="Info" final="true" />
</rules>
</nlog>

49
JetHerald/Program.cs Normal file
View File

@ -0,0 +1,49 @@
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.Extensions.Logging;
using NLog.Web;
namespace JetHerald
{
public class Program
{
public static void Main(string[] args)
{
var logger = NLog.Web.NLogBuilder.ConfigureNLog("nlog.config").GetCurrentClassLogger();
try
{
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) =>
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
}
}

View File

@ -0,0 +1,30 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:57041",
"sslPort": 0
}
},
"profiles": {
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": false,
"launchUrl": "api/values",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"JetHerald": {
"commandName": "Project",
"launchBrowser": false,
"launchUrl": "api/values",
"applicationUrl": "http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

43
JetHerald/Startup.cs Normal file
View File

@ -0,0 +1,43 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
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.AddSingleton<Db>();
services.AddSingleton<JetHeraldBot>();
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
var bot = app.ApplicationServices.GetService<JetHeraldBot>();
bot.Init().Wait();
app.UsePathBase(Configuration.GetValue<string>("PathBase"));
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseMvc();
}
}
}

View File

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information"
}
}
}

View File

@ -0,0 +1,11 @@
{
"Logging": {
"LogLevel": {
"Default": "Warning"
}
},
"Telegram": {
"UseProxy": "false"
},
"PathBase": "/"
}