diff --git a/.gitignore b/.gitignore index e469fdf..10b758b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .vs +.vscode */bin */obj -*.user \ No newline at end of file +*.user diff --git a/3rd-party-licenses.txt b/3rd-party-licenses.txt index 415f1cf..1c82441 100644 --- a/3rd-party-licenses.txt +++ b/3rd-party-licenses.txt @@ -43,31 +43,3 @@ Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. - - -Markdig -- https://github.com/lunet-io/markdig -============================================== - -Copyright (c) 2018, Alexandre Mutel -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification -, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/LICENSE b/LICENSE index ff6b6eb..b6aa777 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2018 bnfour +Copyright (c) 2018, 2020-2023 bnfour Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/WebToTelegramCore/BotCommands/AboutCommand.cs b/WebToTelegramCore/BotCommands/AboutCommand.cs index e55cf42..2560bce 100644 --- a/WebToTelegramCore/BotCommands/AboutCommand.cs +++ b/WebToTelegramCore/BotCommands/AboutCommand.cs @@ -1,8 +1,8 @@ using System; using System.Reflection; - +using WebToTelegramCore.Interfaces; using WebToTelegramCore.Models; -using WebToTelegramCore.Options; +using WebToTelegramCore.Resources; namespace WebToTelegramCore.BotCommands { @@ -11,35 +11,28 @@ namespace WebToTelegramCore.BotCommands /// public class AboutCommand : BotCommandBase, IBotCommand { - /// - /// Template to message, {0} is assembly version. - /// - private const string _template = "**Dotnet Telegram forwarder** v. {0}\n\n" + - "[Open-source!](https://github.com/bnfour/dotnet-telegram-forwarder) " + - "Powered by ASP.NET Core!\n" + - "Written by bnfour, August, October 2018.\n\nN<3"; - /// /// Command's text. /// public override string Command => "/about"; /// - /// Constructor that passed localization options to base class. + /// Constructor. /// - /// Localization options to use. - public AboutCommand(LocalizationOptions locale) : base(locale) { } + public AboutCommand() : base() { } /// /// Method to process the command. /// /// Record associated with user who sent the command. /// Unused here. - /// Text of message that should be returned to user. + /// Text of message that should be returned to user, with '.' escaped for MarkdownV2 public override string Process(Record record) { var version = Assembly.GetExecutingAssembly().GetName().Version; - return base.Process(record) ?? String.Format(_template, version); + // imagine having to escape dot for "markdown" + var prettyVersion = $"{version.Major}\\.{version.Minor}"; + return base.Process(record) ?? String.Format(Locale.About, prettyVersion); } } } diff --git a/WebToTelegramCore/BotCommands/BotCommandBase.cs b/WebToTelegramCore/BotCommands/BotCommandBase.cs index c99df70..924dd31 100644 --- a/WebToTelegramCore/BotCommands/BotCommandBase.cs +++ b/WebToTelegramCore/BotCommands/BotCommandBase.cs @@ -1,6 +1,7 @@ using WebToTelegramCore.Data; +using WebToTelegramCore.Interfaces; using WebToTelegramCore.Models; -using WebToTelegramCore.Options; +using WebToTelegramCore.Resources; namespace WebToTelegramCore.BotCommands { @@ -10,25 +11,15 @@ namespace WebToTelegramCore.BotCommands /// public abstract class BotCommandBase : IBotCommand { - /// - /// Text somewhat explaining why processing of this Record - /// was cancelled in this class. - /// - private readonly string _error; - /// /// Command text; not implemented in abstract classes. /// public abstract string Command { get; } /// - /// Constructor that sets error message. + /// Constructor. /// - /// Locale options to use. - public BotCommandBase(LocalizationOptions locale) - { - _error = locale.ErrorConfirmationPending; - } + public BotCommandBase() { } /// /// Method of abstract base class that filters out users with pending @@ -39,8 +30,9 @@ public BotCommandBase(LocalizationOptions locale) /// or null otherwise. public virtual string Process(Record record) { - return (record != null && record.State != RecordState.Normal) ? - _error : null; + return (!string.IsNullOrEmpty(record.Token) && record.State != RecordState.Normal) + ? Locale.ErrorConfirmationPending + : null; } } } diff --git a/WebToTelegramCore/BotCommands/CancelCommand.cs b/WebToTelegramCore/BotCommands/CancelCommand.cs index ac373ce..fb0e503 100644 --- a/WebToTelegramCore/BotCommands/CancelCommand.cs +++ b/WebToTelegramCore/BotCommands/CancelCommand.cs @@ -1,38 +1,25 @@ using WebToTelegramCore.Data; +using WebToTelegramCore.Interfaces; using WebToTelegramCore.Models; -using WebToTelegramCore.Options; +using WebToTelegramCore.Resources; namespace WebToTelegramCore.BotCommands { /// - /// Class that implements /cancel command. + /// Class that implements /cancel command to cancel pending destructive + /// operations. /// public class CancelCommand : ConfirmationCommandBase, IBotCommand { - /// - /// Reply text when deletion was cancelled. - /// - private readonly string _deletionCancel; - - /// - /// Reply text when regeneration was cancelled. - /// - private readonly string _regenerationCancel; - /// /// Command's text. /// public override string Command => "/cancel"; /// - /// Constructor that sets error message. + /// Constructor. /// - /// Locale options to use. - public CancelCommand(LocalizationOptions locale) : base(locale) - { - _deletionCancel = locale.CancelDeletion; - _regenerationCancel = locale.CancelRegeneration; - } + public CancelCommand() : base() { } /// /// Method to process the command. Resets Record's State back to Normal. @@ -48,8 +35,9 @@ public override string Process(Record record) return baseResult; } - string reply = record.State == RecordState.PendingDeletion ? - _deletionCancel : _regenerationCancel; + string reply = record.State == RecordState.PendingDeletion + ? Locale.CancelDeletion + : Locale.CancelRegeneration; record.State = RecordState.Normal; return reply; diff --git a/WebToTelegramCore/BotCommands/ConfirmCommand.cs b/WebToTelegramCore/BotCommands/ConfirmCommand.cs index 3a87e73..42f224b 100644 --- a/WebToTelegramCore/BotCommands/ConfirmCommand.cs +++ b/WebToTelegramCore/BotCommands/ConfirmCommand.cs @@ -1,28 +1,17 @@ using System; using WebToTelegramCore.Data; +using WebToTelegramCore.Interfaces; using WebToTelegramCore.Models; -using WebToTelegramCore.Options; -using WebToTelegramCore.Services; +using WebToTelegramCore.Resources; namespace WebToTelegramCore.BotCommands { /// - /// Class that implements /cancel command which either deletes user's token or - /// replaces it with a new one. + /// Class that implements /confirm command which either deletes user's token or + /// replaces it with a new one after a request via previous command. /// public class ConfirmCommand : ConfirmationCommandBase, IBotCommand { - /// - /// Message to display when token is deleted. - /// - private readonly string _deletion; - - /// - /// Format string for message about token regeneration. The only argument {0} - /// is a newly generated token. - /// - private readonly string _regenration; - /// /// Command's text. /// @@ -38,20 +27,23 @@ public class ConfirmCommand : ConfirmationCommandBase, IBotCommand /// private readonly ITokenGeneratorService _tokenGenerator; + /// + /// Record manipulation service helper reference. + /// + private readonly IRecordService _recordService; + /// /// Constructor. /// - /// Locale options to use. /// Database context to use. /// Token generator to use. - public ConfirmCommand(LocalizationOptions locale, RecordContext context, - ITokenGeneratorService generator) : base(locale) + /// Record helper to use. + public ConfirmCommand(RecordContext context, ITokenGeneratorService generator, + IRecordService recordService) : base() { _context = context; _tokenGenerator = generator; - - _deletion = locale.ConfirmDeletion; - _regenration = locale.ConfirmRegeneration; + _recordService = recordService; } /// @@ -85,16 +77,18 @@ public override string Process(Record record) private string Regenerate(Record record) { string newToken = _tokenGenerator.Generate(); - // so apparently, primary key cannot be changed - Record newRecord = new Record() - { - AccountNumber = record.AccountNumber, - Token = newToken - }; + // so apparently, primary key cannot be changed, + // create a new record and transfer all data but token and state (it's pending regeneration right now) + var newRecord = _recordService.Create(newToken, record.AccountNumber); + // consider moving these to Create params with default values? + newRecord.UsageCounter = record.UsageCounter; + newRecord.LastSuccessTimestamp = record.LastSuccessTimestamp; + _context.Remove(record); _context.Add(newRecord); _context.SaveChanges(); - return String.Format(_regenration, newToken); + + return String.Format(Locale.ConfirmRegeneration, newToken); } /// @@ -104,9 +98,9 @@ private string Regenerate(Record record) /// Message about performed operation. private string Delete(Record record) { - _context.Records.Remove(record); + _context.Remove(record); _context.SaveChanges(); - return _deletion; + return Locale.ConfirmDeletion; } } } diff --git a/WebToTelegramCore/BotCommands/ConfirmationCommandBase.cs b/WebToTelegramCore/BotCommands/ConfirmationCommandBase.cs index f6299e7..986513a 100644 --- a/WebToTelegramCore/BotCommands/ConfirmationCommandBase.cs +++ b/WebToTelegramCore/BotCommands/ConfirmationCommandBase.cs @@ -1,6 +1,7 @@ using WebToTelegramCore.Data; +using WebToTelegramCore.Interfaces; using WebToTelegramCore.Models; -using WebToTelegramCore.Options; +using WebToTelegramCore.Resources; namespace WebToTelegramCore.BotCommands { @@ -11,25 +12,15 @@ namespace WebToTelegramCore.BotCommands /// public abstract class ConfirmationCommandBase : IBotCommand { - /// - /// Text somewhat explaining why processing of this Record - /// was cancelled in this class. - /// - private readonly string _error; - /// /// Command text; not implemented in abstract classes. /// public abstract string Command { get; } /// - /// Constructor that sets error message. + /// Constructor. /// - /// Locale options to use. - public ConfirmationCommandBase(LocalizationOptions locale) - { - _error = locale.ErrorNoConfirmationPending; - } + public ConfirmationCommandBase() { } /// /// Method of abstract base class that filters out users without pending @@ -40,8 +31,9 @@ public ConfirmationCommandBase(LocalizationOptions locale) /// or null otherwise. public virtual string Process(Record record) { - return (record == null || record.State == RecordState.Normal) ? - _error : null; + return (string.IsNullOrEmpty(record.Token) || record.State == RecordState.Normal) + ? Locale.ErrorNoConfirmationPending + : null; } } } diff --git a/WebToTelegramCore/BotCommands/CreateCommand.cs b/WebToTelegramCore/BotCommands/CreateCommand.cs index 1bd104e..0c96faa 100644 --- a/WebToTelegramCore/BotCommands/CreateCommand.cs +++ b/WebToTelegramCore/BotCommands/CreateCommand.cs @@ -1,7 +1,7 @@ using System; +using WebToTelegramCore.Interfaces; using WebToTelegramCore.Models; -using WebToTelegramCore.Options; -using WebToTelegramCore.Services; +using WebToTelegramCore.Resources; namespace WebToTelegramCore.BotCommands { @@ -15,16 +15,6 @@ public class CreateCommand : GuestOnlyCommandBase, IBotCommand /// public override string Command => "/create"; - /// - /// Message to display on token creation. Must be formatted, {0} is token. - /// - private readonly string _message; - - /// - /// Message to display when registration is off. - /// - private readonly string _goAway; - /// /// Field to store database context reference. /// @@ -35,15 +25,10 @@ public class CreateCommand : GuestOnlyCommandBase, IBotCommand /// private readonly ITokenGeneratorService _generator; - // I'm bad at computer programming: - // the existing "architecture" always passes nulls as Records if user have - // no record in DB yet. That means it's impossible to create a user :( - // so here we go - // TODO: do something better /// - /// ID of account that sent the command. + /// Record manipulation service helper reference. /// - public long? Crutch { get; set; } + private readonly IRecordService _recordService; /// /// Field to store whether registration is enabled. True is enabled. @@ -53,19 +38,18 @@ public class CreateCommand : GuestOnlyCommandBase, IBotCommand /// /// Constructor that injects dependencies and sets up registration state. /// - /// Locale options to use. /// Database context to use. /// Token generator service to use. + /// Record helper service to use. /// State of registration. - public CreateCommand(LocalizationOptions locale, RecordContext context, - ITokenGeneratorService generator, bool isRegistrationEnabled) : base(locale) + public CreateCommand(RecordContext context, ITokenGeneratorService generator, + IRecordService recordService, bool isRegistrationEnabled) : base() { _context = context; _generator = generator; - _isRegistrationEnabled = isRegistrationEnabled; + _recordService = recordService; - _message = locale.CreateSuccess; - _goAway = locale.CreateGoAway; + _isRegistrationEnabled = isRegistrationEnabled; } /// @@ -81,27 +65,22 @@ public override string Process(Record record) /// /// Actual method that does registration or denies it. /// - /// Record to process. _Must be null_. + /// Record to process. Is null if working properly. /// Message with new token or message stating that registration /// is closed for good. private string InternalProcess(Record record) { - // record being null is enforced by base calls. - if (!Crutch.HasValue) - { - throw new ApplicationException("Crutch is not set before calling"); - } if (_isRegistrationEnabled) { string token = _generator.Generate(); - Record r = new Record() { AccountNumber = Crutch.Value, Token = token }; + var r = _recordService.Create(token, record.AccountNumber); _context.Add(r); _context.SaveChanges(); - return String.Format(_message, token); + return String.Format(Locale.CreateSuccess, token); } else { - return _goAway; + return Locale.CreateGoAway; } } } diff --git a/WebToTelegramCore/BotCommands/DeleteCommand.cs b/WebToTelegramCore/BotCommands/DeleteCommand.cs index aecd6a3..74eddad 100644 --- a/WebToTelegramCore/BotCommands/DeleteCommand.cs +++ b/WebToTelegramCore/BotCommands/DeleteCommand.cs @@ -1,6 +1,7 @@ using WebToTelegramCore.Data; +using WebToTelegramCore.Interfaces; using WebToTelegramCore.Models; -using WebToTelegramCore.Options; +using WebToTelegramCore.Resources; namespace WebToTelegramCore.BotCommands { @@ -15,16 +16,6 @@ public class DeleteCommand : UserOnlyCommandBase, IBotCommand /// public override string Command => "/delete"; - /// - /// Message that confirms deletion is now pending. - /// - private readonly string _message; - - /// - /// Additional warning shown when registration is turned off. - /// - private readonly string _noTurningBack; - /// /// Boolean that indicates whether this instance is accepting new users. /// True if it does. @@ -34,15 +25,10 @@ public class DeleteCommand : UserOnlyCommandBase, IBotCommand /// /// Constructor. /// - /// Locale options to use. /// Registration state. True is enabled. - public DeleteCommand(LocalizationOptions locale, bool registrationEnabled) - : base(locale) + public DeleteCommand(bool registrationEnabled) : base() { _registrationEnabled = registrationEnabled; - - _message = locale.DeletionPending; - _noTurningBack = locale.DeletionNoTurningBack; } /// @@ -63,7 +49,9 @@ public override string Process(Record record) private string InternalProcess(Record record) { record.State = RecordState.PendingDeletion; - return _registrationEnabled ? _message : _message + "\n\n" + _noTurningBack; + return _registrationEnabled + ? Locale.DeletionPending + : Locale.DeletionPending + "\n\n" + Locale.DeletionNoTurningBack; } } } diff --git a/WebToTelegramCore/BotCommands/DirectiveCommand.cs b/WebToTelegramCore/BotCommands/DirectiveCommand.cs index 92d7296..d8390e1 100644 --- a/WebToTelegramCore/BotCommands/DirectiveCommand.cs +++ b/WebToTelegramCore/BotCommands/DirectiveCommand.cs @@ -1,5 +1,5 @@ -using WebToTelegramCore.Models; -using WebToTelegramCore.Options; +using WebToTelegramCore.Interfaces; +using WebToTelegramCore.Models; namespace WebToTelegramCore.BotCommands { @@ -24,8 +24,7 @@ public class DirectiveCommand : BotCommandBase, IBotCommand /// Constructor that does literally nothing yet required due to my "superb" /// planning skills. /// - /// Locale options to use. - public DirectiveCommand(LocalizationOptions locale) : base(locale) { } + public DirectiveCommand() : base() { } /// /// Method to process the command. diff --git a/WebToTelegramCore/BotCommands/GuestOnlyCommandBase.cs b/WebToTelegramCore/BotCommands/GuestOnlyCommandBase.cs index 93c5a9a..8e6ca04 100644 --- a/WebToTelegramCore/BotCommands/GuestOnlyCommandBase.cs +++ b/WebToTelegramCore/BotCommands/GuestOnlyCommandBase.cs @@ -1,5 +1,6 @@ -using WebToTelegramCore.Models; -using WebToTelegramCore.Options; +using WebToTelegramCore.Interfaces; +using WebToTelegramCore.Models; +using WebToTelegramCore.Resources; namespace WebToTelegramCore.BotCommands { @@ -9,27 +10,17 @@ namespace WebToTelegramCore.BotCommands /// public abstract class GuestOnlyCommandBase : BotCommandBase, IBotCommand { - /// - /// Text somewhat explaining why processing of this Record - /// was cancelled in this class. - /// - private readonly string _error; - /// /// Constructor that sets up error message. /// /// Locale options to use. - public GuestOnlyCommandBase(LocalizationOptions locale) : base(locale) - { - _error = locale.ErrorMustBeGuest; - } + public GuestOnlyCommandBase() : base() { } /// /// Method of abstract base class that adds filtering out users - /// with no associated token. - /// + /// with no associated token. /// Record to process. - /// Error message if there is an operation pending or user has no token, + /// Error message if there is an operation pending or user has a token, /// or null otherwise. public new virtual string Process(Record record) { @@ -40,11 +31,12 @@ public GuestOnlyCommandBase(LocalizationOptions locale) : base(locale) /// Method that filters out users with tokens. /// /// Record to process. - /// Error message if user has a token, - /// or null otherwise. + /// Error message if user has a token, or null otherwise. private string InternalProcess(Record record) { - return record != null ? _error : null; + return string.IsNullOrEmpty(record.Token) + ? null + : Locale.ErrorMustBeGuest; } } } diff --git a/WebToTelegramCore/BotCommands/HelpCommand.cs b/WebToTelegramCore/BotCommands/HelpCommand.cs index 37e40a5..f699884 100644 --- a/WebToTelegramCore/BotCommands/HelpCommand.cs +++ b/WebToTelegramCore/BotCommands/HelpCommand.cs @@ -1,5 +1,6 @@ -using WebToTelegramCore.Models; -using WebToTelegramCore.Options; +using WebToTelegramCore.Interfaces; +using WebToTelegramCore.Models; +using WebToTelegramCore.Resources; namespace WebToTelegramCore.BotCommands { @@ -14,18 +15,9 @@ public class HelpCommand : BotCommandBase, IBotCommand public override string Command => "/help"; /// - /// Helpful help message to send. + /// Constructor. /// - private readonly string _message; - - /// - /// Constructor that sets up message. - /// - /// Locale options to use. - public HelpCommand(LocalizationOptions locale) : base(locale) - { - _message = locale.Help; - } + public HelpCommand() : base() { } /// /// Method to process the command. @@ -36,7 +28,7 @@ public HelpCommand(LocalizationOptions locale) : base(locale) /// corresponding error message otherwise. public override string Process(Record record) { - return base.Process(record) ?? _message; + return base.Process(record) ?? Locale.Help; } } } diff --git a/WebToTelegramCore/BotCommands/RegenerateCommand.cs b/WebToTelegramCore/BotCommands/RegenerateCommand.cs index 1d7e4a9..b5e6931 100644 --- a/WebToTelegramCore/BotCommands/RegenerateCommand.cs +++ b/WebToTelegramCore/BotCommands/RegenerateCommand.cs @@ -1,6 +1,7 @@ using WebToTelegramCore.Data; +using WebToTelegramCore.Interfaces; using WebToTelegramCore.Models; -using WebToTelegramCore.Options; +using WebToTelegramCore.Resources; namespace WebToTelegramCore.BotCommands { @@ -16,18 +17,9 @@ public class RegenerateCommand : UserOnlyCommandBase, IBotCommand public override string Command => "/regenerate"; /// - /// Message that confirms regeneration is now pending. + /// Constructor. /// - private readonly string _message; - - /// - /// Constructor that sets up message. - /// - /// Locale options to use. - public RegenerateCommand(LocalizationOptions locale) : base(locale) - { - _message = locale.RegenerationPending; - } + public RegenerateCommand() : base() { } /// /// Method to process the command. @@ -47,7 +39,7 @@ public override string Process(Record record) private string InternalProcess(Record record) { record.State = RecordState.PendingRegeneration; - return _message; + return Locale.RegenerationPending; } } } diff --git a/WebToTelegramCore/BotCommands/StartCommand.cs b/WebToTelegramCore/BotCommands/StartCommand.cs index 535dbd2..e98947f 100644 --- a/WebToTelegramCore/BotCommands/StartCommand.cs +++ b/WebToTelegramCore/BotCommands/StartCommand.cs @@ -1,5 +1,6 @@ -using WebToTelegramCore.Models; -using WebToTelegramCore.Options; +using WebToTelegramCore.Interfaces; +using WebToTelegramCore.Models; +using WebToTelegramCore.Resources; namespace WebToTelegramCore.BotCommands { @@ -8,21 +9,6 @@ namespace WebToTelegramCore.BotCommands /// public class StartCommand : BotCommandBase, IBotCommand { - /// - /// Fisrt part of response to the command which is always displayed. - /// - private readonly string _startMessage; - - /// - /// Additional text to display when registration is open. - /// - private readonly string _registrationHint; - - /// - /// Additional text to display when registration is closed. - /// - private readonly string _noRegistration; - /// /// Field to store current state of registartion of new users. /// @@ -36,17 +22,11 @@ public class StartCommand : BotCommandBase, IBotCommand /// /// Constructor. /// - /// Locale options to use. /// Boolean indicating whether registration /// is enabled (true) or not. - public StartCommand(LocalizationOptions locale, bool registrationEnabled) - : base(locale) + public StartCommand(bool registrationEnabled) : base() { _isRegistrationOpen = registrationEnabled; - - _startMessage = locale.StartMessage; - _noRegistration = locale.StartGoAway; - _registrationHint = locale.StartRegistrationHint; } /// @@ -58,8 +38,8 @@ public StartCommand(LocalizationOptions locale, bool registrationEnabled) /// corresponding error message otherwise. public override string Process(Record record) { - string appendix = _isRegistrationOpen ? _registrationHint : _noRegistration; - return base.Process(record) ?? _startMessage + "\n\n" + appendix; + string appendix = _isRegistrationOpen ? Locale.StartRegistrationHint : Locale.StartGoAway; + return base.Process(record) ?? Locale.StartMessage + "\n\n" + appendix; } } } diff --git a/WebToTelegramCore/BotCommands/TokenCommand.cs b/WebToTelegramCore/BotCommands/TokenCommand.cs index 75beec9..1e02253 100644 --- a/WebToTelegramCore/BotCommands/TokenCommand.cs +++ b/WebToTelegramCore/BotCommands/TokenCommand.cs @@ -1,7 +1,8 @@ using System; -using System.Collections.Generic; +using WebToTelegramCore.Helpers; +using WebToTelegramCore.Interfaces; using WebToTelegramCore.Models; -using WebToTelegramCore.Options; +using WebToTelegramCore.Resources; namespace WebToTelegramCore.BotCommands { @@ -18,12 +19,12 @@ public class TokenCommand : UserOnlyCommandBase, IBotCommand /// /// Random quotes to display as message example. /// - private readonly List _examples = new List() + private readonly string[] _examples = new[] { "Hello world!", "Timeline lost", "send help", - "`inhale`", + "inhale", "KNCA KYKY", "Hey Red", "Powered by .NET!", @@ -33,7 +34,10 @@ public class TokenCommand : UserOnlyCommandBase, IBotCommand "Is it banana time yet?", "Try again later", "More than two and less than four", - "Of course I still love you" + "Of course I still love you", + "それは何?", + "There was nothing to be sad about", + "I never asked for this" }; /// @@ -41,29 +45,13 @@ public class TokenCommand : UserOnlyCommandBase, IBotCommand /// private readonly string _apiEndpoint; - /// - /// Template for reply with three formatters: {0} is token, {1} is API endpoint, - /// {2} is random vanity message example. - /// - private readonly string _templateOne; - - /// - /// Message explaining Response structure and roles of its fields. - /// - private readonly string _errors; - /// /// Constructor. /// - /// Locale options to use. /// API endpoint URL. - public TokenCommand(LocalizationOptions locale, string apiEndpoint) - : base(locale) + public TokenCommand(string apiEndpoint) : base() { _apiEndpoint = apiEndpoint; - - _templateOne = locale.TokenTemplate; - _errors = locale.TokenErrorsDescription; } /// @@ -84,9 +72,9 @@ public override string Process(Record record) /// Message with token and API usage example. private string InternalProcess(Record record) { - string text = _examples[new Random().Next(0, _examples.Count)]; - return String.Format(_templateOne, record.Token, _apiEndpoint, text) - + "\n\n" + _errors; + var text = _examples[new Random().Next(0, _examples.Length)]; + return String.Format(Locale.TokenTemplate, TelegramMarkdownFormatter.Escape(record.Token), + TelegramMarkdownFormatter.Escape(_apiEndpoint + "/api"), TelegramMarkdownFormatter.Escape(text)); } } } diff --git a/WebToTelegramCore/BotCommands/UserOnlyCommandBase.cs b/WebToTelegramCore/BotCommands/UserOnlyCommandBase.cs index 0d54cf1..90c038a 100644 --- a/WebToTelegramCore/BotCommands/UserOnlyCommandBase.cs +++ b/WebToTelegramCore/BotCommands/UserOnlyCommandBase.cs @@ -1,5 +1,6 @@ -using WebToTelegramCore.Models; -using WebToTelegramCore.Options; +using WebToTelegramCore.Interfaces; +using WebToTelegramCore.Models; +using WebToTelegramCore.Resources; namespace WebToTelegramCore.BotCommands { @@ -9,17 +10,11 @@ namespace WebToTelegramCore.BotCommands /// public abstract class UserOnlyCommandBase : BotCommandBase, IBotCommand { + /// - /// Text somewhat explaining why processing of this Record - /// was cancelled in this class. + /// Constructor. /// - private readonly string _error; - - - public UserOnlyCommandBase(LocalizationOptions locale) : base(locale) - { - _error = locale.ErrorMustBeUser; - } + public UserOnlyCommandBase() : base() { } /// /// Method of abstract base class that adds filtering out users @@ -41,7 +36,7 @@ public UserOnlyCommandBase(LocalizationOptions locale) : base(locale) /// or null otherwise. private string InternalProcess(Record record) { - return record == null ? _error : null; + return string.IsNullOrEmpty(record.Token) ? Locale.ErrorMustBeUser : null; } } } diff --git a/WebToTelegramCore/Controllers/TelegramApiController.cs b/WebToTelegramCore/Controllers/TelegramApiController.cs new file mode 100644 index 0000000..0d61d5f --- /dev/null +++ b/WebToTelegramCore/Controllers/TelegramApiController.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.Mvc; +using System.Net; +using System.Threading.Tasks; +using Telegram.Bot.Types; +using WebToTelegramCore.Interfaces; + +namespace WebToTelegramCore.Controllers +{ + /// + /// Controller that handles both web API and telegram API calls, + /// since they're both POST with JSON bodies. + /// + public class TelegramApiController : Controller + { + /// + /// Field to store injected Telegram API service. + /// + private ITelegramApiService _tgApi; + + /// + /// Constructor with dependency injection. + /// + /// Web API service instance to use. + /// Telegram API service instance to use. + public TelegramApiController(ITelegramApiService tgApi) + { + _tgApi = tgApi; + } + + // POST /api/{bot token} + /// + /// Handles webhook calls. + /// + /// Token which is used as part of endpoint url + /// to verify request's origin. + /// Update to handle. + /// 404 Not Found on wrong tokens, 200 OK otherwise, + /// unless there is an internal server error. + [HttpPost, Route("api/{token}")] + public async Task HandleTelegramApi(string token, [FromBody] Update update) + { + if (!_tgApi.IsToken(token)) + { + return NotFound(); + } + try + { + await _tgApi.HandleUpdate(update); + return Ok(); + } + catch + { + return StatusCode((int)HttpStatusCode.InternalServerError); + } + } + } +} diff --git a/WebToTelegramCore/Controllers/WebAndTelegramApisController.cs b/WebToTelegramCore/Controllers/WebAndTelegramApisController.cs deleted file mode 100644 index 25675b0..0000000 --- a/WebToTelegramCore/Controllers/WebAndTelegramApisController.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc; -using Telegram.Bot.Types; - -using WebToTelegramCore.Models; -using WebToTelegramCore.Services; - -namespace WebToTelegramCore.Controllers -{ - /// - /// Controller that handles both web API and telegram API calls, - /// since they're both POST with JSON bodies. - /// - public class WebAndTelegramApisController : Controller - { - /// - /// Field to store injected web API service. - /// - private IOwnApiService _ownApi; - - /// - /// Field to store injected Telegram API service. - /// - private ITelegramApiService _tgApi; - - /// - /// Constructor with dependency injection. - /// - /// Web API service instance to use. - /// Telegram API service instance to use. - public WebAndTelegramApisController(IOwnApiService ownApi, - ITelegramApiService tgApi) - { - _ownApi = ownApi; - _tgApi = tgApi; - } - - // POST /api - /// - /// Handles web API calls. - /// - /// Request object in POST request body. - /// 400 Bad request on malformed Requests, - /// 200 OK with corresponding Response otherwise. - [HttpPost, Route("api")] - public ActionResult HandleWebApi([FromBody] Request request) - { - // silently deny malformed requests - if (!ModelState.IsValid) - { - return BadRequest(); - } - return _ownApi.HandleRequest(request); - } - - // POST /api/{bot token} - /// - /// Handles webhook calls. - /// - /// Token which is used as part of endpoint url - /// to verify request's origin. - /// Update to handle. - /// 400 Bad Request on wrong tokens, 200 OK otherwise. - [HttpPost, Route("api/{token}")] - public ActionResult HandleTelegramApi(string token, [FromBody] Update update) - { - if (!_tgApi.IsToken(token)) - { - return BadRequest(); - } - _tgApi.HandleUpdate(update); - return Ok(); - } - } -} diff --git a/WebToTelegramCore/Controllers/WebApiController.cs b/WebToTelegramCore/Controllers/WebApiController.cs new file mode 100644 index 0000000..d5a51eb --- /dev/null +++ b/WebToTelegramCore/Controllers/WebApiController.cs @@ -0,0 +1,61 @@ +using Microsoft.AspNetCore.Mvc; +using System.Net; +using System.Threading.Tasks; +using WebToTelegramCore.Exceptions; +using WebToTelegramCore.Interfaces; +using WebToTelegramCore.Models; + +namespace WebToTelegramCore.Controllers +{ + public class WebApiController : Controller + { + /// + /// Field to store injected web API service. + /// + private readonly IOwnApiService _ownApi; + + public WebApiController(IOwnApiService ownApi) + { + _ownApi = ownApi; + } + + // POST /api + /// + /// Handles web API calls. + /// + /// Request object in POST request body. + /// HTTP status code result indicating whether the request was handled + /// successfully, or one of the error codes. + [HttpPost, Route("api")] + public async Task HandleWebApi([FromBody] Request request) + { + // deny malformed requests + if (!ModelState.IsValid) + { + return BadRequest(); + } + try + { + await _ownApi.HandleRequest(request); + return Ok(); + } + catch (TokenNotFoundException) + { + return NotFound(); + } + catch (BandwidthExceededException) + { + return StatusCode((int)HttpStatusCode.TooManyRequests); + } + // if the formatting is malformed, relay Telegram's "bad request" to the user + catch (Telegram.Bot.Exceptions.ApiRequestException ex) when (ex.Message.StartsWith("Bad Request")) + { + return BadRequest(); + } + catch + { + return StatusCode((int)HttpStatusCode.InternalServerError); + } + } + } +} diff --git a/WebToTelegramCore/Data/MessageParsingType.cs b/WebToTelegramCore/Data/MessageParsingType.cs new file mode 100644 index 0000000..741c894 --- /dev/null +++ b/WebToTelegramCore/Data/MessageParsingType.cs @@ -0,0 +1,20 @@ +namespace WebToTelegramCore.Data +{ + /// + /// Represents available parsing modes for the user message + /// to be sent via Telegram's API. + /// + public enum MessageParsingType + { + /// + /// Text as is, no formatting. + /// + Plaintext, + /// + /// Text with some formatting to be displayed. + /// Please note that this is Telegram's own "MarkdownV2" flavour, + /// see https://core.telegram.org/bots/api#markdownv2-style for reference. + /// + Markdown + } +} diff --git a/WebToTelegramCore/Data/ResponseState.cs b/WebToTelegramCore/Data/ResponseState.cs deleted file mode 100644 index 3739576..0000000 --- a/WebToTelegramCore/Data/ResponseState.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace WebToTelegramCore.Data -{ - /// - /// Represents various web API calls outcomes. - /// - public enum ResponseState - { - /// - /// Request accepted. No further action by client needed. - /// - OkSent, - /// - /// Token not found. Client should not retry. - /// - NoSuchToken, - /// - /// Too many messages at a time. Client should retry later. - /// - BandwidthExceeded, - /// - /// An exception occured. Client may or may not retry later. - /// - SomethingBadHappened - } -} diff --git a/WebToTelegramCore/Exceptions/BandwidthExceededException.cs b/WebToTelegramCore/Exceptions/BandwidthExceededException.cs new file mode 100644 index 0000000..521a79d --- /dev/null +++ b/WebToTelegramCore/Exceptions/BandwidthExceededException.cs @@ -0,0 +1,11 @@ +using System; + +namespace WebToTelegramCore.Exceptions +{ + /// + /// Exception that is thrown by + /// when used have exceeded their allowed bandwidth. + /// Handled in the + /// + public class BandwidthExceededException : Exception { } +} diff --git a/WebToTelegramCore/Exceptions/TokenNotFoundException.cs b/WebToTelegramCore/Exceptions/TokenNotFoundException.cs new file mode 100644 index 0000000..1372c67 --- /dev/null +++ b/WebToTelegramCore/Exceptions/TokenNotFoundException.cs @@ -0,0 +1,11 @@ +using System; + +namespace WebToTelegramCore.Exceptions +{ + /// + /// Exception that is thrown by + /// when user-supplied token does not match any registered in the database. + /// Handled in the + /// + public class TokenNotFoundException : Exception { } +} diff --git a/WebToTelegramCore/FormatterHelpers/MarkdownTransformer.cs b/WebToTelegramCore/FormatterHelpers/MarkdownTransformer.cs deleted file mode 100644 index a7ac799..0000000 --- a/WebToTelegramCore/FormatterHelpers/MarkdownTransformer.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System.Collections.Generic; -using System.Text.RegularExpressions; - -namespace WebToTelegramCore.FormatterHelpers -{ - /// - /// Class that converts Telegram's flavor of Markdown to CommonMark - /// understood by Markdig. - /// - public class MarkdownTransformer - { - /// - /// List of sequences to escape. Contains single asterisk and underscore, - /// as these are not formatting and should be left as-is. - /// - private static readonly List _toEscape = new List() - { - // * in regexes should be escaped - @"\*", - "_" - }; - - /// - /// List of regexes that will escape symbols from _toEscape. - /// - private readonly List _regexes = new List(); - - /// - /// Constructor that sets up used regexes. - /// - public MarkdownTransformer() - { - foreach (var p in _toEscape) - { - var r = new Regex($@"([^{p}])({p})([^{p}])"); - _regexes.Add(r); - } - } - - /// - /// Method to convert Telegram's markdown to CommonMark. - /// - /// String to convert. - /// String with converted formatting. - public string ToCommonMark(string TgMarkdown) - { - string result = TgMarkdown; - // escape single "*" and "_" - foreach (var regex in _regexes) - { - result = regex.Replace(result, @"$1\$2$3"); - } - - result = result - // fix italics - .Replace("__", "*") - // ensure triple backticks are on their own line to prevent misparsing - .Replace("```", "\n```\n"); - - return result; - } - } -} diff --git a/WebToTelegramCore/FormatterHelpers/NonNestedHtmlLinkInlineRenderer.cs b/WebToTelegramCore/FormatterHelpers/NonNestedHtmlLinkInlineRenderer.cs deleted file mode 100644 index 8e5bfef..0000000 --- a/WebToTelegramCore/FormatterHelpers/NonNestedHtmlLinkInlineRenderer.cs +++ /dev/null @@ -1,80 +0,0 @@ -using Markdig.Renderers; -using Markdig.Syntax.Inlines; - -namespace WebToTelegramCore.FormatterHelpers -{ - /// - /// HTML renderer for Markdig's LinkInline that does not put any formatting - /// inside the link tag. - /// - public class NonNestedHtmlLinkInlineRenderer - : Markdig.Renderers.Html.Inlines.LinkInlineRenderer - { - /// - /// Method to render inline link. - /// - /// Renderer to use. - /// Link to render. - protected override void Write(HtmlRenderer renderer, LinkInline link) - { - if (renderer.EnableHtmlForInline) - { - renderer.Write(link.IsImage ? "\"");"); - } - } - else - { - if (renderer.EnableHtmlForInline) - { - if (AutoRelNoFollow) - { - renderer.Write(" rel=\"nofollow\""); - } - renderer.Write(">"); - } - // this block of code was the sole reason of re-implemening this method - // Telegram's API docs forbid nested HTML tags. Better safe than sorry. - var wasEnableHtmlForInline = renderer.EnableHtmlForInline; - renderer.EnableHtmlForInline = false; - renderer.WriteChildren(link); - renderer.EnableHtmlForInline = wasEnableHtmlForInline; - if (renderer.EnableHtmlForInline) - { - renderer.Write(""); - } - } - } - } -} diff --git a/WebToTelegramCore/FormatterHelpers/TweakedCodeBlockRenderer.cs b/WebToTelegramCore/FormatterHelpers/TweakedCodeBlockRenderer.cs deleted file mode 100644 index 8dccb3a..0000000 --- a/WebToTelegramCore/FormatterHelpers/TweakedCodeBlockRenderer.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Markdig.Renderers; -using Markdig.Syntax; - -namespace WebToTelegramCore.FormatterHelpers -{ - /// - /// Class that renders multiline code blocks inside <pre> tags only - /// as requested by Telegram's API docs, - /// opposed to default <pre> and <code>. - /// - public class TweakedCodeBlockRenderer : Markdig.Renderers.Html.CodeBlockRenderer - { - /// - /// Method to render the code block. - /// - /// Renderer to use. - /// Code block to render. - protected override void Write(HtmlRenderer renderer, CodeBlock obj) - { - renderer.EnsureLine(); - // all code related to
was wiped out as it's unused here anyway - if (renderer.EnableHtmlForBlock) - { - renderer.Write(""); - } - - renderer.WriteLeafRawLines(obj, true, true); - - if (renderer.EnableHtmlForBlock) - { - renderer.WriteLine(""); - } - - renderer.EnsureLine(); - - } - } -} diff --git a/WebToTelegramCore/Helpers/TelegramMarkdownFormatter.cs b/WebToTelegramCore/Helpers/TelegramMarkdownFormatter.cs new file mode 100644 index 0000000..b4213ea --- /dev/null +++ b/WebToTelegramCore/Helpers/TelegramMarkdownFormatter.cs @@ -0,0 +1,31 @@ +namespace WebToTelegramCore.Helpers +{ + /// + /// Static helper to make not hardcoded texts suitable for sending. + /// + public static class TelegramMarkdownFormatter + { + /// + /// List of characters to escape in order to satisfy the API. + /// + private readonly static string[] _toEscape = new[] + { + "_", "*", "[", "]", "(", ")", "~", "`", ">", + "#", "+", "-", "=", "|", "{", "}", ".", "!" + }; + + /// + /// Escapes the dangerous symbols in the string. + /// + /// String to process. + /// Telegram Markdown v2 friendly string. + public static string Escape(string s) + { + foreach (var c in _toEscape) + { + s = s.Replace(c, @"\" + c); + } + return s; + } + } +} diff --git a/WebToTelegramCore/BotCommands/IBotCommand.cs b/WebToTelegramCore/Interfaces/IBotCommand.cs similarity index 82% rename from WebToTelegramCore/BotCommands/IBotCommand.cs rename to WebToTelegramCore/Interfaces/IBotCommand.cs index ea1d100..a9ed703 100644 --- a/WebToTelegramCore/BotCommands/IBotCommand.cs +++ b/WebToTelegramCore/Interfaces/IBotCommand.cs @@ -1,6 +1,6 @@ using WebToTelegramCore.Models; -namespace WebToTelegramCore.BotCommands +namespace WebToTelegramCore.Interfaces { /// /// Interface to implement various bot commands without arguments. @@ -17,8 +17,8 @@ public interface IBotCommand /// /// Method to process received message. /// - /// Record associated with user who sent teh command, - /// or null if user has no Record (have not received the token). + /// Record associated with user who sent the command, + /// or a mock Record with everything but account id set to default values. /// Message to send back to user. Markdown is supported. string Process(Record record); } diff --git a/WebToTelegramCore/Services/IOwnApiService.cs b/WebToTelegramCore/Interfaces/IOwnApiService.cs similarity index 60% rename from WebToTelegramCore/Services/IOwnApiService.cs rename to WebToTelegramCore/Interfaces/IOwnApiService.cs index d08acd7..f8d5150 100644 --- a/WebToTelegramCore/Services/IOwnApiService.cs +++ b/WebToTelegramCore/Interfaces/IOwnApiService.cs @@ -1,6 +1,7 @@ -using WebToTelegramCore.Models; +using System.Threading.Tasks; +using WebToTelegramCore.Models; -namespace WebToTelegramCore.Services +namespace WebToTelegramCore.Interfaces { /// /// Interface to implement non-Telegram web API handling. @@ -11,7 +12,6 @@ public interface IOwnApiService /// Method to handle incoming request from the web API. /// /// Request to handle. - /// Response to the request, ready to be returned to client. - Response HandleRequest(Request request); + Task HandleRequest(Request request); } } diff --git a/WebToTelegramCore/Interfaces/IRecordService.cs b/WebToTelegramCore/Interfaces/IRecordService.cs new file mode 100644 index 0000000..a2275d5 --- /dev/null +++ b/WebToTelegramCore/Interfaces/IRecordService.cs @@ -0,0 +1,28 @@ +using WebToTelegramCore.Models; + +namespace WebToTelegramCore.Interfaces +{ + /// + /// Interface that makes managing records look easy for its users. + /// + public interface IRecordService + { + /// + /// Creates a new Record, setting common default values + /// for all instances. + /// + /// Token to create Record with. + /// Account id to create token with. + /// Record with all properties populated. + Record Create(string token, long accountId); + + /// + /// Checks if Record holds enough charges to be able to send a message + /// immediately ( > 0). If so, returns true, + /// and updates Record's state to indicate the message was sent just now. + /// + /// Record to check and possibly update. + /// True if message can and should be sent, false otherwise. + bool CheckIfCanSend(Record record); + } +} diff --git a/WebToTelegramCore/Services/ITelegramApiService.cs b/WebToTelegramCore/Interfaces/ITelegramApiService.cs similarity index 81% rename from WebToTelegramCore/Services/ITelegramApiService.cs rename to WebToTelegramCore/Interfaces/ITelegramApiService.cs index 6467854..a7a0aea 100644 --- a/WebToTelegramCore/Services/ITelegramApiService.cs +++ b/WebToTelegramCore/Interfaces/ITelegramApiService.cs @@ -1,6 +1,7 @@ -using Update = Telegram.Bot.Types.Update; +using System.Threading.Tasks; +using Update = Telegram.Bot.Types.Update; -namespace WebToTelegramCore.Services +namespace WebToTelegramCore.Interfaces { /// /// Interface to implement Telegram webhook handling. @@ -19,6 +20,6 @@ public interface ITelegramApiService /// Method to handle incoming updates from the webhook. /// /// Received update. - void HandleUpdate(Update update); + Task HandleUpdate(Update update); } } diff --git a/WebToTelegramCore/Interfaces/ITelegramBotService.cs b/WebToTelegramCore/Interfaces/ITelegramBotService.cs new file mode 100644 index 0000000..8fb0c02 --- /dev/null +++ b/WebToTelegramCore/Interfaces/ITelegramBotService.cs @@ -0,0 +1,44 @@ +using System.Threading.Tasks; +using WebToTelegramCore.Data; + +namespace WebToTelegramCore.Interfaces +{ + /// + /// Interface that exposes greatly simplified version of Telegram bot API. + /// The only method allows sending of text messages. + /// + public interface ITelegramBotService + { + /// + /// Sends text message. Markdown formatting should be supported. + /// + /// ID of account to send message to. + /// Text of the message. + /// Flag to set whether to suppress the notification. + /// Like the API defaults to false for bot responses, which are sent immediately after user's message. + /// Formatting type used in the message. + /// Unlike the API, defaults for Markdown for bot responses, which do use some formatting. + Task Send(long accountId, string message, bool silent = false, MessageParsingType parsingType = MessageParsingType.Markdown); + + /// + /// Sends a predefined sticker. Used as an easter egg with a 5% chance + /// of message on unknown user input to be the sticker instead of text. + /// + /// ID of account to send sticker to. + Task SendTheSticker(long accountId); + + /// + /// Method to manage external webhook for the Telegram API. + /// Part one: called to set the webhook on application start. + /// + Task SetExternalWebhook(); + + /// + /// Method to manage external webhook for the Telegram API. + /// Part two: called to remove the webhook gracefully on application exit. + /// Not that it makes much sense in an app designed to be run 24/7, but is + /// nice to have nonetheless. + /// + Task ClearExternalWebhook(); + } +} diff --git a/WebToTelegramCore/Services/ITokenGeneratorService.cs b/WebToTelegramCore/Interfaces/ITokenGeneratorService.cs similarity index 92% rename from WebToTelegramCore/Services/ITokenGeneratorService.cs rename to WebToTelegramCore/Interfaces/ITokenGeneratorService.cs index 25294dd..3fabe6e 100644 --- a/WebToTelegramCore/Services/ITokenGeneratorService.cs +++ b/WebToTelegramCore/Interfaces/ITokenGeneratorService.cs @@ -1,4 +1,4 @@ -namespace WebToTelegramCore.Services +namespace WebToTelegramCore.Interfaces { /// /// Interface to services that generate auth tokens. diff --git a/WebToTelegramCore/Models/Record.cs b/WebToTelegramCore/Models/Record.cs index b850fb3..f1d6cee 100644 --- a/WebToTelegramCore/Models/Record.cs +++ b/WebToTelegramCore/Models/Record.cs @@ -9,16 +9,6 @@ namespace WebToTelegramCore.Models /// public class Record { - /// - /// Maximum possible amount of messages available immidiately. - /// - private static int _counterMax; - - /// - /// Boolean indicating whether max value from config was set. - /// - private static bool _maxSet = false; - /// /// Auth token associated with this record. Primary key in the DB. /// @@ -30,32 +20,9 @@ public class Record public long AccountNumber { get; set; } /// - /// Backing field of UsageCounter property. + /// Holds amount of messages available immidiately. /// - private int _counter; - - /// - /// Holds amount of messages available immidiately, prevents over- and underflows. - /// - public int UsageCounter - { - get => _counter; - set - { - if (value <= 0) - { - _counter = 0; - } - else if (value >= _counterMax) - { - _counter = _counterMax; - } - else - { - _counter = value; - } - } - } + public int UsageCounter { get; set; } /// /// Timestamp of last successful request. Used to calculate how much to add @@ -68,37 +35,5 @@ public int UsageCounter /// on destructive command. /// public RecordState State { get; set; } - - /// - /// Constructor that sets up default values for properties not stored in the DB. - /// - public Record() - { - if (!_maxSet) - { - throw new ApplicationException("No default maximum count value was set."); - } - _counter = _counterMax; - LastSuccessTimestamp = DateTime.Now; - State = RecordState.Normal; - } - - /// - /// Sets maximum value once. Throws exception when value is already set. - /// - /// Value to set. - public static void SetMaxValue(int value) - { - if (!_maxSet) - { - _counterMax = value; - _maxSet = true; - } - else - { - throw new ApplicationException("Record's maximum count value " + - "was already set."); - } - } } } diff --git a/WebToTelegramCore/Models/Request.cs b/WebToTelegramCore/Models/Request.cs index 95c9e1a..922702e 100644 --- a/WebToTelegramCore/Models/Request.cs +++ b/WebToTelegramCore/Models/Request.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using System.ComponentModel.DataAnnotations; +using WebToTelegramCore.Data; namespace WebToTelegramCore.Models { @@ -19,5 +22,20 @@ public class Request /// [Required, StringLength(4096)] public string Message { get; set; } + + /// + /// Optional parameter to suppress the notification for the + /// message from the bot. If the to true, message will be silent. + /// Note that it's possible for the end used to mute the bot so + /// this setting will have no effect. + /// + public bool Silent { get; set; } = false; + + /// + /// Optional parameter to set parsing mode of the message. + /// Defaults to plaintext if not specified. + /// + [JsonConverter(typeof(StringEnumConverter))] + public MessageParsingType Type { get; set; } = MessageParsingType.Plaintext; } } diff --git a/WebToTelegramCore/Models/Response.cs b/WebToTelegramCore/Models/Response.cs deleted file mode 100644 index 88249a6..0000000 --- a/WebToTelegramCore/Models/Response.cs +++ /dev/null @@ -1,38 +0,0 @@ -using ResponseState = WebToTelegramCore.Data.ResponseState; - -namespace WebToTelegramCore.Models -{ - /// - /// Represents web API response to user. - /// Also in JSON, just like the initial request. - /// - public class Response - { - /// - /// Quick indicator of whether request was accepted. - /// - public bool Ok { get; private set; } - - /// - /// Machine-readable error code. - /// - public int Code { get; private set; } - - /// - /// Human-readable description of error. - /// - public string Details { get; private set; } - - /// - /// Class constructor. - /// - /// ResponseState field values are based on. - /// Human-readable message corresponding to state. - public Response(ResponseState state, string details) - { - Ok = state == ResponseState.OkSent; - Code = (int)state; - Details = details; - } - } -} diff --git a/WebToTelegramCore/Options/LocalizationOptions.cs b/WebToTelegramCore/Options/LocalizationOptions.cs deleted file mode 100644 index 8001d1d..0000000 --- a/WebToTelegramCore/Options/LocalizationOptions.cs +++ /dev/null @@ -1,141 +0,0 @@ -namespace WebToTelegramCore.Options -{ - /// - /// Class that holds all customizable string the bot may reply with. - /// Also, descriptions of error codes. - /// - public class LocalizationOptions - { - /// - /// Message to show when token deletion was cancelled. - /// - public string CancelDeletion { get; set; } - - /// - /// Message to show when token regeneration was cancelled. - /// - public string CancelRegeneration { get; set; } - - /// - /// Message to show when token deletion was completed. - /// - public string ConfirmDeletion { get; set; } - - /// - /// Template for message to show when token regeneration was completed. - /// {0} is new token. - /// - public string ConfirmRegeneration { get; set; } - - /// - /// Message to reply to /create with when registration is disabled. - /// - public string CreateGoAway { get; set; } - - /// - /// Template for message to show when token was created successfully. - /// {0} is new token. - /// - public string CreateSuccess { get; set; } - - /// - /// Message to show when user initiated token deletion and registration is off. - /// - public string DeletionNoTurningBack { get; set; } - - /// - /// Message to show when user initiated token deletion. - /// - public string DeletionPending { get; set; } - - /// - /// Message to show when confirmation is pending and user tries to use - /// non-confirming command. - /// - public string ErrorConfirmationPending { get; set; } - - /// - /// Message to show to unknown commands starting with slash. - /// - public string ErrorDave { get; set; } - - /// - /// Message to show when usage of command requires no token set, - /// but user has one. - /// - public string ErrorMustBeGuest { get; set; } - - /// - /// Message to show when usage of command requires a token, but user has none. - /// - public string ErrorMustBeUser { get; set; } - - /// - /// Message to show when user is trying to use confirming command, but no - /// confirmation is pending. - /// - public string ErrorNoConfirmationPending { get; set; } - - /// - /// Message to show on unknown input. - /// - public string ErrorWhat { get; set; } - - /// - /// Output of /help command. - /// - public string Help { get; set; } - - /// - /// Message to show when user initiated token regeneration. - /// - public string RegenerationPending { get; set; } - - /// - /// Human-readable representation of ResponseState.BandwidthExceeded enum member. - /// - public string RequestBandwidthExceeded { get; set; } - - /// - /// Human-readable representation of ResponseState.NoSuchToken enum member. - /// - public string RequestNoToken { get; set; } - - /// - /// Human-readable representation of ResponseState.OkSent enum member. - /// - public string RequestOk { get; set; } - - /// - /// Human-readable representation of ResponseState.SomethingBadHappened - /// enum member. - /// - public string RequestWhat { get; set; } - - /// - /// Message to warn new users that registration is closed. - /// - public string StartGoAway { get; set; } - - /// - /// Message to show when user first engages the bot. - /// - public string StartMessage { get; set; } - - /// - /// Message to encourage new users to register. - /// - public string StartRegistrationHint { get; set; } - - /// - /// Helper text that explains web API output. - /// - public string TokenErrorsDescription { get; set; } - - /// - /// Template for message to reply to /token command with. - /// {0} is token, {1} is API endpoint URL, {2} is vanity quote. - /// - public string TokenTemplate { get; set; } - } -} diff --git a/WebToTelegramCore/Program.cs b/WebToTelegramCore/Program.cs index aef82ae..01b4068 100644 --- a/WebToTelegramCore/Program.cs +++ b/WebToTelegramCore/Program.cs @@ -1,31 +1,57 @@ -using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using System; +using WebToTelegramCore.Interfaces; namespace WebToTelegramCore { public class Program { - public static void Main(string[] args) { - CreateWebHostBuilder(args).Build().Run(); - } - - public static IWebHostBuilder CreateWebHostBuilder(string[] args) - { - // hardcoded default + // hardcoded default, because 8080 and 8081 were already taken on my machine int port = 8082; - if (args.Length == 2 && args[0].Equals("--port") - && System.Int32.TryParse(args[1], out int nonDefPort)) + if (args.Length == 2 && args[0].Equals("--port") && int.TryParse(args[1], out int nonDefPort)) { port = nonDefPort; } - return WebHost.CreateDefaultBuilder(args) - .UseKestrel() - .UseUrls($"http://localhost:{port}") - .UseStartup(); + var builder = WebApplication.CreateBuilder(new WebApplicationOptions + { + // quick fix to prevent using solution folder for configuration files + // instead of built binary folder, where these files are overridden with juicy secrets + ContentRootPath = AppContext.BaseDirectory + }); + + // this is used to force the code to use database from the current (e.g output for debug) directory + // instead of using the file in the project root every launch + + // please note that this value ends with backslash, so in the connection string, + // file name goes straight after |DataDirectory|, no slashes of any kind + AppDomain.CurrentDomain.SetData("DataDirectory", AppContext.BaseDirectory); + + builder.WebHost.UseKestrel(kestrelOptions => + { + // this won't work without a SSL proxy over it anyway, + // so listening to localhost only + kestrelOptions.ListenLocalhost(port); + }); + + // carrying over legacy startup-based configuration (for now?) + var startup = new Startup(builder.Configuration); + startup.ConfigureServices(builder.Services); + + var app = builder.Build(); + app.MapControllers(); + + app.Lifetime.ApplicationStarted.Register(async () => + await (app.Services.GetService(typeof(ITelegramBotService)) as ITelegramBotService).SetExternalWebhook()); + + app.Lifetime.ApplicationStopped.Register(async () => + await (app.Services.GetService(typeof(ITelegramBotService)) as ITelegramBotService).ClearExternalWebhook()); + + app.Run(); } } } diff --git a/WebToTelegramCore/Properties/PublishProfiles/FolderProfile.pubxml b/WebToTelegramCore/Properties/PublishProfiles/FolderProfile.pubxml deleted file mode 100644 index df979ca..0000000 --- a/WebToTelegramCore/Properties/PublishProfiles/FolderProfile.pubxml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - FileSystem - FileSystem - Release - Any CPU - - True - False - netcoreapp2.1 - 2.1.3 - linux-x64 - 09c905a4-e83d-42f1-9f7c-299082194b74 - false - <_IsPortable>true - bin\Debug\netcoreapp2.1\publish\ - True - - \ No newline at end of file diff --git a/WebToTelegramCore/Properties/launchSettings.json b/WebToTelegramCore/Properties/launchSettings.json index dadd404..1a8f352 100644 --- a/WebToTelegramCore/Properties/launchSettings.json +++ b/WebToTelegramCore/Properties/launchSettings.json @@ -1,8 +1,4 @@ { - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true - }, "$schema": "http://json.schemastore.org/launchsettings.json", "profiles": { "WebToTelegramCore": { diff --git a/WebToTelegramCore/RecordContext.cs b/WebToTelegramCore/RecordContext.cs index aaa37e1..0c0e154 100644 --- a/WebToTelegramCore/RecordContext.cs +++ b/WebToTelegramCore/RecordContext.cs @@ -1,7 +1,5 @@ using Microsoft.EntityFrameworkCore; -using System; -using System.Linq; - +using System.Threading.Tasks; using WebToTelegramCore.Models; namespace WebToTelegramCore @@ -44,18 +42,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) ///
/// Token to search for in the DB. /// Associated Record or null if none found. - public Record GetRecordByToken(string token) + public async Task GetRecordByToken(string token) { - Record ret; - try - { - ret = Records.Single(r => r.Token.Equals(token)); - } - catch (InvalidOperationException) - { - return null; - } - return ret; + return await Records.SingleOrDefaultAsync(r => r.Token.Equals(token)); } /// @@ -63,18 +52,9 @@ public Record GetRecordByToken(string token) /// /// ID to search for. /// Associated Record or null if none present. - public Record GetRecordByAccountId(long accountId) + public async Task GetRecordByAccountId(long accountId) { - Record ret; - try - { - ret = Records.Single(r => r.AccountNumber == accountId); - } - catch (InvalidOperationException) - { - return null; - } - return ret; + return await Records.SingleOrDefaultAsync(r => r.AccountNumber == accountId); } } } diff --git a/WebToTelegramCore/Resources/Locale.cs b/WebToTelegramCore/Resources/Locale.cs new file mode 100644 index 0000000..05fc594 --- /dev/null +++ b/WebToTelegramCore/Resources/Locale.cs @@ -0,0 +1,212 @@ +namespace WebToTelegramCore.Resources +{ + /// + /// Class that holds all customizable string the bot may reply with. + /// Also, descriptions of error codes. + /// Not ideal, but slightly better (is it?) than holding these in appsettings.json. + /// + + // Please note that these should be formatted as Telegram-flavoured Markdown + // see https://core.telegram.org/bots/api#markdownv2-style + // Templating parameters (like {0}) are filled in before sending to the API, + // so { and } in these should NOT be escaped. But the actual text in these should be. + + public static class Locale + { + /// + /// Template for reply for /about command. {0} is assembly version. + /// + public const string About = """ + **Dotnet Telegram forwarder** v {0}\. + + [Open\-source\!](https://github.com/bnfour/dotnet-telegram-forwarder) + by bnfour, 2018, 2020\-2023\. + """; + + /// + /// Message to show when token deletion was cancelled. + /// + public const string CancelDeletion = """ + Token deletion cancelled\. + """; + + /// + /// Message to show when token regeneration was cancelled. + /// + public const string CancelRegeneration = """ + Token regeneration cancelled\. + """; + + /// + /// Message to show when token deletion was completed. + /// + public const string ConfirmDeletion = """ + Token deleted\. + Thank you for giving us an oppurtunity to serve you\. + """; + + /// + /// Template for message to show when token regeneration was completed. + /// {0} is new token. + /// + public const string ConfirmRegeneration = """ + Your new token is + + `{0}` + + Don't forget to update your clients' settings\. + """; + + /// + /// Message to reply to /create with when registration is disabled. + /// + public const string CreateGoAway = """ + This instance of the bot is not accepting new users for now\. + """; + + /// + /// Template for message to show when token was created successfully. + /// {0} is new token. + /// + public const string CreateSuccess = """ + Success\! Your token is: + + `{0}` + + Please consult /token for usage\. + """; + + /// + /// Message to show when user initiated token deletion and registration is off. + /// + public const string DeletionNoTurningBack = """ + This bot has registration turned *off*\. You won't be able to create new token\. Please be certain\. + """; + + /// + /// Message to show when user initiated token deletion. + /// + public const string DeletionPending = """ + *Token deletion pending\!* + + Please either /confirm or /cancel it\. This cannot be undone\. + If you need to change your token, consider to /regenerate it instead of deleting and re\-creating\. + """; + + /// + /// Message to show when confirmation is pending and user tries to use + /// non-confirming command. + /// + public const string ErrorConfirmationPending = """ + You have an operation pending confirmation\. Please /confirm or /cancel it before using other commands\. + """; + + /// + /// Message to show to unknown commands starting with slash. + /// + public const string ErrorDave = """ + I'm afraid I can't let you do that\. + """; + + /// + /// Message to show when usage of command requires no token set, + /// but user has one. + /// + public const string ErrorMustBeGuest = """ + In order to use this command, you must have no token associated with your account\. You can /delete your existing one, but why? + """; + + /// + /// Message to show when usage of command requires a token, but user has none. + /// + public const string ErrorMustBeUser = """ + In order to use this command, you must have a token associated with your account\. /create one\. + """; + + /// + /// Message to show when user is trying to use confirming command, but no + /// confirmation is pending. + /// + public const string ErrorNoConfirmationPending = """ + This command is only useful when you're trying to /delete or /regenerate your token\. + """; + + /// + /// Message to show on unknown input. + /// + public const string ErrorWhat = """ + Unfortunately, I'm not sure how to interpret this\. + """; + + /// + /// Output of /help command. + /// + public const string Help = """ + This app provides a web API to route messages from API's endpoint to you in Telegram via this bot\. It can be used to notify you in Telegram from any Internet\-enabled device you want \(provided you know how to make POST requests from it\)\. + To start, /create your token\. You can /delete it anytime\. To change your token for whatever reason, please /regenerate it and not delete and re\-create\. + Once you have a token, see /token for additional usage help\. + + Other commands supported by bot include: + \- /confirm and /cancel are used to prevent accidental deletions and regenerations; + \- /help displays this message; + \- /about displays general info about this bot\. + + \-\-bnfour + """; + + /// + /// Message to show when user initiated token regeneration. + /// + public const string RegenerationPending = """ + *Token regenration pending\!* + + Please either /confirm or /cancel it\. It cannot be undone\. Please be certain\. + """; + + /// + /// Message to warn new users that registration is closed. + /// + public const string StartGoAway = """ + Sorry, this instance of bot is not accepting new users for now\. + """; + + /// + /// Message to show when user first engages the bot. + /// + public const string StartMessage = """ + Hello\! + + This bot provides a standalone web API to relay messages from anything that can send web requests to Telegram as messages from the bot\. + + *Please note*: this requires some external tools and knowledge\. If you consider "Send a POST request" a magic gibberish you don't understand, this bot probably isn't much of use to you\. + """; + + /// + /// Message to encourage new users to register. + /// + public const string StartRegistrationHint = """ + If that does not stop you, feel free to /create your very own token\. + """; + + /// + /// Template for message to reply to /token command with. + /// {0} is token, {1} is API endpoint URL, {2} is vanity quote. + /// + // double braces are escaping for formatting + public const string TokenTemplate = """ + Your token is + + `{0}` + + *Usage:* To deliver a message, send a POST request to {1} with JSON body\. The payload must contain two parameters: your token and your message\. There are also optional parameters, please consult [the documentation](https://github.com/bnfour/dotnet-telegram-forwarder#web-api) for details\. Example of a payload: + ``` + {{ + "token": "{0}", + "message": "{2}" + }} + ``` + + If everything is okay, the API will return a blank 200 OK response\. If something is not okay, a different status code will be returned\. Consult [the documentation](https://github.com/bnfour/dotnet-telegram-forwarder#web-api) to see error code list\. + """; + } +} diff --git a/WebToTelegramCore/Services/FormatterService.cs b/WebToTelegramCore/Services/FormatterService.cs deleted file mode 100644 index b159676..0000000 --- a/WebToTelegramCore/Services/FormatterService.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System.IO; -using System.Linq; -using WebToTelegramCore.FormatterHelpers; - -namespace WebToTelegramCore.Services -{ - /// - /// Class that converts Markdown to HTML. - /// - public class FormatterService : IFormatterService - { - /// - /// Instance of MarkdownTransformer to use. - /// - private readonly MarkdownTransformer _transformer = new MarkdownTransformer(); - - /// - /// Transforms Markdown to HTML. Expects Markdown like Telegram clients do: - /// **bold** and __italic__ are main differences from the CommonMark. - /// - /// Markdown-formatted text. - /// Text with same format but in HTML supported by Telegram's API. - public string TransformToHtml(string TgFlavoredMarkdown) - { - // TODO think how to reuse these - TextWriter writer = new StringWriter(); - var renderer = new Markdig.Renderers.HtmlRenderer(writer); - - renderer = new Markdig.Renderers.HtmlRenderer(writer) - { - ImplicitParagraph = true - }; - - // replacing default renderes with our "fixed" versions - var toRemove = renderer.ObjectRenderers.Where(x => - x.GetType() == typeof(Markdig.Renderers.Html.CodeBlockRenderer) || - x.GetType() == typeof(Markdig.Renderers.Html.Inlines.LinkInlineRenderer)) - .ToList(); - foreach (var r in toRemove) - { - renderer.ObjectRenderers.Remove(r); - } - renderer.ObjectRenderers.Add(new NonNestedHtmlLinkInlineRenderer()); - renderer.ObjectRenderers.Add(new TweakedCodeBlockRenderer()); - - // setting up rendering pipeline - var pipeline = new Markdig.MarkdownPipelineBuilder().Build(); - pipeline.Setup(renderer); - - // actually rendering the HTML - var cm = _transformer.ToCommonMark(TgFlavoredMarkdown); - var doc = Markdig.Parsers.MarkdownParser.Parse(cm); - renderer.Render(doc); - writer.Flush(); - var formatted = writer.ToString(); - formatted = formatted - .Replace("
", " "); - return formatted; - } - } -} diff --git a/WebToTelegramCore/Services/IFormatterService.cs b/WebToTelegramCore/Services/IFormatterService.cs deleted file mode 100644 index cc2e5da..0000000 --- a/WebToTelegramCore/Services/IFormatterService.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace WebToTelegramCore.Services -{ - /// - /// Interface that exposes method to convert Markdown flavor that used in - /// Telegram clients to HTML. - /// - public interface IFormatterService - { - /// - /// Transforms Markdown to HTML. Expects Markdown like Telegram clients do: - /// **bold** and __italic__ are main differences from the CommonMark. - /// - /// Markdown-formatted text. - /// Text with same format but in HTML supported by Telegram's API. - string TransformToHtml(string TgFlavoredMarkdown); - } -} diff --git a/WebToTelegramCore/Services/ITelegramBotService.cs b/WebToTelegramCore/Services/ITelegramBotService.cs deleted file mode 100644 index 279327b..0000000 --- a/WebToTelegramCore/Services/ITelegramBotService.cs +++ /dev/null @@ -1,31 +0,0 @@ -namespace WebToTelegramCore.Services -{ - /// - /// Interface that exposes greatly simplified version of Telegram bot API. - /// The only method allows sending of text messages. - /// - public interface ITelegramBotService - { - /// - /// Sends text message. Markdown formatting should be supported. - /// - /// ID of account to send message to. - /// Text of the message. - void Send(long accountId, string message); - - /// - /// Sends a predefined sticker. Used as an easter egg with a 5% chance - /// of message on unknown user input to be the sticker instead of text. - /// - /// ID of account to send sticker to. - void SendTheSticker(long accountId); - - /// - /// Sends message in CommonMark as Markdown. Used only internally as a crutch - /// to display properly formatteded pre-defined messages. HTML breaks them :( - /// - /// ID of account to send message to. - /// Text of the message. - void SendPureMarkdown(long accountId, string message); - } -} diff --git a/WebToTelegramCore/Services/OwnApiService.cs b/WebToTelegramCore/Services/OwnApiService.cs index 3398e9d..f8c128a 100644 --- a/WebToTelegramCore/Services/OwnApiService.cs +++ b/WebToTelegramCore/Services/OwnApiService.cs @@ -1,10 +1,7 @@ -using Microsoft.Extensions.Options; -using System; -using System.Collections.Generic; - -using WebToTelegramCore.Data; +using System.Threading.Tasks; +using WebToTelegramCore.Exceptions; +using WebToTelegramCore.Interfaces; using WebToTelegramCore.Models; -using WebToTelegramCore.Options; namespace WebToTelegramCore.Services { @@ -13,11 +10,6 @@ namespace WebToTelegramCore.Services ///
public class OwnApiService : IOwnApiService { - /// - /// Amount of seconds lince last successful API call to regenerate counter. - /// - private readonly int _secondsPerRegen; - /// /// Field to store app's database context. /// @@ -29,9 +21,9 @@ public class OwnApiService : IOwnApiService private readonly ITelegramBotService _bot; /// - /// Holds string representations of various response results. + /// Field to store Record management service. /// - private readonly Dictionary _details; + private readonly IRecordService _recordService; /// /// Constuctor that injects dependencies. @@ -39,79 +31,28 @@ public class OwnApiService : IOwnApiService /// Database context to use. /// Bot service to use. /// Bandwidth options. - /// Localization options. - public OwnApiService(RecordContext context, ITelegramBotService bot, - IOptions options, IOptions locale) + public OwnApiService(RecordContext context, ITelegramBotService bot, IRecordService recordService) { _context = context; _bot = bot; - - _secondsPerRegen = options.Value.SecondsPerRegeneration; - - - LocalizationOptions locOptions = locale.Value; - _details = new Dictionary() - { - [ResponseState.OkSent] = locOptions.RequestOk, - [ResponseState.BandwidthExceeded] = locOptions.RequestBandwidthExceeded, - [ResponseState.NoSuchToken] = locOptions.RequestNoToken, - [ResponseState.SomethingBadHappened] = locOptions.RequestWhat - }; + _recordService = recordService; } /// - /// Public method to handle incoming requests. Call underlying internal method - /// and wraps its output into a Response. + /// Public method to handle incoming requests. Call underlying internal method. /// /// Request to handle. - /// Response to the request, ready to be returned to client. - public Response HandleRequest(Request request) + public async Task HandleRequest(Request request) { - ResponseState result = HandleRequestInternally(request); - return new Response(result, _details[result]); - } - - /// - /// Internal method to handle requests. - /// - /// Request to handle. - /// ResponseState indicatig result of the request processing. - private ResponseState HandleRequestInternally(Request request) - { - // wrapping all these into a big try clause - // returning ResponseState.SomethingBadHappened in catch is silly - // but how else API can be made "robust"? - // TODO: think about role of ResponseState.SomethingBadHappened - var record = _context.GetRecordByToken(request.Token); - if (record == null) - { - return ResponseState.NoSuchToken; - } - UpdateRecordCounter(record); - if (record.UsageCounter > 0) + var record = await _context.GetRecordByToken(request.Token) ?? throw new TokenNotFoundException(); + if (_recordService.CheckIfCanSend(record)) { - record.LastSuccessTimestamp = DateTime.Now; - record.UsageCounter--; - _bot.Send(record.AccountNumber, request.Message); - return ResponseState.OkSent; + await _bot.Send(record.AccountNumber, request.Message, request.Silent, request.Type); } else { - return ResponseState.BandwidthExceeded; + throw new BandwidthExceededException(); } } - - /// - /// Updates Record's usage counter based on time passed since last successful - /// message delivery. - /// - /// Record to update. - private void UpdateRecordCounter(Record record) - { - TimeSpan sinceLastSuccess = DateTime.Now - record.LastSuccessTimestamp; - int toAdd = (int)(sinceLastSuccess.TotalSeconds / _secondsPerRegen); - // overflows are handled in the property's setter - record.UsageCounter += toAdd; - } } } diff --git a/WebToTelegramCore/Services/RecordService.cs b/WebToTelegramCore/Services/RecordService.cs new file mode 100644 index 0000000..75ce038 --- /dev/null +++ b/WebToTelegramCore/Services/RecordService.cs @@ -0,0 +1,78 @@ +using Microsoft.Extensions.Options; +using System; +using WebToTelegramCore.Data; +using WebToTelegramCore.Interfaces; +using WebToTelegramCore.Models; +using WebToTelegramCore.Options; + +namespace WebToTelegramCore.Services +{ + /// + /// Class that makes managing Records look easy for its users. + /// hide_the_pain_harold.jpg + /// + public class RecordService : IRecordService + { + /// + /// Maximum possible amount of messages available immidiately. + /// + private readonly int _counterMax; + + /// + /// Amount of seconds lince last successful API call to regenerate counter. + /// + private readonly TimeSpan _timeToRegen; + + /// + /// Constructor that gets message bandwiths settings for later use. + /// + /// Options to use. + public RecordService(IOptions options) + { + _counterMax = options.Value.InitialCount; + _timeToRegen = TimeSpan.FromSeconds(options.Value.SecondsPerRegeneration); + } + + /// + /// Checks if Record holds enough charges to be able to send a message + /// immediately ( > 0). If so, returns true, + /// and updates Record's state to indicate the message was sent just now. + /// + /// Record to check and possibly update. + /// True if message can and should be sent, false otherwise. + public bool CheckIfCanSend(Record record) + { + var sinceLastSuccess = DateTime.UtcNow - record.LastSuccessTimestamp; + var toAdd = (int)(sinceLastSuccess / _timeToRegen); + + record.UsageCounter = Math.Min(_counterMax, record.UsageCounter + toAdd); + + if (record.UsageCounter > 0) + { + record.LastSuccessTimestamp = DateTime.UtcNow; + record.UsageCounter--; + return true; + } + return false; + } + + /// + /// Creates a new Record, setting common default values + /// for all instances. + /// + /// Token to create Record with. + /// Account id to create token with. + /// Record with all properties populated. + public Record Create(string token, long accountId) + { + return new Record + { + AccountNumber = accountId, + LastSuccessTimestamp = DateTime.UtcNow, + State = RecordState.Normal, + Token = token, + UsageCounter = _counterMax + }; + } + } +} diff --git a/WebToTelegramCore/Services/TelegramApiService.cs b/WebToTelegramCore/Services/TelegramApiService.cs index cb36f12..200ab74 100644 --- a/WebToTelegramCore/Services/TelegramApiService.cs +++ b/WebToTelegramCore/Services/TelegramApiService.cs @@ -2,12 +2,15 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using Telegram.Bot.Types; using Telegram.Bot.Types.Enums; using WebToTelegramCore.BotCommands; +using WebToTelegramCore.Interfaces; using WebToTelegramCore.Models; using WebToTelegramCore.Options; +using WebToTelegramCore.Resources; namespace WebToTelegramCore.Services { @@ -37,79 +40,58 @@ public class TelegramApiService : ITelegramApiService private readonly ITokenGeneratorService _generator; /// - /// List of commands available to the bot. - /// - private readonly List _commands; - - /// - /// /create handler, as it requires special treating since i'm bad at programming. - /// - private readonly CreateCommand _thatOneCommand; - - /// - /// Indicates whether usage of /create command is enabled. - /// - private readonly bool _isRegistrationOpen; - - /// - /// Message to reply with when input is starting with slash, but none of the - /// commands fired in response. + /// Record manipulation service helper reference. /// - private readonly string _invalidCommandReply; + private readonly IRecordService _recordService; /// - /// Message to reply with when input isn't even resembles a command. + /// List of commands available to the bot. /// - private readonly string _invalidReply; + private readonly List _commands; /// /// Constructor that injects dependencies and configures list of commands. /// /// Options that include token. - /// Localization options. /// Database context to use. /// Bot service instance to use. /// Token generator service to use. - public TelegramApiService(IOptions options, - IOptions locale, RecordContext context, - ITelegramBotService bot, ITokenGeneratorService generator) + /// Record helper service to use. + public TelegramApiService(IOptions options, + RecordContext context, ITelegramBotService bot, + ITokenGeneratorService generator, IRecordService recordService) { _token = options.Value.Token; _context = context; _bot = bot; _generator = generator; + _recordService = recordService; - _isRegistrationOpen = options.Value.RegistrationEnabled; - - LocalizationOptions locOptions = locale.Value; - - _invalidCommandReply = locOptions.ErrorDave; - _invalidReply = locOptions.ErrorWhat; + var isRegistrationOpen = options.Value.RegistrationEnabled; _commands = new List() { - new StartCommand(locOptions, _isRegistrationOpen), - new TokenCommand(locOptions, options.Value.ApiEndpointUrl), - new RegenerateCommand(locOptions), - new DeleteCommand(locOptions, _isRegistrationOpen), - new ConfirmCommand(locOptions, _context, _generator), - new CancelCommand(locOptions), - new HelpCommand(locOptions), - new DirectiveCommand(locOptions), - new AboutCommand(locOptions) - + new StartCommand(isRegistrationOpen), + new TokenCommand(options.Value.ApiEndpointUrl), + new RegenerateCommand(), + new DeleteCommand(isRegistrationOpen), + new ConfirmCommand(_context, _generator, _recordService), + new CancelCommand(), + new HelpCommand(), + new DirectiveCommand(), + new AboutCommand(), + new CreateCommand(_context, _generator, _recordService, isRegistrationOpen) }; - _thatOneCommand = new CreateCommand(locOptions, _context, _generator, - _isRegistrationOpen); } /// /// Method to handle incoming updates from the webhook. /// /// Received update. - public void HandleUpdate(Update update) + public async Task HandleUpdate(Update update) { - // a few sanity checks + // a few sanity checks: + // only handles text messages, hopefully commands if (update.Message.Type != MessageType.Text) { return; @@ -117,32 +99,27 @@ public void HandleUpdate(Update update) long? userId = update?.Message?.From?.Id; string text = update?.Message?.Text; - - if (userId == null || String.IsNullOrEmpty(text)) + // and the update contains everything we need to process it + if (userId == null || string.IsNullOrEmpty(text)) { return; } - // null check was done above, it's safe to use userId.Value directly - Record record = _context.GetRecordByAccountId(userId.Value); + // if user has no record associated, make him a mock one with just an account number, + // so we know who they are in case we're going to create them a proper one + Record record = await _context.GetRecordByAccountId(userId.Value) + ?? _recordService.Create(null, userId.Value); IBotCommand handler = null; - if (text.StartsWith(_thatOneCommand.Command)) - { - handler = _thatOneCommand; - _thatOneCommand.Crutch = userId.Value; - } - else - { - _thatOneCommand.Crutch = null; - handler = _commands.SingleOrDefault(c => text.StartsWith(c.Command)); - } + string commandText = text.Split(' ').FirstOrDefault(); + // will crash if multiple command classes share same text, who cares + handler = _commands.SingleOrDefault(c => c.Command.Equals(commandText)); if (handler != null) { - _bot.SendPureMarkdown(userId.Value, handler.Process(record)); + await _bot.Send(userId.Value, handler.Process(record)); } else { - HandleUnknownText(userId.Value, text); + await HandleUnknownText(userId.Value, commandText); } } @@ -164,18 +141,19 @@ public bool IsToken(string calledToken) /// User to reply to. /// Received message that was not processed /// by actual commands. - private void HandleUnknownText(long accountId, string text) + private async Task HandleUnknownText(long accountId, string text) { // suddenly, cat! if (new Random().Next(0, 19) == 0) { - _bot.SendTheSticker(accountId); + await _bot.SendTheSticker(accountId); } else { - string reply = text.StartsWith("/") ? - _invalidCommandReply : _invalidReply; - _bot.Send(accountId, reply); + string reply = text.StartsWith("/") + ? Locale.ErrorDave + : Locale.ErrorWhat; + await _bot.Send(accountId, reply); } } } diff --git a/WebToTelegramCore/Services/TelegramBotService.cs b/WebToTelegramCore/Services/TelegramBotService.cs index 557a582..893362a 100644 --- a/WebToTelegramCore/Services/TelegramBotService.cs +++ b/WebToTelegramCore/Services/TelegramBotService.cs @@ -1,9 +1,12 @@ using Microsoft.Extensions.Options; +using System; +using System.Threading.Tasks; using Telegram.Bot; using Telegram.Bot.Types; using Telegram.Bot.Types.Enums; using Telegram.Bot.Types.InputFiles; - +using WebToTelegramCore.Data; +using WebToTelegramCore.Interfaces; using WebToTelegramCore.Options; namespace WebToTelegramCore.Services @@ -31,34 +34,37 @@ public class TelegramBotService : ITelegramBotService private readonly InputOnlineFile _sticker = new InputOnlineFile(_theStickerID); /// - /// Field to store used instance of formatter. + /// Field to store our webhook URL to be advertised to Telegram's API. /// - private readonly IFormatterService _formatter; + private readonly string _webhookUrl; /// - /// Constructor that also sets up the webhook. + /// Constructor that get the options required for this service to operate. /// /// Options to use. - /// Formatter to use. - public TelegramBotService(IOptions options, - IFormatterService formatter) + public TelegramBotService(IOptions options) { _client = new TelegramBotClient(options.Value.Token); + // made unclear that "api" part is needed as well, shot myself in the leg 3 years after + _webhookUrl = options.Value.ApiEndpointUrl + "/api/" + options.Value.Token; + } - _formatter = formatter; - - var webhookUrl = options.Value.ApiEndpointUrl + "/" + options.Value.Token; - // this code is dumb and single-threaded. _Maybe_ later - _client.SetWebhookAsync(webhookUrl, - allowedUpdates: new[] { UpdateType.Message }); + /// + /// Method to manage external webhook for the Telegram API. + /// Part one: called to set the webhook on application start. + /// + public async Task SetExternalWebhook() + { + await _client.SetWebhookAsync(_webhookUrl, allowedUpdates: new[] { UpdateType.Message }); } /// - /// Destructor that removes the webhook. + /// Method to manage external webhook for the Telegram API. + /// Part two: called to remove the webhook gracefully on application exit. /// - ~TelegramBotService() + public async Task ClearExternalWebhook() { - _client.DeleteWebhookAsync(); + await _client.DeleteWebhookAsync(); } /// @@ -66,34 +72,38 @@ public TelegramBotService(IOptions options, /// /// ID of the account to send to. /// Markdown-formatted message. - public void Send(long accountId, string message) + /// Flag to set whether to suppress the notification. + /// Formatting type used in the message. + public async Task Send(long accountId, string message, bool silent = false, MessageParsingType parsingType = MessageParsingType.Markdown) { // I think we have to promote account ID back to ID of chat with this bot var chatId = new ChatId(accountId); - _client.SendTextMessageAsync(chatId, _formatter.TransformToHtml(message), - ParseMode.Html, true); + + await _client.SendTextMessageAsync(chatId, message, + ResolveRequestParseMode(parsingType), disableWebPagePreview: true, + disableNotification: silent); } /// /// Method to send predefined sticker on behalf of the bot. /// /// ID of the account to send to. - public void SendTheSticker(long accountId) + public async Task SendTheSticker(long accountId) { var chatId = new ChatId(accountId); - _client.SendStickerAsync(chatId, _sticker); + await _client.SendStickerAsync(chatId, _sticker); } - /// - /// Sends message in CommonMark as Markdown. Used only internally as a crutch - /// to display properly formatteded pre-defined messages. HTML breaks them :( - /// - /// ID of account to send message to. - /// Text of the message. - public void SendPureMarkdown(long accountId, string message) + private ParseMode? ResolveRequestParseMode(MessageParsingType fromRequest) { - var chatId = new ChatId(accountId); - _client.SendTextMessageAsync(chatId, message, ParseMode.Markdown, true); + return fromRequest switch + { + MessageParsingType.Plaintext => null, + MessageParsingType.Markdown => ParseMode.MarkdownV2, + // should never happen, but for sake of completeness, + // fall back to plaintext + _ => null + }; } } } diff --git a/WebToTelegramCore/Services/TokenGeneratorService.cs b/WebToTelegramCore/Services/TokenGeneratorService.cs index b56c388..92ddbf0 100644 --- a/WebToTelegramCore/Services/TokenGeneratorService.cs +++ b/WebToTelegramCore/Services/TokenGeneratorService.cs @@ -1,6 +1,8 @@ using System; using System.Security.Cryptography; +using System.Linq; using System.Text; +using WebToTelegramCore.Interfaces; namespace WebToTelegramCore.Services { @@ -22,18 +24,19 @@ internal class TokenGeneratorService : ITokenGeneratorService "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz").ToCharArray(); /// - /// Strong random instance used to generate random tokens. + /// DB context. Used to check for collisions with existing tokens. /// - private readonly RandomNumberGenerator _random; + private readonly RecordContext _context; /// /// Class constructor. /// - public TokenGeneratorService() + public TokenGeneratorService(RecordContext context) { - // let's pretend we're serious business for a moment - _random = RandomNumberGenerator.Create(); + _context = context; + // sanity check for random evenness + // TODO consider it to be a warning instead of an exception if (256 % _alphabet.Length != 0) { throw new ApplicationException("Selected alphabet does not map evenly " + @@ -42,13 +45,33 @@ public TokenGeneratorService() } /// - /// Token generation method. + /// Generates a token and ensures it is not yet assigned to other accounts. /// - /// Token. + /// An unique token. public string Generate() + { + string token = null; + var done = false; + while (!done) + { + token = GenerateRandom(); + done = !_context.Records.Any(r => r.Token == token); + } + return token; + } + + /// + /// Actual token generation method. + /// + /// A completely random token. + private string GenerateRandom() { var randomBytes = new byte[_tokenLength]; - _random.GetBytes(randomBytes); + // let's pretend we're serious business for a moment + using (var random = RandomNumberGenerator.Create()) + { + random.GetBytes(randomBytes); + } var sb = new StringBuilder(); foreach (var b in randomBytes) diff --git a/WebToTelegramCore/Startup.cs b/WebToTelegramCore/Startup.cs index 845cea0..b2ba015 100644 --- a/WebToTelegramCore/Startup.cs +++ b/WebToTelegramCore/Startup.cs @@ -1,15 +1,10 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; - +using WebToTelegramCore.Interfaces; using WebToTelegramCore.Options; using WebToTelegramCore.Services; -using Record = WebToTelegramCore.Models.Record; - namespace WebToTelegramCore { public class Startup @@ -24,40 +19,25 @@ public Startup(IConfiguration configuration) // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { - services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1); - // singleton makes changes to non-db properties persistent services.AddDbContext(options => options.UseSqlite(Configuration.GetConnectionString("DefaultConnection")), ServiceLifetime.Singleton, ServiceLifetime.Singleton); - services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); - // Options pattern to the rescue? - services.Configure(Configuration.GetSection("General")); - services.Configure(Configuration.GetSection("Bandwidth")); - services.Configure(Configuration.GetSection("Strings")); - // loading this explicitly as there's no straightforward way to pass options - // to models; I can be wrong though - // TODO: see if there's a better way - var preload = Configuration.GetSection("Bandwidth").GetValue("InitialCount"); - Record.SetMaxValue(preload); - } + services.AddTransient(); - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - public void Configure(IApplicationBuilder app, IHostingEnvironment env) - { - if (env.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - } + // Telegram.Bot's Update class relies on some Newtonsoft attributes, + // so to deserialize it correctly, we need to use this library as well + services.AddControllers().AddNewtonsoftJson(); - app.UseMvc(); + services.Configure(Configuration.GetSection("General")); + services.Configure(Configuration.GetSection("Bandwidth")); } } } diff --git a/WebToTelegramCore/WebToTelegramCore.csproj b/WebToTelegramCore/WebToTelegramCore.csproj index 64c0dac..c07ebd4 100644 --- a/WebToTelegramCore/WebToTelegramCore.csproj +++ b/WebToTelegramCore/WebToTelegramCore.csproj @@ -1,24 +1,26 @@  - netcoreapp2.1 - 1.2.0.0 + net7.0 + 2.0.0.0 bnfour Dotnet Telegram forwarder - © 2018 bnfour + © 2018, 2020–2023, bnfour - - - - + + + PreserveNewest + + PreserveNewest + diff --git a/WebToTelegramCore/appsettings.json b/WebToTelegramCore/appsettings.json index 30a4cd6..1fc1bf1 100644 --- a/WebToTelegramCore/appsettings.json +++ b/WebToTelegramCore/appsettings.json @@ -1,47 +1,20 @@ { - "Logging": { - "LogLevel": { - "Default": "Warning" - } - }, - "AllowedHosts": "*", - "ConnectionStrings": { - "DefaultConnection": "Data Source=database.sqlite" - }, - "General": { - "Token": "it's a secret", - "ApiEndpointUrl": "also a secret for now", - "RegistrationEnabled": true - }, - "Bandwidth": { - "InitialCount": 20, - "SecondsPerRegeneration": 60 - }, - "Strings": { - "CancelDeletion": "Token deletion cancelled.", - "CancelRegeneration": "Token regeneration cancelled.", - "ConfirmDeletion": "Token deleted. Thank you for giving us an oppurtunity to serve you.", - "ConfirmRegeneration": "Your new token is\n\n`{0}`\n\nDon't forget to update your clients' settings.", - "CreateGoAway": "This instance of bot is not accepting new users for now.", - "CreateSuccess": "Success! Your token is:\n\n`{0}`\n\nPlease consult /token for usage.", - "DeletionNoTurningBack": "This bot has registration turned *off*. You won't be able to create new token. Please be certain.", - "DeletionPending": "*Token deletion pending!*\n\nPlease either /confirm or /cancel it. It cannot be undone.\nIf you need to change your token, please consider to /regenerate it instead of deleting and re-creating it.", - "ErrorConfirmationPending": "You have an operation pending cancellation. Please /confirm or /cancel it before using other commands.", - "ErrorDave": "I'm afraid I can't let you do that.", - "ErrorMustBeGuest": "In order to use this command, you must have no token associated with your account. You can /delete your existing one, but why?", - "ErrorMustBeUser": "In order to use this command, you must have a token associated with your account. Try running /create first.", - "ErrorNoConfirmationPending": "This command is only useful when you're trying to /delete or /regenerate your token.", - "ErrorWhat": "Unfortunately, I'm not sure how to interpret this.", - "Help": "This bot provides web API to route messages from API's endpoint to you in Telegram via bot. It can be used to notify you in Telegram from any Internet-enabled device you want (provided you know how to make POST requests from it).\nTo start, /create your token (if this particular instance of bot has registration of new users open). You can /delete it anytime. To change your token for whatever reason, please /regenerate it and not delete and re-create.\nOnce you have a token, see /token for additional usage help.\nOther commands supported by bot include:\n- /confirm and /cancel are used to prevent accidental deletions and regenerations;\n- /help displays this message;\n- /about displays general info about this bot.\n\nThere's also an easter egg command and a rare response to unknown commands: one way to satisfy your curiosity is to check out bot's source code.\n\n--bnfour", - "RegenerationPending": "*Token regenration pending!*\n\nPlease either /confirm or /cancel it. It cannot be undone. Please be certain.", - "RequestBandwidthExceeded": "Too many messages. Try again later.", - "RequestNoToken": "Invalid token.", - "RequestOk": "Request accepted.", - "RequestWhat": "[UNUSED] Something bad happened", - "StartGoAway": "Sorry, this instance of bot is not accepting new users for now.", - "StartMessage": "Hello!\n\nThis bot provides a standalone web API to relay messages from whatever you'll use it from to Telegram as messages from the bot. It might come in handy to unify your notifications in one place.\n\n*Please note*: this requires some external tools. If you consider phrases like \"Send a POST request to the endpoint with JSON body with two string fields\" a magic gibberish you don't understand, this bot probably isn't much of use to you.", - "StartRegistrationHint": "If that does not stop you, feel free to /create your very own token.", - "TokenErrorsDescription": "If you send a malformed request, the API will return `400 Bad Request`. If request is properly formed, `200 OK` is returned, along with a response in JSON. The response contains three fields:\n- *Ok: boolean.* If `true`, your message is received and will be sent via bot shortly. If `false`, something went wrong, see the next field.\n- *Code: int.* Represents various error codes:\n-- 0: No error. Sent when *Ok* is true;\n-- 1: No such token found in database. Your client should not retry same request but should ask user to double-check their settings;\n-- 2: Bandwidth exceeded. Bot's throughput is limited. You shouldn't see this unless you send *a lot* of messages, but when you do, please wait a few minutes before retrying your request.\n- *Details: string.* Human-readable description of *Code* field, similar to the text above.\n\n There's also always a possibility of `500 Internal Server Error`.", - "TokenTemplate": "Your token is\n\n`{0}`\n\n*Usage*:\nSend a POST request to {1} with JSON body. Body must contain two string fields: \"token\" with your token, and \"message\" with text to send via bot.\n\n*Example*:\n```\n{{\n \"token\": \"{0}\",\n \"message\": \"{2}\"\n}}\n```" + "Logging": { + "LogLevel": { + "Default": "Warning" } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "DefaultConnection": "Data Source=|DataDirectory|database.sqlite" + }, + "General": { + "Token": "it's a secret", + "ApiEndpointUrl": "also a secret for now", + "RegistrationEnabled": true + }, + "Bandwidth": { + "InitialCount": 20, + "SecondsPerRegeneration": 60 + } } diff --git a/readme.md b/readme.md index a7dd1ec..af3c620 100644 --- a/readme.md +++ b/readme.md @@ -1,73 +1,80 @@ # Dotnet Telegram forwarder -(temporary name that stuck since I can't think of a better one) -An ASP.NET Core app providing HTTP API for notifications via Telegram bot. - -## What's this? -My attempt to grasp the basics of both ASP.NET and EF by writing moderately useful app. -Telegram bot is used to provide auth tokens to users and to actually notify them when something makes a request to the provided web API. -This can be used to deliver all kinds of notifications via Telegram. +("temporary" generic name from the very start of development) +An app providing HTTP API to deliver arbitrary text notifications via associated Telegram bot from anywhere where making HTTP POST requests is available. ## Status -Pretty much works on my server (sorry, registration's closed). Your mileage may vary. -I still have to write some actual clients though. +Operational. First version has been serving me flawlessly (as I can tell) since mid 2018. ## Description -Let's try [readme driven development](http://tom.preston-werner.com/2010/08/23/readme-driven-development.html) this time. So this app consists of two parts: Telegram bot and a web API. +Let's try [readme driven development](http://tom.preston-werner.com/2010/08/23/readme-driven-development.html) this time. So this app consists of two equally important parts: Telegram bot and a web API. ### Web API -Has only one method to send the notification. Its endpoint listens for requests with JSON body containing auth token and message to deliver. (Messages should support markdown.) -Request should look like that: -``` -{ - "token": "basically `[0-9a-zA-Z+=]{16}`>", - "message": "text to be delivered via bot" -} -``` -Messages use the same format Telegram does: major differences are `**bold**` for **bold**, `__italic__` for *italic*. Also, `<` and `>` should be escaped as `<` and `>` for notifications to work. Complex formatting may break down the makeshift "parser", test notification to get through. +Has only one method to send the notification. Its endpoint listens for POST requests with JSON body. Actual endpoint URL will be provided via the bot itself when a new token is created. -Tokens are tied to Telegram IDs internally, also there is limitations on how often messages can be sent: every token has up to 20 instant deliveries, -with one regenerating every minute after last successful message delivery. -If request was correctly formed, API will respond with another JSON object: +#### Request +Request's body has this structure: ``` { - "ok": boolean, (if true message is accepted. If false, some kind of error happened), - "code": int, (represents error codes in machine-friendly format), - "details": string (human-friendly error description) + "token": string, + "type": (optional) string, + "message": string, + "silent": (optional) boolean } ``` -Possible error codes: -* 0 -- No error, message sent. Used when "ok" is true; -* 1 -- No such token. Token is in valid format but is not found in the database; -* 2 -- Bandwidth exceeded. Too many messages in a given amount of time. Client should wait at least a minute (by default) before retrying. +* Token is this service's user identifier, randomly generated per Telegram user, with abilities to withdraw it or replace with a new one anytime. It's a 16 characters long string that may contain alphanumerics, and plus and equals signs (So `[0-9a-zA-Z+=]{16}`). +* Type is used to select between two supported parse modes: `"plaintext"` for plain text, and `"markdown"` for MarkdownV2 as described in Telegram docs [here](https://core.telegram.org/bots/api#markdownv2-style). If value is not supplied, defaults to `"plaintext"`. These two are separated, because Telegram flavoured Markdown requires escaping for a fairly common plaintext punctuation marks, and will fail if not formed correctly. +* Message is the text of the message to be sent via the bot. Maximum length is 4096 (also happens to be a maximum length of one Telegram message). +* Silent is boolean to indicate whether them message from the bot in Telegram will come with a notification with sound. Behaves what you'd expect. If not supplied, defaults to `false`. Please note that the end user is able to mute the bot, effectively rendering this option useless. -If request isn't in correct format, blank 400 Bad Request is thrown instead. +#### Response +API returns an empty HTTP response with any of the following status codes: +* `200 OK` if everything is indeed OK and message should be expeted to be delivered via the bot +No further actions from the client required. +* `400 Bad Request` if the user request is malformed and cannot be processed +Client should check that the request is well-formed and meet the specifications, and retry with the fixed request. +* `404 Not Found` if supplied token is not present in the database +Client should check that the token they provided is valid and has not been removed or changed, and retry with the correct one. +* `429 Too Many Requests` if current limit of sent messages is exhausted +Client should retry later, after waiting at least one minute (on default throughput config). +* `500 Internal Server Error` in case anything goes wrong +Client can try to retry later, but ¯\\\_(ツ)\_/¯ +#### Rate limitation +The API has a rate limitation, preventing large(ish) amount of notifications in a short amount of time. By default (can be adjusted via config files), every user has 20 ...message points, I guess? Every notification sent removes 1 message point, and requests will be refused with `429 Too Many Requests` status code when all points are depleted. A single point is regenerated every minute after last message was sent. +For instance, if API is used to send 40 notifications in quick succession, only 20 first messages will be sent to the user. If client waits 5 minutes after API starts responding with 429's, they will be able to send 5 more messages instantenously before hitting the limit again. After 20 minutes of idle time since the last successfully sent message, the API will behave as usual. ### Telegram bot The bot is used both to deliver messages and to obtain token for requests for a given account. It has some commands: * `/start` -- obligatory one, contains short description; -* `/help` -- also obligatory, displays output similar to this text; +* `/help` -- also obligatory, displays output similar to this section; * `/about` -- displays basic info about the bot, like link back to this repo; -* `/token` -- reminds user's token if there is one, also API usage hints; +* `/token` -- reminds user's token if there is one, also API usage hints and endpoint location; * `/create` -- generates new token for user if none present; * `/regenerate` -- once confirmed (see `/confirm` and `/cancel`) replaces user's token with a new one; * `/delete` -- once confirmed removes user's token; * `/confirm` -- confirms regeneration or deletion; * `/cancel` -- cancels regeneration or deletion. -When there is destructive (either regeneration or deletion) operation pending after initial command, only `/cancel` or `/confirm` commands are -accepted. +The ability for anyone to create a token for themselves is toggleable via config entry. You can always run direct queries against bot's DB for quick editing. Note that messages will not be delivered unless user actually engaged in a conversation with the bot. + +When there is a destructive (either regeneration or deletion) operation pending, only `/cancel` or `/confirm` commands are accepted. ## Configuration and deployment +You'll need .NET 7 runtime. Hosting on GNU/Linux in a proper data center is highly encouraged. By default, listens on port 8082. This can be changed with `--port ` command-line argument. Rest of the settings are inside `appsettings.json`: -* "Strings" section contains all the customizable strings just in case localization is ever needed. -* "General" section contains Telegram's bot token, API endpoint URL as seen from outside world (certainly not localhost:8082 as Kestrel would told you it listens to) and a boolean that controls whether new users can create tokens. +* "General" section contains Telegram's bot token, API endpoint URL as seen from outside world (certainly not localhost:8082 as Kestrel would told you it listens to) and a boolean that controls whether new users can create tokens. Please note that `/api` will be appended to this address. So, for example, if you set `https://foo.example.com/bar` here, actual endpoint to be used with the app (both for Telegram API webhooks and for user messages) will be `https://foo.example.com/bar/api` * "Bandwidth" section controls bot's throughput: maximum amount of messages to be delivered at once and amount of seconds to regenerate one message delivery is set here. -To deploy this bot, you'll need something that will append SSL as required by Telegram. As always, I recommend `nginx` as a reverse proxy. Another quirk is that launching via `./WebToTelegramCore` -didn't worked with my setup: server had ASP.NET Core 2.1.3 runtime, but app was expecting 2.1.2. `dotnet WebToTelegramCore.dll` worked though. +To deploy this bot, you'll need something that will append SSL as required by Telegram. As always with Telegram bots, I recommend `nginx` as a reverse proxy. You'll need to set up HTTPS as well. -## Possible TODO -Make a companion website that also allows token manipulation just like the bot? \ No newline at end of file +## Version history +* **v 1.0**, 2018-08-29 +Initial release. More an excercise in ASP.NET Core than an app I needed at the moment. Actually helped me to score a software engineering job and turned out to be moderately useful tool. +* **v 1.2**, 2018-10-05 +Greatly increased the reliability of Markdown parsing in one of the most **not** straightforward ways you can imagine -- by converting the Markdown to HTML with a few custom convertion quirks. +* **no version number**, 2020-05-14 +Shelved attempt to improve the codebase. Consists of one architecture change and is fully ~~included~~ rewritten in the next release. +* **v 2.0**, 2023-05-06 +Really proper markdown support this time (Telegram's version with questionable selection of characters to be escaped), option to send a silent notification, async everthing, .NET 7, HTTP status codes instead of custom errors, and probably something else I forgot about.