Skip to content
This repository was archived by the owner on Jul 4, 2024. It is now read-only.
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions botkit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@

from importlib.metadata import version

from ._settings import _BotkitSettings

try:
__version__ = version(__name__)
except:
__version__ = None

settings = _BotkitSettings()
botkit_settings = settings
5 changes: 1 addition & 4 deletions botkit/settings.py → botkit/_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class _BotkitSettings:

# region Callback manager

callback_manager_qualifier: Literal["memory", "redis"] = "memory"
callback_store_qualifier: Literal["memory", "redis"] = "memory"
"""
Qualifier key of the kind of callback manager to be used. Should be "memory" for an in-memory store (without
persistence) and "redis" if you have the `redis_collections` package installed.
Expand Down Expand Up @@ -60,6 +60,3 @@ def log_level(self, value: Optional[str]) -> None:
self._current_log_level = value

# endregion


botkit_settings = _BotkitSettings()
2 changes: 1 addition & 1 deletion botkit/agnostic/_pyrogram_update_type_inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from boltons.iterutils import flatten
from pyrogram.handlers.handler import Handler

from botkit.routing.update_types.updatetype import UpdateType
from tgtypes.updatetype import UpdateType
from botkit.utils.typed_callable import TypedCallable

PYROGRAM_UPDATE_TYPES: Dict[Type[pyrogram.types.Update], UpdateType] = {
Expand Down
2 changes: 1 addition & 1 deletion botkit/agnostic/annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
)

from botkit.agnostic.library_checks import is_installed
from botkit.views.botkit_context import Context
from botkit.botkit_context import Context

if TYPE_CHECKING:
from botkit.clients.client import IClient
Expand Down
83 changes: 61 additions & 22 deletions botkit/agnostic/pyrogram_chat_resolver.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
from typing import Pattern, cast
import asyncio
from datetime import timedelta
from typing import (
Optional,
Pattern,
cast,
)

from pyrogram.errors import BotMethodInvalid

from botkit.tghelpers.names import display_name
from botkit.utils.botkit_logging.setup import create_logger
from tgtypes.interfaces.resolvercache import IResolverCache
from tgtypes.persistence.json_file_resolver_cache import JsonFileResolverCache
from tgtypes.utils.debounce import DebouncedTask
from tgtypes.primitives import Username
from tgtypes.identities.chat_identity import ChatIdentity, ChatType
from tgtypes.interfaces.chatresolver import IChatResolver
from tgtypes.primitives import Username
from tgtypes.utils.async_lazy_dict import AsyncLazyDict

try:
# TODO: Turn this into a contextmanager, `with lib_check('Pyrogram'): import ...`
from pyrogram import Client as PyrogramClient
from pyrogram.types import Message, User
from pyrogram.types import Chat, Message, User
except ImportError as e:
raise ImportError(
"The Pyrogram library does not seem to be installed, so using Botkit in Pyrogram flavor is not possible. "
Expand All @@ -19,30 +30,58 @@


class PyrogramChatResolver(IChatResolver):
def __init__(self, client: PyrogramClient):
def __init__(self, client: PyrogramClient, cache: Optional[IResolverCache] = None):
self.client = client
self.context = AsyncLazyDict()
self.cache: IResolverCache = cache or JsonFileResolverCache()
self._iter_dialogs_lock = asyncio.Lock()
self._save_func = DebouncedTask(
lambda: self.cache.dump_data(), delta=timedelta(seconds=20), num_runs=3
)

async def resolve_chat_by_username(self, username: Username) -> ChatIdentity:
chat = await self.context.setdefault_lazy("chat", self.client.get_chat(username))
await self.cache.ensure_initialized()
chat = await self.cache.setdefault_lazy("chat", self.client.get_chat(username))
return ChatIdentity(type=cast(ChatType, chat.type), peers=chat.id)

async def resolve_chat_by_chat_id(self, chat_id: int) -> ChatIdentity:
chat = await self.context.setdefault_lazy("chat", self.client.get_chat(chat_id))
await self.cache.ensure_initialized()
chat = await self.cache.setdefault_lazy("chat", self.client.get_chat(chat_id))
return ChatIdentity(type=cast(ChatType, chat.type), peers=chat.id)

async def resolve_chat_by_title_regex(self, title_regex: Pattern) -> ChatIdentity:
LIMIT = 1000

async for d in self.client.iter_dialogs(limit=LIMIT):
# noinspection PyUnboundLocalVariable
if (
(chat := getattr(d, "chat", None))
and (title := getattr(chat, "title", None))
and title_regex.match(title)
):
return ChatIdentity(type=cast(ChatType, chat.type), peers=chat.id)

raise ValueError(
f"No chat found matching pattern {title_regex} in the uppermost {LIMIT} dialogs."
)
await self.cache.ensure_initialized()
LIMIT = 500

self.cache.setdefault("identity_titles", [])

try:
# In order to make use of caching, disallow running multiple iter_dialogs methods concurrently
async with self._iter_dialogs_lock:
# Check cached items first
for ident, t in self.cache["identity_titles"]:
if title_regex.match(t):
return ident

async for d in self.client.iter_dialogs(limit=LIMIT):
chat: Chat = getattr(d, "chat", None)

if not chat:
continue

title: str = display_name(chat)
identity = ChatIdentity(type=cast(ChatType, chat.type), peers=chat.id)

self.cache["identity_titles"].append((identity, title))

# noinspection PyUnboundLocalVariable
if title_regex.match(title):
return identity

raise ValueError(
f"No chat found matching pattern {title_regex} in the uppermost {LIMIT} dialogs."
)
except BotMethodInvalid as ex:
raise ValueError(
"Method invalid: Bots cannot read chat lists and thus not match via `title_regex`. "
"You should resolve with a user client instead."
) from ex
14 changes: 7 additions & 7 deletions botkit/views/botkit_context.py → botkit/botkit_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
from typing import Any, Generic, Iterator, Optional, TypeVar

from botkit.dispatching.types import CallbackActionType
from botkit.views.rendered_messages import RenderedMessage
from tgtypes.identities.chat_identity import ChatIdentity
from tgtypes.identities.message_identity import MessageIdentity
from tgtypes.update_field_extractor import UpdateFieldExtractor
from .rendered_messages import RenderedMessage
from ..routing.types import TViewState
from ..routing.update_types.updatetype import UpdateType
from botkit.dispatching.update_field_extractor import UpdateFieldExtractor
from tgtypes.updatetype import UpdateType
from botkit.routing.types import TViewState

TPayload = TypeVar("TPayload")

Expand Down Expand Up @@ -46,14 +46,14 @@ def __init__(self):


@dataclass
class Context(Generic[TViewState, TPayload], UpdateFieldExtractor): # TODO: maybe `RouteContext`?
class Context(UpdateFieldExtractor): # TODO: maybe `RouteContext`?
# TODO: rename to `view_state`?
# TODO: maybe this shouldn't even be part of the context but always be passed separately (because of reducers)?
update_type: UpdateType
view_state: TViewState
view_state: Any

action: Optional[CallbackActionType] = None
payload: Optional[TPayload] = None
payload: Optional[Any] = None

message_state: Optional[Any] = None # TODO: wtf
user_state: Optional[UserState] = None
Expand Down
63 changes: 10 additions & 53 deletions botkit/builders/__init__.py
Original file line number Diff line number Diff line change
@@ -1,60 +1,17 @@
from typing import Any, TYPE_CHECKING

from haps import Container, inject
from injector import Binder

from .callbackbuilder import CallbackBuilder

if TYPE_CHECKING:
from botkit.widgets import Widget

from .htmlbuilder import HtmlBuilder
from .menubuilder import MenuBuilder
from .metabuilder import MetaBuilder
from ..persistence.callback_store import ICallbackStore
from ..settings import botkit_settings
from ..views.rendered_messages import RenderedMessage, RenderedTextMessage


class ViewBuilder:
html: HtmlBuilder
menu: MenuBuilder
meta: MetaBuilder

def __init__(self, callback_builder: CallbackBuilder):
self.html = HtmlBuilder(callback_builder)
self.menu = MenuBuilder(callback_builder)
self.meta = MetaBuilder()

def add(self, widget: "Widget"):
self.html.add(widget)
self.menu.add(widget)
self.meta.add(widget)
widget.render_html(self.html)

@property
def is_dirty(self) -> bool:
return any((x.is_dirty for x in [self.html, self.menu, self.meta]))

def render(self) -> RenderedMessage:
# TODO: implement the other message types aswell
html_text = self.html.render_html()
rendered_menu = self.menu.render()
return RenderedTextMessage(
text=html_text,
inline_buttons=rendered_menu,
title=self.meta.title,
description=self.meta.description,
)
from .quizbuilder import QuizBuilder
from .viewbuilder import ViewBuilder


# def _determine_message_type(msg: RenderedMessageMarkup) -> MessageType:
# if isinstance(msg, RenderedMessage):
# if msg.media and msg.sticker: # keep this check updated with new values!
# raise ValueError("Ambiguous message type.")
# if msg.sticker:
# return MessageType.sticker
# elif msg.media:
# return MessageType.media
# return MessageType.text
# elif isinstance(msg, RenderedPollMessage):
# return MessageType.poll
def configure_builders(binder: Binder) -> None:
binder.bind(CallbackBuilder)
binder.bind(QuizBuilder)
binder.bind(HtmlBuilder)
binder.bind(MenuBuilder)
binder.bind(MetaBuilder)
binder.bind(ViewBuilder)
3 changes: 3 additions & 0 deletions botkit/builders/callbackbuilder.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from contextlib import contextmanager
from typing import Any, Literal, Optional

from injector import NoInject, inject

from botkit.abstractions._named import INamed
from botkit.core.services import service
from botkit.dispatching.types import CallbackActionType
Expand All @@ -12,6 +14,7 @@
class CallbackBuilder:
_SEPARATOR = "##"

@inject
def __init__(self, state: TViewState, callback_store: ICallbackStore):
self.state = state
self._callback_store = callback_store
Expand Down
42 changes: 41 additions & 1 deletion botkit/builders/htmlbuilder.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,58 @@
from typing import Any, Callable, NoReturn, TYPE_CHECKING, Union
from typing import Any, Callable, List, NoReturn, TYPE_CHECKING, Union
from injector import inject

from botkit.builders import CallbackBuilder
from botkit.builders.text.basetextbuilder import TState
from botkit.builders.text.htmltextbuilder import _HtmlTextBuilder
from botkit.builders.text.telegram_entity_builder import EntityBuilder
from botkit.builders.text.typographybuilder import TypographyBuilder

if TYPE_CHECKING:
from botkit.widgets import HtmlWidget

"""
# More ideas:

- `html.desc("https://blabla", "")` --> ""
"""


class HtmlBuilder(TypographyBuilder, EntityBuilder):
@inject
def __init__(self, callback_builder: CallbackBuilder = None):
super().__init__(callback_builder)

def add(self, widget: "HtmlWidget") -> "HtmlBuilder":
with self.callback_builder.scope(widget):
widget.render_html(self)
return self

s = _HtmlTextBuilder.strike
u = _HtmlTextBuilder.underline
i = _HtmlTextBuilder.italic
b = _HtmlTextBuilder.bold
lin = _HtmlTextBuilder.link


HtmlRenderer = Callable[[TState, HtmlBuilder], Union[NoReturn, Any]]


if __name__ == "__main__":
from botkit.widgets import HtmlWidget

class ListView(HtmlWidget):
def __init__(self, items: List[Any]):
self.items = items

unique_name = "my_list_view"

def render_html(self, html: HtmlBuilder):
html.list(self.items)

html = HtmlBuilder(None)

html.add(ListView(["henlo", "fren", "waddup"]))

html("I am ").b("testing")

print(html.render_html())
10 changes: 7 additions & 3 deletions botkit/builders/text/basetextbuilder.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,19 @@
from haps import Container
from typing import Any, Optional, TypeVar

from injector import inject
from pyrogram.parser import Parser
from pyrogram.types.messages_and_media.message import Str

from botkit.builders.callbackbuilder import CallbackBuilder
from botkit.persistence.callback_store import ICallbackStore
from botkit.settings import botkit_settings

TState = TypeVar("TState")


class BaseTextBuilder:
def __init__(self, callback_builder: CallbackBuilder): # TODO: make non-optional
@inject
def __init__(self, callback_builder: CallbackBuilder):
self.parts = []
self.callback_builder = callback_builder

Expand All @@ -32,8 +33,11 @@ def br(self, count: int = 1):
self.parts.append("\n" * count)
return self

def as_para(cls):
return "\n\n"

def para(self):
self.parts.append("\n\n")
self.parts.append(self.as_para())
return self

def _append(self, text: str):
Expand Down
4 changes: 3 additions & 1 deletion botkit/builders/text/emoji.py
Original file line number Diff line number Diff line change
Expand Up @@ -1274,6 +1274,8 @@
}

aliases_unicode = {
":duck:": "🦆",
":crossed_fingers:": "🤞",
":turtle:": "🐢",
":bike:": "🚲",
":family_mwg:": "👨👩👧",
Expand Down Expand Up @@ -6373,7 +6375,7 @@ def contains_emoji(text: str):
return False


def replace_aliases(sentence: str):
def replace_emoji_aliases(sentence: str):
pattern = r"(:[A-Za-z0-9_-]+:)"
matches = re.search(pattern, sentence)
if matches is None:
Expand Down
Loading