-
Notifications
You must be signed in to change notification settings - Fork 0
SKitLs Core
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.
- Setup
- Module Review
- Architecture
- Casted Updates
- Interceptors (WIP)
- Services (WIP)
- Users Management (WIP)
- Featues
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.
This project is available as a NuGet package. To install it run:
dotnet add package SKitLs.Bots.Telegram.Core
Get the required localization files from the repository and install them in the folder /resources/locals
.
Updates casting and handling logic realized in a model of five-step funnel:
- BotManager
- ChatScanner
- IUpdateHandler
- IActionManager
- IBotAction
If you would like to dive in this architecture, see the Architecture section.
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.
- Just raise static
BotBuilder.NewBuilder()
function to create a new Builder and explore its functionality. - Via BotBuilder you can design your BotManager for your needs, using closed, safe functions.
- After you have set up all interior you can get your constructed BotManager with a
BotBuilder.Build()
function. - 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.
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.
- Step one: Bot Manager.
- Step two: Chat Scanning
- Step three: Handling Updates
- Step four: Management
- Final step: Actions
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.
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.
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
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>).
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.
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;
}
}
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.
(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.
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 certainIBotUser 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).
WIP
WIP
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.
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)
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
- Settings
- Localization
- Exceptions Owning
You can explore bot settings via BotManager.Settings property and using BotBuilder.DebugSettings.
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..];
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 ). |
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:
- 'lang' is an IETF laguage tag (work in progress, see Localizations project)
- 'name' is a readable filename
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);
}
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.
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>();
If you don't understand how this or that thing works, please share your problem in discussion.