Skip to content

SKitLs Core

SKitLs-dev edited this page Mar 30, 2024 · 19 revisions

An essential core module that contains main architecture and logic that handles and casts incoming updates. All the basics can be used and implemented in other modules to extend basic functionality.

The idea of this module is to unify incoming telegram updates. Three main aspects of this module are: casted updates, handling architecture and services.

Contents

Brief

The core task undertaken by this module is the transformation of nullable data received from the server and processed into corresponding nullable C# classes by the Telegram.Bot library to the custom SKitLs library classes. In essence, the Core module progressively assembles a strictly typed : ICastedUpdate update, to deliver all the provided data to the final execution method.

At present, the library supports two of the most common types of updates: text messages and callbacks, represented as SignedCallbackUpdate and AnonymMessageTextUpdate, respectively.

Note: For signed updates, which can be used to identify the user sending the update, the library provides a separate interpretation for each Anonym update, inheriting from ISignedUpdate : ICastedUpdate and extending the signature to match IBotUser.

To identify users in an internal database, the IUsersManager interface is employed. Each ChatScanner can define its own IUserManager.

In cases where there is no built-in database for user storage, the GetDefaultBotUser function can be used to create a temporary user instance on the fly. However, this method is not suitable if you need to store any intermediate data associated with users between updates.

Setup

Download Package

This project is available as a NuGet package. To install it run:

dotnet add package SKitLs.Bots.Telegram.Core

Download Locals

Get the required localization files from the repository and install them in the folder /resources/locals.

Back to contents

Module Review

General

Updates casting and handling logic realized in a model of five-step funnel:

  1. BotManager
  2. ChatScanner
  3. IUpdateHandler
  4. IActionManager
  5. IBotAction

If you would like to dive in this architecture, see the Architecture section.

BotBuilder and ChatDesigner

To use project's facilities use BotBuilder and ChatDesigner classes. See Code Snippets for more info. These two classes are wizard constructors for BotManager and ChatScanner classes, which are two basic ones.

BotManager - the heart of your bot - does not have public constructor. Use BotBuilder wizard constructor class instead.

  1. Just raise static BotBuilder.NewBuilder() function to create a new Builder and explore its functionality.
  2. Via BotBuilder you can design your BotManager for your needs, using closed, safe functions.
  3. After you have set up all interior you can get your constructed BotManager with a BotBuilder.Build() function.
  4. To activate your bot use BotManager.Listen() function.

BotBuilder Methods (Last review: .Core v3.1.1)

Modif Method Description
public NewBuilder(string) Initializes a new instance with the provided token.
public EnableChatsType(ChatType, ChatDesigner?)* Enables update handling for specific chat types. Utilizes the ChatScanner from the provided ChatDesigner (see below).
public CustomDelivery(IDeliveryService) Updates the delivery service. Can be utilized with a custom service or the Advanced one from the .AdvancedMessages module.
public AddService<T>(T) Adds a custom service.
public AddInterceptor(IUpdateInterceptor) Adds a custom interceptor.
public Build(string?) Creates a BotManager, optionally assigning a debug name from the parameter.

* Available shortcuts:

  • EnablePrivates(ChatDesigner?)
  • EnableGroups(ChatDesigner?)
  • EnableSupergroups(ChatDesigner?)
  • EnableChannels(ChatDesigner?)

BotBuilder Methods (Last review: .Core v3.1.1)

The same process is applicable for ChatScanners, used for processing updates from chats, via their wizard constructor class ChatDesigner.

Modif Method Description
public NewDesigner() Initializes a new instance of a chat designer.
public OverrideDefaultUserFunc(Func<long, IBotUser>)* Updates the function for retrieving the Sender from the Update (if UserManager is not set).
public UseUsersManager(IUsersManager) Updates the used UserManager.
public UseXHandler(IUpdateHandlerBase<X>?)** Declares a handler for updates of type X.
internal Build(string?) Creates a ChatScanner, optionally assigning a debug name if provided (raised while BotBuilder.Build()).

* Default one:

GetDefaultBotUser = (id) => new DefaultBotUser(id, false, "en", "Default Bot User");

** XHandler examples:

  • UseMessageHandler
  • UseEditedMessageHandler
  • UseChatJoinRequestHandler
  • UsePreCheckoutQueryHandler
  • etc.

Back to contents

Architecture

In general, the architecture can be represented as a sorting machine that forwards an update coming from the server to one of the end managers. They, in turn, redirect the update to one of the coded actions (how to).

Overall there are five-level funnel of processing server updates.

  1. Step one: Bot Manager.
  2. Step two: Chat Scanning
  3. Step three: Handling Updates
  4. Step four: Management
  5. Final step: Actions

Architecture Sum up

Bot Manager

First step of this funnel is BotManager, which is a start point of all project. It contains general information and designed to link Telegram.Bots and SKitLs.Bots.Telegram libraries.

Modif Type Element Description
public ITelegramBotClient Bot Provides access to Telegram API.
public BotSettings Settings Declares bot settings.
private List<IUpdateInterceptor> Interceptors Contains interceptors.
private Dictionary<Type, object> Services IoC container for storing services.
public IDeliveryService DeliveryService Access to IDeliveryService for sending messages.
public Dictionary<ChatType, ChatScanner> ChatHandlers Dictionary of ChatScanners.

After BotManager handled an update it is sent to one of ChatScanners.

Telegram.Bot ITelegramBotClient

BotManager provides access to the ITelegramBotClient object from the Telegram.Bot library. It, in turn, gives access to direct work with the Telegram API.

You can check project's official site for more info.

Services Container

BotManager realizes simple IoC-container (see Services). Container only supports Singletons, but can be accessed from any part of your code. All services must be derived from IBotService interface (see example).

Services container is a private one property. Use following BotManager methods:

  • Add: public void AddService<T>(T service)
  • Get: public T ResolveService<T>()

Furthermore, BotManager contains some pre-set services, necessary for the work such as:

  • Localiztor for getting localized strings (see: (TODO: add ref))
  • DeliveryService for converting SKitLs messages to API ones via declared interfaces (see: (TODO: add ref))
  • Some others

Back to Architecture

Chat Scanner

Meanwhile BotManager is used to process global logic, Chat Scanners work only with certain updates (depending on thier Chat Type). It helps to split logic into parts and save code clear.

Each ChatType needs its own ChatScanner. But one ChatScanner can be subscribed for several ChatTypes.

Chat Scanner consists of several Update Handlers. Each Update Handler only works with its own update type (TUpdate : ICastedUpdate).

Modif Type Member Description
public ChatType ChatType Determines Chat Type that this scanner works with.
public IUsersManager? UsersManager Determines UsersManager.
public Func<long, IBotUser> GetDefaultBotUser Alternative to User Manager.
public IUpdateHandlerBase<X>* XHandler Specific handlers used to handle updates of a type X : ICastedUpdate.

* Better to see ChatScanner source code. Would be updated in further versions (Generic Dictionary<UpdateType, Handler>).

Back to Architecture

Update Handlers

Update Handlers are realized via IUpdateHandlerBase and IUpdateHandlerBase<TUpdate>.

Modif Method Description
IUpdateHandlerBase
public Task HandleUpdateAsync(ICastedUpdate, IBotUser?) Asynchronously handles an incoming update.
IUpdateHandlerBase<TUpdate>
public TUpdate CastUpdate(ICastedUpdate, IBotUser?); Casts an incoming ICastedUpdate __ to the specified TUpdate.
public Task HandleUpdateAsync(TUpdate); Asynchronously handles custom TUpdate.

Default classes derived from IUpdateHandlerBase<TUpdate> can be found here.

Note: Not all updates and handlers are currently implemented.

Callback and Message updates are realized by default, but some other Update Handlers do not determine specific update type. You should be more patient, assigning these handlers and overriding handlers interior.

public class ChatScanner
{
      public IUpdateHandlerBase<CastedUpdate>? ChatJoinRequestHandler { get; set; }
}

Update Handlers are final step of a global 'casting and handling' logic. At this moment updates are finally casted to specified ICatedUpdate types such as SignedCallbackUpdate and prepared to be passed to next steps.

Back to Architecture

Updates and Actions Management

Since an update was finally prepared in a certain Update Handler it could be sent to an Action Manager (IActionManager<TUpdate>).

Action Managers can be added to your custom Update Handler class. Default Update Handlers contains IActionManager<TUpdate> properties.

public class DefaultSignedMessageTextUpdateHandler : IUpdateHandlerBase<SignedMessageTextUpdate>
{
    public IActionManager<SignedMessageTextUpdate> CommandsManager { get; set; }
    public IActionManager<SignedMessageTextUpdate> TextInputManager { get; set; }
}

Default implementation is LinearActionManager<TUpdate>. As the name suggests, this manager linearly scans the IBotActions stored in it and transmits the incoming update to one or more of them (depending on the bool OnlyOneAction property). Unlike the STATE manager (TODO: add ref), which passes updates to actions depending on the sender's state.

public async Task ManageUpdateAsync(TUpdate update)
{
    foreach (IBotAction<TUpdate> callback in Actions)
    if (callback.ShouldBeExecutedOn(update))
    {
        await callback.Action(update);
        if (OnlyOneAction)
            break;
    }
}

Back to Architecture

Bot Actions

Actions are used to make two things together: an action that should be executed and a rule when it should be to.

All actions are derived from IBotAction<TUpdate> : IBotAction where TUpdate : ICastedUpdate, where TUpdate is an update this action should react on.

As an example: callback action should react only on callback updates. So its realization is:

public class DefaultCallback : DefaultBotAction<SignedCallbackUpdate>

IBotAction provides specific bool ShouldBeExecutedOn(TUpdate) method that determines whether the action should be raised as a reaction on a specific update.

All the defaults ShouldBeExecutedOn() implementations provide a simple equality comparison between the incoming update data and the current action name base.

public class DefaultCallback : DefaultBotAction<SignedCallbackUpdate>
{
    // ...
    public override bool ShouldBeExecutedOn(SignedCallbackUpdate update) => ActionNameBase == update.Data;
}

Example: manager contains Сommand-Actions '/start', '/reset' and '/rebuild'. An incoming update is a Message Text with '/re' content.

If Action's checker is 'Equality' (ex. update.Text == action.ActionNameBase) then none of Actions will be executed.

But in case Action's checker is 'StartsWith' (ex. action.ActionNameBase.StartsWith(update.Text)), '/reset' and '/rebuild' commands will be executed.

Some defaults are DefaultCallback or DefaultCommand. But across the solution you can find such actions as BotArgCommand<TArg> or DefaultProcessBase (TODO: add ref)

public class BotArgCommand<TArg> : DefaultCommand, IArgedAction<TArg, SignedMessageTextUpdate>
    where TArg : notnull, new() { }
public abstract class DefaultProcessBase : IBotProcess, IStatefulIntegratable<SignedMessageTextUpdate>,
    IBotAction<SignedMessageTextUpdate> { }

Though they contain more complex logic, Action Managers are still able to handle them properly.

Back to Architecture

Architecture Sum up

(TODO)

Probably, this funnel looks a bit scary and complex, but if are not interested in diving into library interior you are still able to use prewritten defaults in your project and only code Actions logic to launch your bot. Their functionality covers all the basic needs.

Back to contents

Casted Updates

All the casted updates are implemented from ICastedUpdate interface that describes main information about an update.

Modif Type Member Description
public BotManager Owner Returns the BotManager that processed the update.
public ChatScanner ChatScanner Returns the ChatScanner that processed the update.
public ChatType ChatType Returns the ChatType that the update was received from.
public long ChatId Returns the ID of the chat the update was received from.
public Update OriginalSource Returns the original Telegram Update that was received.
public UpdateType Type Returns the original Telegram Update's type.

Some additional interfaces are also declared to help define a certain groups of updates:

  • ISignedUpdate : ICastedUpdate represents an update that have a certain IBotUser sender.
    • public IBotUser Sender { get; }
  • IMessageTriggered represents an update that is associated with some messages (ex. Callback or Message Received).
    • public int TriggerMessageId { get; }

Creating your own typed ICastedUpdate is described in Applicants > Custom Updates (TODO).

Back to contents

Interceptors

WIP

Back to contents

Services

WIP

Back to contents

Delivery Service

IBuildableMessage

See

Back to contents

Users Management

In cases where there is no built-in database for user storage, the GetDefaultBotUser function can be used to create a temporary user instance on the fly. However, this method is not suitable if you need to store any intermediate data associated with users between updates.

By default your bot would not collect and save any data about users. Necessary data would be created during runtime based on incoming update to handle it and all the resources would be released as soon as update is handled.

To be able to save this data an IUserManager interface is declared. Users Manager is stand-alone prototype that could be implemented in your project and added to your bot with ChatDesigner:

ChatDesigner chatDesigner = ChatDesigner.NewDesigner()
    .UseUsersManager(/*your manager*/);

Users Manager has default realization in *.DataBases project.

IBotUser

The IBotUser interface represents a fundamental building block for bot user instances. It is designed to allow developers to extend user functionality as needed. Here's a brief overview:

  • Purpose: The primary purpose of this interface is to provide a structured way to interact with bot user data within your application.
  • Structure: The IBotUser interface includes a single property
    • long TelegramId: This property retrieves the Telegram ID of the user. It's particularly useful for sending messages directly to a specific user instead of relying on chat IDs.
  • Usage: When implementing this interface, you can extend it to include additional properties and methods that are specific to your bot user's needs. This flexibility allows you to tailor user objects to suit your application's requirements.

By implementing IBotUser, you can create and manage user instances efficiently and enhance their functionality as your bot application evolves.

Extension: IStateful User (.Stateful), IRoledUser (.Roles), IPermitteUser (.Roles)

IUsersManager

The IUsersManager interfaces provide a powerful toolkit for managing user data within a bot application. Here's an overview of these interfaces:

  • Purpose: These interfaces are designed to simplify and streamline the management of user data, catering to the specific type of user data your application requires.

  • Structure: There are two interfaces in this family:

    • IUsersManager<T>: This generic interface allows you to manage user data of a specific type T, which is typically expected to implement the IBotUser interface.
    • IUsersManager: This non-generic interface is a convenient extension of IUsersManager with a default type of IBotUser. It inherits the methods and events from IUsersManager<T> for managing users of the default type.

    Both include several methods:

    • SignedUpdateHandled: An event that occurs when a signed update is handled, providing user data for the sender.
    • CheckIfRegisteredAsync(long telegramId): Asynchronously checks if a user with a specified Telegram ID is registered.
    • GetUserByIdAsync(long telegramId): Asynchronously retrieves user data for a given Telegram ID.
    • RegisterNewUserAsync(ICastedUpdate update): Asynchronously registers a new user using incoming update data.
  • Usage: Implementing these interfaces in your bot application allows you to create, update, and manage user data efficiently. You can tailor these interfaces to handle your specific user data requirements, making it a versatile solution for user management.

By utilizing the IUsersManager interfaces, you can ensure a robust and customizable user management system within your bot application, accommodating different user data types and use cases.

Realization: DbUserManager (.DataBases) - allows to base your UM on IBotDataSet.

WIP

Back to contents

Features

Settings

You can explore bot settings via BotManager.Settings property and using BotBuilder.DebugSettings.

Bot Settings

Type Member Description
LangKey BotLanguage Default IDeliveryService language (used to send localized messages).
Func<string, bool>* IsCommand Defines the rule to determine if current string is command (ex '/start').
Func<string, string>** GetCommandText Defines the rule to extract command's payload (ex '/start' => 'start')
bool MakeDeliverySafe Determines whether IDeliveryService should check parsing and make it safe.

* Default

private bool IsCommandM(string command) => command.StartsWith('/');

** Default

private string GetCommandTextM(string command) => command[1..];

Debug Settings

You can read about localiztions below

Type Member Default Value Description
LangKey DebugLanguage LangKey.EN Determines the language used in debug output.
ILocalizator Localizator new DefaultLocalizator("resources/locals") Represents the localization service used for retrieving localized debugging strings.
ILocalizedLogger LocalLogger new LocalizedConsoleLogger(Localizator) Represents the logger service used for logging system messages.
bool LogUpdates true Determines whether information about incoming updates should be printed (handled by ).
bool LogExceptions true Determines whether information about thrown exceptions should be printed.
bool LogExceptionTrace false Determines whether information about exceptions' stack trace should be printed.

* Updates logged in BotManager.SubDelegateUpdate(). Exceptions handled by BotManager.HandleErrorAsync().

Method Description
UpdateLocalsPath(string) Sets a custom path for debug localization.
UpdateLocalsSystem(ILocalizator) Sets a custom debug localization service (Localizator).
UpdateLogger(ILocalizedLogger) Sets a custom debug logger (LocalLogger).

Back to Features

Localization

With Localization Service you can do both: localize debugging process or bot's behavior. Localized strings are loaded from a specific directory, "resources/locals" by default. It can be updated in Debug or Bot Settings.

JSON files stored in this directory should match next pattern: {lang}.{name}.json where:

Here is an example of a JSON language pack:

en.app.json

{
  "app.startUp": "Hello world!\n\nOpen menu: {0}."
}

ru.app.json

{
  "app.startUp": "Hello world!\n\nОткрыть меню: {0}."
}

Then you can resolve it via BotManager methods:

public sealed class BotManager
{
    // Resolves string, using BotManager.Localizator
    string? ResolveBotString(string key, params string?[] format);

    // Resolves string, using DebugSettings.Localizator
    string ResolveDebugString(string key, params string?[] format);
}

Exceptions

Framework's exceptions are based on SKTgExcpetion class.

Exceptions are marked as Internal, External or Inexternal by their origin type.

Type Origin
Internal Thrown as a result of some internal processes (It means that is absolutly my fault. Please, be kind and write an issue).
External Thrown as a result of some external actions (These exceptions raised in case you have done something wrong).
Inexternal Thrown either by some internal processes or external actions.

To simplify debugging process, SKTgExcpetion does not contain exception message, but carries a reference to its localization string. Exceptions' messages and captions are hosted in localization files. Here is an example:

en.core.json

{
  "system.ExternalExcep": "This exception is marked as External. It means that it occurred because of external actions and code. Please make sure your code is safe.",
  "system.InexternalExcep": "This exception can be both: Internal or External. Please make sure your code is safe. If you are sure it's ok - add an issue via GitHub.",
  "system.InternalExcep": "This exception is marked as Internal. It means that it occurred because of internal library problems. Please save info about exception context and share it via GitHub.",

  "exceptionCap.NullOwner": "Null Owner",
  "exceptionMes.NullOwner": "Was not able to access an owner of a type {0}.",
}

NullOwnerException.cs

public class NullOwnerException : SKTgException
{
    public Type SenderType { get; private set; }
    public NullOwnerException(Type senderType) : base("NullOwner", SKTEOriginType.Inexternal, senderType.Name)
    {
        SenderType = senderType;
    }
}

Every exception is printed with a help of CustomLoggingExtension extension class:

public static void Log(this ILocalizedLogger logger, Exception exception)
{
    if (exception is SKTgException sktg)
    {
        errorMes += "SKitLs.Bots.Telegram Error\n";
        errorMes += $"\n{Local(logger, sktg.CaptionLocalKey)}\n";
        errorMes += $"{Local(logger, sktg.MessgeLocalKey, sktg.Format)}";

        warn = sktg.OriginType switch
        {
            SKTEOriginType.Internal => Local(logger, "system.InternalExcep"),
            SKTEOriginType.Inexternal => Local(logger, "system.InexternalExcep"),
            SKTEOriginType.External => Local(logger, "system.ExternalExcep"),
            _ => null,
        };
    }
}

Thrown by ChatScanner NullOwnerException would have next output if settings language is 'EN':

Exception was thrown: SKitLs.Bots.Telegram Error

Null Owner
Was not able to access an owner of a type ChatScanner.
This exception can be both: Internal or External. Please make sure your code is safe. If you are sure it's ok make - pull request via GitHub.

Owning

Though in 99 of 100 cases your project will have the only one object of a type BotManager (until you are nesting several bots in one solution), BotManager is not created as a static one class to keep solution flexible and generic.

So how can you access Bot Manager's interior during runtime process? For these needs 'Owners System' is realized.

But BotManager's constructor is an internal one and a new object can be only created with a BotBuilder after all classes and services that need their Owner are already initialized. It means that instead of

BotBuilder.NewBuilder(token)
   .EnablePrivates(privates)
   .Build("Bot name")
   .Listen();

you will have to assign your .Build(...) object to some variable and then step-by-step reassign each Owner property, what is quite messy.

To prevent it, Dynamic Compilation is realized. Just implement IOwnerCompilable interface to your class, that should be owned.

You can do it in the next way:

public class YourService : IOwnerCompilable
{
   private BotManager? _owner;
   public BotManager Owner
   {
      get => _owner ?? throw new NullOwnerException(GetType());
      set => _owner = value;
   }
   public Action<object, BotManager>? OnCompilation => null;
}

How does it work?

When BotManager BotBuilder.Build() is summoned, BotManager.ReflectiveCompile() is raised:

internal void ReflectiveCompile()
{
   GetType()
      .GetProperties()
      .Where(x => x.GetCustomAttribute<OwnerCompileIgnoreAttribute>() is null)
      .Where(x => x.PropertyType.GetInterfaces().Contains(typeof(IOwnerCompilable)))
      .ToList()
      .ForEach(refCompile =>
      {
         var cmpVal = refCompile.GetValue(this);
         if (cmpVal is IOwnerCompilable oc)
            oc.ReflectiveCompile(cmpVal, this);
      });

   Services.Values.Where(x => x is IOwnerCompilable)
      .ToList()
      .ForEach(service => (service as IOwnerCompilable)!.ReflectiveCompile(service, this));
}

and then in IOwnerCompilable:

public BotManager Owner { get; set; }
public Action<object, BotManager>? OnCompilation { get; }
public void ReflectiveCompile(object sender, BotManager owner)
{
   Owner = owner;
   OnCompilation?.Invoke(sender, owner);
   sender.GetType().GetProperties()
      .Where(x => x.GetCustomAttribute<OwnerCompileIgnoreAttribute>() is null)
      .Where(x => x.PropertyType.GetInterfaces().Contains(typeof(IOwnerCompilable)))
      .ToList()
      .ForEach(refCompile =>
      {
         var cmpVal = refCompile.GetValue(sender);
         if (cmpVal is IOwnerCompilable oc)
            oc.ReflectiveCompile(cmpVal, owner);
      });
}

all the necessary work would be done automatically.

To prevent your IOwnerCompilable from automatic assigning for some reason, you can use OwnerCompileIgnoreAttribute:

[OwnerCompileIgnore]
public ILocalizator Localizator => ResolveService<ILocalizator>();