garnet is a ridiculously simple library created mainly for managing your stateful telegram bots written with Telethon.
Although, garnet is garnet, it is named telegram-garnet on the PyPI, you'll have to tell that to pip.
pip install -U telegram-garnet
# export BOT_TOKEN, APP_ID, APP_HASH, SESSION_DSN env vars.fromgarnetimportctxfromgarnet.eventsimportRouterfromgarnet.filtersimportState, text, groupfromgarnet.storagesimportDictStoragerouter=Router()
UserStates=group.Group.from_iter(["echo"]) # declare users states# register handler for "/start" commands for users with none yet set state@router.message(text.commands("start"), State.entry)asyncdefentrypoint(event):
awaitevent.reply("You entered echo zone!\n/cancel to exit")
fsm=ctx.CageCtx.get()
awaitfsm.set_state(UserStates.echo)
# register handler for "/cancel" commands for users that have entered any state@router.message(text.commands("cancel"), State.any)asyncdefcancel(event):
awaitevent.reply("Cancelled :)\n/start to restart")
awaitctx.CageCtx.get().set_state(None)
# handle any message from users with state=UserState.echo@router.message(State.exact(UserStates.echo))asyncdefecho(event):
awaitevent.reply(event.text)
if__name__=="__main__":
fromgarnet.runnerimportrun, launchlaunch("my-first-garnet-app", run(router, DictStorage()))Basically Filter is a "lazy" callable which holds an optional single-parameter function.
Filters are event naive and event aware. Filters are mutable, they can migrate from event-naive to event-aware in garnet.
.is_event_naive -> bool.call(e: T, /) -> Awaitable[bool]
Filter(function[, event_builder])
Value of the parameter function must be function that takes exactly one argument with type Optional[Some] and
returns bool either True or False.
(those are, primarily logical operators)
&is a logical AND for two filters|is a logical OR for two filters^is a logical XOR for two filters
~is a logical NOT for a filter
fromgarnetimportFilter, eventsasyncdeffun(_): ...
# example of event aware filterFilter(fun, events.NewMessage)
# example of event-naiveFilter(fun)By default Filter is event-naive, however when using with garnet::Router for handlers it may be changed.
Operations on Filter((e: Some) -> bool); Some.raw_text or Some.text
from garnet.filters import text
text.Lenis a special class forlen(Some.raw_text ... "")operations. Supports logical comparison operations, such are==,>,>=,<,<=text.startswith(prefix: str, /)will evaluates toSome.raw_text.startswith(prefix)text.commands(*cmds: str, prefixes="/", to_set=True)will evaluate to check if command is withincmd(ignores mentions, and works on Some.text)text.match(rexpr: str, flags=0, /)will evaluate tore.compile(rexpr, flags).match(Some.raw_text)text.between(*texts: str, to_set=True)will evaluate toSome.raw_text in textstext.can_be_int(base=10)will evaluate totry{int(Some.raw_text);return True;}except(ValueError){return False;}text.can_be_float()similarly totext.can_be_intbut for floats.
Operations on users' states.
from garnet.filters import State
State.anywill evaluate to match any state but notNoneState.entrywill evaluate toTrueif only current state isNoneState.exact(state: GroupT | M | "*")when "*" is passed will useState.any, when states group is passed will check if current state is any states from the group, when state group member (M) passed will check if current state is exactly this stateState == {some}will callState.exact(state=some)
State filter has effect on garnet.ctx.MCtx.
And if you're not sure what are you doing try not to apply logical operators on State filters.
Simply, don't do ~State.any or ~State.exact(...some...)
from garnet.filters import group
yes, "M" stands for member.
.nextreturn the nextMin the group or raisegroup.NoNextexception.prevreturn the previousMin the group or raisegroup.NoPrevexception.topreturn the top (head)Min the group or raisegroup.NoTopexception
Group of state members declared as a class (can be nested)
.firstreturns (M) the first declared member.lastreturns (M) the last declared member
Note.first and .last are reserved "keywords" for state
fromgarnet.filtersimportgroup, StateclassUsers(group.Group):
ask_name=group.M()
ask_age=group.M()
classPet(group.Group):
ask_name=group.M()
ask_age=group.M()
classHobby(group.Group):
frequency=group.M()
ask_if_popular=group.M()
# 💫 just imagine we already have router 💫@router.default(State.exact(Users)) # will handle all states in "Users"# --- some code ---@router.default(State.exact(Users.Pet.ask_age)) # will handle only if current state is equal to "Users.Pet.ask_age"# --- some code ---Think of groups as an immutable(not really...) linked list of connected group members
As you can see in the example above we use nested states groups.
One thing about about M.[next/prev/top].
We can go to Users.Pet.ask_name from Users.ask_age using Users.ask_age.next,
but not backwards as someone could expect with Users.Pet.ask_name.prev (will actually raise NoPrev)
Nested group members do not know anything about upper members, but they have "owners" which have access to their parent groups and
in order to access parent of owner of x = Users.Pet.ask_name, we would use x.owner
Operations on callback queries. Baker is a callback_data string generator/parser/validator. garnet.ctx::Query has
context value which is set after every successful validation.
from garnet.filters import QueryBaker
(prefix:str, /, *args:str, [ignore:Iterable[QItem]=(),][sep:str="\n",][maxlen:int=64])initializer function, if you want to have custom types in QueryDict.filter(extend_ignore:Iterable[str]=(), /, **config)will make sure user given callback data is valid by given config..get_checked(**non_ignored:Any)will return a string based on passed passed args
fromgarnet.filtersimportQueryBakerqb=QueryBaker(
"v", # set v string as identity(prefix) for our baker
("id", uuid.UUID), # make uuid.UUID a factory for id arg"act",
ignore=("id",), # mark id arg as `optional`sep=":", # set a separator for arg values, better not changemaxlen=64, # get_checked will check the length of generated callback and tell you if it's more than maxlen
)
# create v:{id}:{act} patternqb.filter(act="apply")
# will be a filter to match queries like "v:(.*):apply"qb.get_checked(id="51b3f442-a9f6-4dcc-918e-1f08b1189386", act="clear")
# will produce a "safe" string pattern v:51b3f442-a9f6-4dcc-918e-1f08b1189386:clear# Where you may want to use .get_checkedButton.inline(..., data=qb.get_checked(id=<...>, act="mpa"))Don't use separator string inside your arg values.
To reuse validated data from filter, use Query (validated dict)
Router (routing table) is a collection of handlers.
Those consist mainly from decorators.
Router(default_event=None, *filters)
default_eventdefault event builder for router*filtersrouter filters, in order to get into handlers, event should pass these filters.
Depending on event_builder of a decorator, filters inherit that event builder mutating themselves.
.default(*filters)event builder is default Router(this, ...), should not be None, must implementtelethon.common::EventBuilder.message(*filters)shortcut decorator for event buildergarnet.events::NewMessage.callback_query(*filters)shortcut decorator for event buildergarnet.events::CallbackQuery.chat_action(*filters)shortcut decorator for event buildergarnet.events::ChatAction.message_edited(*filters)shortcut decorator for event buildergarnet.events::MessageEdited.on(event_builder, /, *filters)pass any event builder (preferably fromgarnet.events::*).use()use this decorator for intermediates that are called after filters
.add_use(intermediate, /)register an intermediate which will be called after filters for handlers.register(handler, filters, event_builder)register handler with binding filters and event_builder to it..include(router, /)"include" passed router in the callee as its child router
fromgarnetimportRouter, events, Filterrouter=Router(events.NewMessage, Filter(lambda_: True), Filter(lambda_: True))
@router.default(Filter(lambda_: True))asyncdefhandler(_): passfrommy_project.routersimportpublic_router, admin_routerfrommy_project.loggingimportput_eventfromgarnetimportRouter, eventscommon_router=Router().include(public_router).include(admin_router)
@common_router.use()asyncdefintermediate(handler, event):
awaitput_event(event, nowait=True)
awaithandler(event)from garnet.ctx import StateCtx, MCtx
MCtx is context variable that points to the current states group member (use it carefully)
it's set in State filters
StateCtx points to garnet.event::UserCage
from garnet.ctx import UserIDCtx, ChatIDCtx
Those will be set after router filters and before handler filters and handlers calls.
from garnet.ctx import HandlerCtx
HandlerCtx points to currently executing handler.
Data that is stored in Dict[str(arg name), T(arg type from arg-factory(arg-str)->T)]
from garnet.ctx import Query
Usual contextual variables, with .get(), .set(), .reset() methods. You'll always end up using .get().
Work with those only in handlers or handler filters.
Also every event builder in garnet.events is "contextfull", but for get, set, reset you shall add _current
postfix.
Try to use context variables everywhere not depending on other mechanisms, because they work as you want.
Garnet consists of two interfaces _garnet and garnet, garnet is a "public" interface that should have somewhat stable interfaces and _garnet which is internal and considered as non-public
git clone git@github.com:ukinti/garnet.git garnet poetry install --dev poetry shell
# simply make lint
Join our small group