Package: telegram-garnet
garnet is a ridiculously simple library created mainly for managing your stateful telegram bots written with Telethon.How to install?Although, garnet isgarnet, it is namedtelegram-garneton the PyPI, you’ll have to tell that to pip.pip install-Utelegram-garnetLet’s dive in# 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()))Key featuresFiltersBasicallyFilteris 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.Public methods.is_event_naive->bool.call(e: T, /)->Awaitable[bool]InitializerFilter(function[, event_builder])Value of the parameterfunctionmust be function that takes exactly one argument with typeOptional[Some]and
returnsbooleither True or False.Possible operations on Filter instances(those are, primarily logical operators)Binary&is a logical AND for two filters|is a logical OR for two filters^is a logical XOR for two filtersUnary~is a logical NOT for a filterExamplesfromgarnetimportFilter,eventsasyncdeffun(_):...# example of event aware filterFilter(fun,events.NewMessage)# example of event-naiveFilter(fun)By defaultFilteris event-naive, however when using withgarnet::Routerfor handlers it may be changed.Filters “from the box”Text filtersOperations onFilter((e:Some)->bool); Some.raw_text or Some.textImportfrom garnet.filters import textLittle journeytext.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 onSome.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);returnTrue;}except(ValueError){returnFalse;}text.can_be_float()similarly totext.can_be_intbut for floats.State filtersOperations on users’ states.Importfrom garnet.filters import StateLittle journeyState.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)NoteState filter has effect ongarnet.ctx.MCtx.
And if you’re not sure what are you doing try not to apply logical operators onStatefilters.
Simply, don’t do~State.anyor~State.exact(...some...)States declarationImportfrom garnet.filters import groupgroup.M (state group Member)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.NoTopexceptiongroup.GroupGroup of state members declared as a class (can be nested).firstreturns (M) the first declared member.lastreturns (M) the last declared memberNote.firstand.lastare reserved “keywords” for stateUsagefromgarnet.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 ---NoteThink 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 aboutM.[next/prev/top].
We can go toUsers.Pet.ask_namefromUsers.ask_ageusingUsers.ask_age.next,
but not backwards as someone could expect withUsers.Pet.ask_name.prev(will actually raiseNoPrev)
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 ofx = Users.Pet.ask_name, we would usex.ownerCallback query (QueryBaker)Operations on callback queries. Baker is acallback_datastring generator/parser/validator.garnet.ctx::Queryhas
context value which is set after every successful validation.Importfrom garnet.filters import QueryBakerLittle journey(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 argsUsagefromgarnet.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"))NoteDon’t use separator string inside your arg values.To reuse validated data from filter, useQuery (validated dict)RoutersRouter (routing table) is a collection of handlers.Public methodsThose consist mainly from decorators.InitializerRouter(default_event=None, *filters)default_eventdefault event builder for router*filtersrouter filters, in order to get into handlers, event should pass these filters.DecoratorsDepending onevent_builderof 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 filtersetc..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 routerExamplesSimple casesfromgarnetimportRouter,events,Filterrouter=Router(events.NewMessage,Filter(lambda_:True),Filter(lambda_:True))@router.default(Filter(lambda_:True))asyncdefhandler(_):passNested routers and a little intermediatefrommy_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)Context variablesUsers statesfrom garnet.ctx import StateCtx, MCtxMCtxis context variable that points to the current states group member (use it carefully)
it’s set inStatefiltersStateCtxpoints togarnet.event::UserCageUser and chat IDsfrom garnet.ctx import UserIDCtx, ChatIDCtxThose will be set after router filters and before handler filters and handlers calls.Handlerfrom garnet.ctx import HandlerCtxHandlerCtxpoints to currently executing handler.Query (validated dict)Data that is stored in Dict[str(arg name), T(arg type from arg-factory(arg-str)->T)]from garnet.ctx import QueryNoteUsual 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 ingarnet.eventsis “contextfull”, but forget,set,resetyou shall add_currentpostfix.Try to use context variables everywhere not depending on other mechanisms, because they work as you want.🦾 Hacking garnetGarnet consists of two interfaces_garnetandgarnet,garnetis a “public” interface that should have somewhat stable interfaces
and_garnetwhich isinternaland considered asnon-publicInstall and get startedgit clone git@github.com:ukinti/garnet.git garnet
poetry install --dev
poetry shellApplying code-style# simply
make lint💬 Contacts/CommunityJoin our smallgroup