telegroq 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- telegroq/__init__.py +78 -0
- telegroq/_groq.py +62 -0
- telegroq/bot.py +494 -0
- telegroq/character.py +217 -0
- telegroq/errors.py +23 -0
- telegroq/fallbacks.py +53 -0
- telegroq/keyboards.py +60 -0
- telegroq/message.py +150 -0
- telegroq/py.typed +0 -0
- telegroq/storage.py +108 -0
- telegroq-0.0.1.dist-info/METADATA +246 -0
- telegroq-0.0.1.dist-info/RECORD +14 -0
- telegroq-0.0.1.dist-info/WHEEL +4 -0
- telegroq-0.0.1.dist-info/licenses/LICENSE +21 -0
telegroq/__init__.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""telegroq: a Groq-powered Telegram bot with a custom persona in a few lines.
|
|
2
|
+
|
|
3
|
+
A thin, friendly bridge between `pyTelegramBotAPI <https://github.com/eternnoir/pyTelegramBotAPI>`_
|
|
4
|
+
and the `Groq API <https://console.groq.com/docs>`_::
|
|
5
|
+
|
|
6
|
+
import telegroq
|
|
7
|
+
|
|
8
|
+
bot = telegroq.createBot("123456:TELEGRAM_TOKEN")
|
|
9
|
+
bot.set_groq_key("gsk_...", model="llama-3.3-70b-versatile")
|
|
10
|
+
character = telegroq.setCh("You are a witty assistant.", max_history=10)
|
|
11
|
+
|
|
12
|
+
bot.auto_reply(character) # or write your own @bot.on_message() handler
|
|
13
|
+
bot.run()
|
|
14
|
+
|
|
15
|
+
Public API
|
|
16
|
+
----------
|
|
17
|
+
Factories: :func:`createBot`, :func:`setCh`
|
|
18
|
+
Classes: :class:`TeleGroqBot`, :class:`Character`, :class:`Reply`,
|
|
19
|
+
:class:`Message`, :class:`CallbackContext`
|
|
20
|
+
Keyboards: :func:`button`, :func:`inline_keyboard`
|
|
21
|
+
Storage: :class:`HistoryStore`, :class:`MemoryHistoryStore`, :class:`SQLiteHistoryStore`
|
|
22
|
+
Misc: :func:`configure_groq`, :data:`DEFAULT_FALLBACKS`, :class:`TeleGroqError`,
|
|
23
|
+
:class:`ConfigurationError`
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from ._groq import DEFAULT_MODEL, configure_groq
|
|
32
|
+
from .bot import TeleGroqBot, createBot
|
|
33
|
+
from .character import Character, Reply
|
|
34
|
+
from .errors import ConfigurationError, TeleGroqError
|
|
35
|
+
from .fallbacks import DEFAULT_FALLBACKS, classify_error
|
|
36
|
+
from .keyboards import button, inline_keyboard
|
|
37
|
+
from .message import CallbackContext, Message
|
|
38
|
+
from .storage import HistoryStore, MemoryHistoryStore, SQLiteHistoryStore
|
|
39
|
+
|
|
40
|
+
__version__ = "0.0.1"
|
|
41
|
+
|
|
42
|
+
# Library etiquette: stay silent unless the application configures logging.
|
|
43
|
+
logging.getLogger("telegroq").addHandler(logging.NullHandler())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def setCh(system_prompt: Any, max_history: int = 10, **kwargs: Any) -> Character:
|
|
47
|
+
"""Create a :class:`Character` (an AI persona with per-user memory).
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
system_prompt: The persona's instructions (or a callable ``(user_id) -> str``).
|
|
51
|
+
max_history: Messages remembered per user (``0`` = stateless).
|
|
52
|
+
**kwargs: ``bot``, ``client``, ``model``, ``temperature``, ``max_tokens``,
|
|
53
|
+
``extra_params``, ``fallbacks``, ``store``. See :class:`Character`.
|
|
54
|
+
"""
|
|
55
|
+
return Character(system_prompt, max_history, **kwargs)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"__version__",
|
|
60
|
+
"DEFAULT_FALLBACKS",
|
|
61
|
+
"DEFAULT_MODEL",
|
|
62
|
+
"CallbackContext",
|
|
63
|
+
"Character",
|
|
64
|
+
"ConfigurationError",
|
|
65
|
+
"HistoryStore",
|
|
66
|
+
"MemoryHistoryStore",
|
|
67
|
+
"Message",
|
|
68
|
+
"Reply",
|
|
69
|
+
"SQLiteHistoryStore",
|
|
70
|
+
"TeleGroqBot",
|
|
71
|
+
"TeleGroqError",
|
|
72
|
+
"button",
|
|
73
|
+
"classify_error",
|
|
74
|
+
"configure_groq",
|
|
75
|
+
"createBot",
|
|
76
|
+
"inline_keyboard",
|
|
77
|
+
"setCh",
|
|
78
|
+
]
|
telegroq/_groq.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Groq client construction and the process-wide default configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .errors import ConfigurationError
|
|
9
|
+
|
|
10
|
+
__all__ = ["DEFAULT_MODEL", "GroqConfig", "build_client", "configure_groq", "get_default"]
|
|
11
|
+
|
|
12
|
+
DEFAULT_MODEL = "llama-3.3-70b-versatile"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class GroqConfig:
|
|
17
|
+
"""A Groq client together with the model to use by default."""
|
|
18
|
+
|
|
19
|
+
client: Any
|
|
20
|
+
model: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_default: GroqConfig | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_client(api_key: str, *, timeout: float = 30.0, max_retries: int = 2) -> Any:
|
|
27
|
+
"""Create a ``groq.Groq`` client (imports the SDK lazily).
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
ConfigurationError: if ``api_key`` is empty.
|
|
31
|
+
ImportError: if the ``groq`` package is not installed.
|
|
32
|
+
"""
|
|
33
|
+
if not isinstance(api_key, str) or not api_key.strip():
|
|
34
|
+
raise ConfigurationError("api_key must be a non-empty string")
|
|
35
|
+
try:
|
|
36
|
+
import groq
|
|
37
|
+
except ImportError as exc: # pragma: no cover - depends on the environment
|
|
38
|
+
raise ImportError("telegroq needs the Groq SDK. Install it with: pip install groq") from exc
|
|
39
|
+
return groq.Groq(api_key=api_key.strip(), timeout=timeout, max_retries=max_retries)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def configure_groq(
|
|
43
|
+
api_key: str,
|
|
44
|
+
model: str = DEFAULT_MODEL,
|
|
45
|
+
*,
|
|
46
|
+
timeout: float = 30.0,
|
|
47
|
+
max_retries: int = 2,
|
|
48
|
+
) -> GroqConfig:
|
|
49
|
+
"""Create a Groq client and make it the process-wide default.
|
|
50
|
+
|
|
51
|
+
Characters that are not bound to a bot (or given an explicit ``client``) use
|
|
52
|
+
the default. ``TeleGroqBot.set_groq_key`` calls this for you.
|
|
53
|
+
"""
|
|
54
|
+
global _default
|
|
55
|
+
config = GroqConfig(build_client(api_key, timeout=timeout, max_retries=max_retries), model)
|
|
56
|
+
_default = config
|
|
57
|
+
return config
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_default() -> GroqConfig | None:
|
|
61
|
+
"""Return the most recently configured :class:`GroqConfig`, if any."""
|
|
62
|
+
return _default
|
telegroq/bot.py
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
"""The :class:`TeleGroqBot` class: routing, sending helpers and the polling loop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import telebot
|
|
11
|
+
from telebot import types, util
|
|
12
|
+
from telebot.apihelper import ApiTelegramException
|
|
13
|
+
|
|
14
|
+
from . import _groq
|
|
15
|
+
from .character import Character
|
|
16
|
+
from .errors import ConfigurationError
|
|
17
|
+
from .message import CallbackContext, Message
|
|
18
|
+
|
|
19
|
+
__all__ = ["TeleGroqBot", "createBot"]
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("telegroq")
|
|
22
|
+
|
|
23
|
+
Handler = Callable[[types.User, Message], Any]
|
|
24
|
+
CallbackHandler = Callable[[types.User, CallbackContext], Any]
|
|
25
|
+
ErrorHandler = Callable[[BaseException, types.User, Message | CallbackContext], Any]
|
|
26
|
+
|
|
27
|
+
_NON_TEXT_TYPES = [t for t in util.content_type_media if t != "text"]
|
|
28
|
+
_COMMAND_NAME = re.compile(r"^[a-z0-9_]{1,32}$")
|
|
29
|
+
_HAS_REPLY_PARAMETERS = hasattr(types, "ReplyParameters")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _quote_kwargs(message_id: int) -> dict[str, Any]:
|
|
33
|
+
"""Keyword arguments that make ``send_message`` quote ``message_id``."""
|
|
34
|
+
if _HAS_REPLY_PARAMETERS:
|
|
35
|
+
return {"reply_parameters": types.ReplyParameters(message_id, allow_sending_without_reply=True)}
|
|
36
|
+
return {"reply_to_message_id": message_id, "allow_sending_without_reply": True} # pragma: no cover
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TeleGroqBot:
|
|
40
|
+
"""A Telegram bot (wrapping ``telebot.TeleBot``) with Groq helpers.
|
|
41
|
+
|
|
42
|
+
Create it with :func:`telegroq.createBot`. The underlying ``TeleBot`` is
|
|
43
|
+
available as :attr:`telebot`, and unknown attributes (``bot.send_photo``,
|
|
44
|
+
``bot.get_me`` ...) are forwarded to it, so everything telebot can do is
|
|
45
|
+
still within reach.
|
|
46
|
+
|
|
47
|
+
Handlers registered with :meth:`cmd`, :meth:`on_message` and :meth:`callback`
|
|
48
|
+
are called as ``handler(user, msg)`` / ``handler(user, call)``; an exception
|
|
49
|
+
inside one is logged and never stops the polling loop.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
token: Telegram bot token from @BotFather (``"<digits>:<secret>"``).
|
|
53
|
+
non_text_reply: Sent in private chats for media nobody handles
|
|
54
|
+
(photos, stickers, voice notes ...). ``None`` stays silent.
|
|
55
|
+
error_reply: Sent when one of your handlers raises. ``None`` stays
|
|
56
|
+
silent. Ignored once you register :meth:`on_error`.
|
|
57
|
+
**telebot_kwargs: Passed to ``telebot.TeleBot`` (``parse_mode``,
|
|
58
|
+
``threaded``, ``num_threads`` ...). ``num_threads`` defaults to 8
|
|
59
|
+
because Groq calls block a worker thread.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
token: str,
|
|
65
|
+
*,
|
|
66
|
+
non_text_reply: str | None = "I can only read text messages for now. \U0001f4dd",
|
|
67
|
+
error_reply: str | None = "Oops, something went wrong on my side. Please try again.",
|
|
68
|
+
**telebot_kwargs: Any,
|
|
69
|
+
) -> None:
|
|
70
|
+
telebot_kwargs.setdefault("num_threads", 8)
|
|
71
|
+
self.telebot = telebot.TeleBot(token, **telebot_kwargs)
|
|
72
|
+
self.groq_client: Any | None = None
|
|
73
|
+
self.model: str = _groq.DEFAULT_MODEL
|
|
74
|
+
self.non_text_reply = non_text_reply
|
|
75
|
+
self.error_reply = error_reply
|
|
76
|
+
self._commands: set[str] = set()
|
|
77
|
+
self._menu: dict[str, str] = {}
|
|
78
|
+
self._claimed_types: set[str] = set()
|
|
79
|
+
self._fallback_registered = False
|
|
80
|
+
self._error_handler: ErrorHandler | None = None
|
|
81
|
+
|
|
82
|
+
def __getattr__(self, name: str) -> Any:
|
|
83
|
+
# Only reached for attributes TeleGroqBot does not define itself.
|
|
84
|
+
if name == "telebot" or (name.startswith("__") and name.endswith("__")):
|
|
85
|
+
raise AttributeError(name)
|
|
86
|
+
return getattr(self.telebot, name)
|
|
87
|
+
|
|
88
|
+
def __repr__(self) -> str:
|
|
89
|
+
return f"<TeleGroqBot id={self.telebot.bot_id} model={self.model!r}>"
|
|
90
|
+
|
|
91
|
+
# ------------------------------------------------------------------- Groq
|
|
92
|
+
def set_groq_key(
|
|
93
|
+
self,
|
|
94
|
+
api_key: str,
|
|
95
|
+
model: str = _groq.DEFAULT_MODEL,
|
|
96
|
+
*,
|
|
97
|
+
timeout: float = 30.0,
|
|
98
|
+
max_retries: int = 2,
|
|
99
|
+
) -> TeleGroqBot:
|
|
100
|
+
"""Create the Groq client used by every :class:`~telegroq.Character`.
|
|
101
|
+
|
|
102
|
+
A character uses the client of the bot it is bound to (``setCh(...,
|
|
103
|
+
bot=bot)`` or ``character.bind(bot)``), otherwise that of the most
|
|
104
|
+
recently configured bot.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
api_key: Groq API key (``gsk_...``).
|
|
108
|
+
model: Default model id, e.g. ``"llama-3.3-70b-versatile"``.
|
|
109
|
+
timeout: Per-request timeout in seconds.
|
|
110
|
+
max_retries: Automatic retries done by the Groq SDK.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
The bot itself, so calls can be chained.
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
ConfigurationError: if ``api_key`` is empty.
|
|
117
|
+
ImportError: if the ``groq`` package is missing.
|
|
118
|
+
"""
|
|
119
|
+
config = _groq.configure_groq(api_key, model, timeout=timeout, max_retries=max_retries)
|
|
120
|
+
self.groq_client, self.model = config.client, config.model
|
|
121
|
+
return self
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------- routing
|
|
124
|
+
def cmd(
|
|
125
|
+
self,
|
|
126
|
+
*names: str,
|
|
127
|
+
description: str | None = None,
|
|
128
|
+
chat_types: list[str] | None = None,
|
|
129
|
+
) -> Callable[[Handler], Handler]:
|
|
130
|
+
"""Register ``handler(user, msg)`` for one or more commands.
|
|
131
|
+
|
|
132
|
+
``@bot.cmd("/start")``, ``@bot.cmd("start")`` and ``@bot.cmd("help", "about")``
|
|
133
|
+
all work, in any letter case. Arguments are in ``msg.args``.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
*names: Command names, with or without the leading slash.
|
|
137
|
+
description: Optional text for Telegram's command menu (the "/" button);
|
|
138
|
+
published for the first name by :meth:`publish_commands` / :meth:`run`.
|
|
139
|
+
chat_types: Restrict to e.g. ``["private"]``.
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
ConfigurationError: for an empty or invalid name (Telegram allows
|
|
143
|
+
lowercase letters, digits and underscores, up to 32 characters).
|
|
144
|
+
"""
|
|
145
|
+
cleaned = [n.strip().lstrip("/").lower() for n in names if n and n.strip().lstrip("/")]
|
|
146
|
+
if not cleaned:
|
|
147
|
+
raise ConfigurationError("cmd() needs at least one command name, e.g. @bot.cmd('/start')")
|
|
148
|
+
for name in cleaned:
|
|
149
|
+
if not _COMMAND_NAME.match(name):
|
|
150
|
+
raise ConfigurationError(f"invalid command name {name!r}: use a-z, 0-9 and _ (max 32 chars)")
|
|
151
|
+
self._commands.update(cleaned)
|
|
152
|
+
if description:
|
|
153
|
+
self._menu[cleaned[0]] = description
|
|
154
|
+
wanted = set(cleaned)
|
|
155
|
+
|
|
156
|
+
def matches(m: types.Message) -> bool:
|
|
157
|
+
# telebot's own ``commands=`` filter is case-sensitive; Telegram clients are not.
|
|
158
|
+
if m.content_type != "text" or not m.text:
|
|
159
|
+
return False
|
|
160
|
+
name = util.extract_command(m.text)
|
|
161
|
+
return name is not None and name.lower() in wanted
|
|
162
|
+
|
|
163
|
+
def decorator(fn: Handler) -> Handler:
|
|
164
|
+
self.telebot.message_handler(content_types=["text"], chat_types=chat_types, func=matches)(
|
|
165
|
+
self._guard(fn)
|
|
166
|
+
)
|
|
167
|
+
return fn
|
|
168
|
+
|
|
169
|
+
return decorator
|
|
170
|
+
|
|
171
|
+
def on_message(
|
|
172
|
+
self,
|
|
173
|
+
content_types: list[str] | None = None,
|
|
174
|
+
*,
|
|
175
|
+
chat_types: list[str] | None = None,
|
|
176
|
+
regexp: str | None = None,
|
|
177
|
+
func: Callable[[types.Message], bool] | None = None,
|
|
178
|
+
typing: bool = True,
|
|
179
|
+
) -> Callable[[Handler], Handler]:
|
|
180
|
+
"""Register a wildcard ``handler(user, msg)`` (text messages by default).
|
|
181
|
+
|
|
182
|
+
Registered commands are never routed here, whatever the registration
|
|
183
|
+
order. Pass ``content_types=["text", "photo"]`` to receive other kinds
|
|
184
|
+
too (``msg.text`` is ``""`` for those).
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
content_types: Telegram content types to accept (default ``["text"]``).
|
|
188
|
+
chat_types: Restrict to e.g. ``["private"]`` (recommended in groups).
|
|
189
|
+
regexp: Only messages whose text matches this regular expression.
|
|
190
|
+
func: Extra filter ``(telebot_message) -> bool``.
|
|
191
|
+
typing: Show "typing..." while your handler (and thus Groq) works.
|
|
192
|
+
"""
|
|
193
|
+
accepted = list(content_types or ["text"])
|
|
194
|
+
self._claimed_types.update(accepted)
|
|
195
|
+
|
|
196
|
+
def not_a_command(m: types.Message) -> bool:
|
|
197
|
+
if m.content_type == "text" and m.text:
|
|
198
|
+
name = util.extract_command(m.text)
|
|
199
|
+
if name is not None and name.lower() in self._commands:
|
|
200
|
+
return False
|
|
201
|
+
return func(m) if func else True
|
|
202
|
+
|
|
203
|
+
def decorator(fn: Handler) -> Handler:
|
|
204
|
+
self.telebot.message_handler(
|
|
205
|
+
content_types=accepted, chat_types=chat_types, regexp=regexp, func=not_a_command
|
|
206
|
+
)(self._guard(fn, typing=typing))
|
|
207
|
+
return fn
|
|
208
|
+
|
|
209
|
+
return decorator
|
|
210
|
+
|
|
211
|
+
def auto_reply(
|
|
212
|
+
self,
|
|
213
|
+
character: Character,
|
|
214
|
+
*,
|
|
215
|
+
chat_types: list[str] | None = None,
|
|
216
|
+
quote: bool | None = None,
|
|
217
|
+
typing: bool = True,
|
|
218
|
+
) -> Handler:
|
|
219
|
+
"""Answer every non-command text message with ``character`` (the few-lines path).
|
|
220
|
+
|
|
221
|
+
Equivalent to a hand-written ``@bot.on_message()`` handler that calls
|
|
222
|
+
``character.get_response`` and replies.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
character: The persona that answers.
|
|
226
|
+
chat_types: Restrict to e.g. ``["private"]``.
|
|
227
|
+
quote: Quote the user's message in the reply. Default: only in groups.
|
|
228
|
+
typing: Show "typing..." while waiting for Groq.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
The registered handler function.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
def _auto_reply(user: types.User, msg: Message) -> None:
|
|
235
|
+
answer = character.get_response(user.id, msg.text)
|
|
236
|
+
if quote or (quote is None and not msg.is_private):
|
|
237
|
+
msg.reply(answer)
|
|
238
|
+
else:
|
|
239
|
+
self.send_message(msg.chat_id, answer)
|
|
240
|
+
|
|
241
|
+
self.on_message(chat_types=chat_types, typing=typing)(_auto_reply)
|
|
242
|
+
return _auto_reply
|
|
243
|
+
|
|
244
|
+
def callback(
|
|
245
|
+
self,
|
|
246
|
+
*data: str,
|
|
247
|
+
prefix: str | None = None,
|
|
248
|
+
regex: str | None = None,
|
|
249
|
+
func: Callable[[types.CallbackQuery], bool] | None = None,
|
|
250
|
+
) -> Callable[[CallbackHandler], CallbackHandler]:
|
|
251
|
+
"""Register ``handler(user, call)`` for presses of inline-keyboard buttons.
|
|
252
|
+
|
|
253
|
+
Pick at most one selector; with none, the handler receives every press
|
|
254
|
+
that no earlier handler took.
|
|
255
|
+
|
|
256
|
+
* ``@bot.callback("menu:help")``: exact ``callback_data`` (several allowed)
|
|
257
|
+
* ``@bot.callback(prefix="persona:")``: starts with; ``call.args`` is the rest
|
|
258
|
+
* ``@bot.callback(regex=r"^vote:(yes|no)$")``: ``re.search`` on the data
|
|
259
|
+
|
|
260
|
+
``call`` is a :class:`~telegroq.CallbackContext`. The query is answered
|
|
261
|
+
automatically when the handler returns, unless you called ``call.answer()``.
|
|
262
|
+
|
|
263
|
+
Raises:
|
|
264
|
+
ConfigurationError: if more than one selector is given.
|
|
265
|
+
"""
|
|
266
|
+
if sum(bool(x) for x in (data, prefix, regex)) > 1:
|
|
267
|
+
raise ConfigurationError("callback(): use only one of exact data, prefix= or regex=")
|
|
268
|
+
pattern = re.compile(regex) if regex else None
|
|
269
|
+
|
|
270
|
+
def matches(call: types.CallbackQuery) -> bool:
|
|
271
|
+
value = call.data or ""
|
|
272
|
+
if data and value not in data:
|
|
273
|
+
return False
|
|
274
|
+
if prefix and not value.startswith(prefix):
|
|
275
|
+
return False
|
|
276
|
+
if pattern and not pattern.search(value):
|
|
277
|
+
return False
|
|
278
|
+
return func(call) if func else True
|
|
279
|
+
|
|
280
|
+
def decorator(fn: CallbackHandler) -> CallbackHandler:
|
|
281
|
+
self.telebot.callback_query_handler(func=matches)(self._guard_callback(fn, prefix))
|
|
282
|
+
return fn
|
|
283
|
+
|
|
284
|
+
return decorator
|
|
285
|
+
|
|
286
|
+
def on_error(self, fn: ErrorHandler) -> ErrorHandler:
|
|
287
|
+
"""Register ``fn(exc, user, update)`` to run when one of your handlers raises.
|
|
288
|
+
|
|
289
|
+
``update`` is the :class:`~telegroq.Message` or :class:`~telegroq.CallbackContext`.
|
|
290
|
+
It replaces the default ``error_reply``; the exception is logged either way.
|
|
291
|
+
"""
|
|
292
|
+
self._error_handler = fn
|
|
293
|
+
return fn
|
|
294
|
+
|
|
295
|
+
# ------------------------------------------------------- handler plumbing
|
|
296
|
+
def _guard(self, fn: Handler, *, typing: bool = False) -> Callable[[types.Message], None]:
|
|
297
|
+
# NB: no functools.wraps - telebot inspects the handler's signature and
|
|
298
|
+
# would follow __wrapped__ to the 2-argument user function.
|
|
299
|
+
def handler(raw: types.Message) -> None:
|
|
300
|
+
user = raw.from_user
|
|
301
|
+
if user is None: # channel posts / anonymous admins have no sender
|
|
302
|
+
return
|
|
303
|
+
msg = Message(raw, self)
|
|
304
|
+
try:
|
|
305
|
+
if typing:
|
|
306
|
+
self._typing(raw.chat.id)
|
|
307
|
+
fn(user, msg)
|
|
308
|
+
except Exception as exc: # noqa: BLE001 - never let a handler kill the polling loop
|
|
309
|
+
self._handle_error(fn, exc, user, msg)
|
|
310
|
+
|
|
311
|
+
handler.__name__ = getattr(fn, "__name__", "handler")
|
|
312
|
+
return handler
|
|
313
|
+
|
|
314
|
+
def _guard_callback(self, fn: CallbackHandler, prefix: str | None) -> Callable[[types.CallbackQuery], None]:
|
|
315
|
+
def handler(raw: types.CallbackQuery) -> None:
|
|
316
|
+
user = raw.from_user
|
|
317
|
+
if user is None: # pragma: no cover - Telegram always sends one
|
|
318
|
+
return
|
|
319
|
+
call = CallbackContext(raw, self, prefix)
|
|
320
|
+
try:
|
|
321
|
+
fn(user, call)
|
|
322
|
+
except Exception as exc: # noqa: BLE001
|
|
323
|
+
self._handle_error(fn, exc, user, call)
|
|
324
|
+
finally:
|
|
325
|
+
if not call.answered: # stop the button's loading spinner
|
|
326
|
+
try:
|
|
327
|
+
call.answer()
|
|
328
|
+
except Exception: # noqa: BLE001 - e.g. "query is too old"
|
|
329
|
+
logger.debug("Could not answer callback query", exc_info=True)
|
|
330
|
+
|
|
331
|
+
handler.__name__ = getattr(fn, "__name__", "handler")
|
|
332
|
+
return handler
|
|
333
|
+
|
|
334
|
+
def _handle_error(
|
|
335
|
+
self, fn: Callable[..., Any], exc: Exception, user: types.User, update: Message | CallbackContext
|
|
336
|
+
) -> None:
|
|
337
|
+
logger.error("Unhandled error in handler %r", getattr(fn, "__name__", fn), exc_info=exc)
|
|
338
|
+
try:
|
|
339
|
+
if self._error_handler is not None:
|
|
340
|
+
self._error_handler(exc, user, update)
|
|
341
|
+
elif self.error_reply:
|
|
342
|
+
if isinstance(update, CallbackContext):
|
|
343
|
+
update.answer(self.error_reply, alert=True)
|
|
344
|
+
else:
|
|
345
|
+
self.telebot.send_message(update.chat_id, self.error_reply)
|
|
346
|
+
except Exception: # noqa: BLE001
|
|
347
|
+
logger.exception("The error handling itself failed")
|
|
348
|
+
|
|
349
|
+
def _typing(self, chat_id: int) -> None:
|
|
350
|
+
try:
|
|
351
|
+
self.telebot.send_chat_action(chat_id, "typing")
|
|
352
|
+
except Exception: # noqa: BLE001 - cosmetic only
|
|
353
|
+
logger.debug("send_chat_action failed", exc_info=True)
|
|
354
|
+
|
|
355
|
+
def _register_non_text_fallback(self) -> None:
|
|
356
|
+
"""Answer media nobody asked for, in private chats only (never spams groups).
|
|
357
|
+
|
|
358
|
+
Registered lazily from :meth:`run` so it is tested *after* user handlers.
|
|
359
|
+
"""
|
|
360
|
+
if self._fallback_registered or not self.non_text_reply:
|
|
361
|
+
return
|
|
362
|
+
self._fallback_registered = True
|
|
363
|
+
unclaimed = [t for t in _NON_TEXT_TYPES if t not in self._claimed_types]
|
|
364
|
+
if not unclaimed:
|
|
365
|
+
return
|
|
366
|
+
|
|
367
|
+
@self.telebot.message_handler(content_types=unclaimed, chat_types=["private"])
|
|
368
|
+
def _non_text(raw: types.Message) -> None:
|
|
369
|
+
try:
|
|
370
|
+
self.telebot.send_message(raw.chat.id, self.non_text_reply)
|
|
371
|
+
except Exception: # noqa: BLE001
|
|
372
|
+
logger.exception("Could not send the non-text notice")
|
|
373
|
+
|
|
374
|
+
# ---------------------------------------------------------------- sending
|
|
375
|
+
def send_message(self, user_id: int | str, text: str, **kwargs: Any) -> types.Message | None:
|
|
376
|
+
"""Send ``text`` to a chat.
|
|
377
|
+
|
|
378
|
+
Texts above Telegram's 4096-character limit are split into several
|
|
379
|
+
messages (``reply_markup`` goes on the last one). If ``parse_mode`` is
|
|
380
|
+
given and Telegram cannot parse the entities (typical for LLM output with
|
|
381
|
+
stray ``*`` or ``_``), the text is re-sent as plain text.
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
user_id: Chat id (the user's id in private chats) or ``"@channel"``.
|
|
385
|
+
text: What to send. Empty or blank text sends nothing.
|
|
386
|
+
**kwargs: Passed to ``telebot.TeleBot.send_message``.
|
|
387
|
+
|
|
388
|
+
Returns:
|
|
389
|
+
The last sent message, or ``None`` if nothing was sent.
|
|
390
|
+
"""
|
|
391
|
+
return self._send(user_id, text, None, kwargs)
|
|
392
|
+
|
|
393
|
+
def reply(
|
|
394
|
+
self,
|
|
395
|
+
user_id: int | str,
|
|
396
|
+
text: str,
|
|
397
|
+
message_id: int | None = None,
|
|
398
|
+
**kwargs: Any,
|
|
399
|
+
) -> types.Message | None:
|
|
400
|
+
"""Like :meth:`send_message`, quoting ``message_id`` on the first part."""
|
|
401
|
+
return self._send(user_id, text, message_id, kwargs)
|
|
402
|
+
|
|
403
|
+
def _send(self, chat_id: int | str, text: str, message_id: int | None, kwargs: dict[str, Any]) -> Any:
|
|
404
|
+
if not text or not text.strip():
|
|
405
|
+
return None
|
|
406
|
+
chunks = util.smart_split(text)
|
|
407
|
+
markup = kwargs.pop("reply_markup", None)
|
|
408
|
+
sent = None
|
|
409
|
+
for index, chunk in enumerate(chunks):
|
|
410
|
+
options = dict(kwargs)
|
|
411
|
+
if index == 0 and message_id is not None:
|
|
412
|
+
for key, value in _quote_kwargs(message_id).items():
|
|
413
|
+
options.setdefault(key, value)
|
|
414
|
+
if index == len(chunks) - 1 and markup is not None:
|
|
415
|
+
options["reply_markup"] = markup
|
|
416
|
+
sent = self._send_chunk(chat_id, chunk, options)
|
|
417
|
+
return sent
|
|
418
|
+
|
|
419
|
+
def _send_chunk(self, chat_id: int | str, text: str, options: dict[str, Any]) -> Any:
|
|
420
|
+
try:
|
|
421
|
+
return self.telebot.send_message(chat_id, text, **options)
|
|
422
|
+
except ApiTelegramException as exc:
|
|
423
|
+
description = str(getattr(exc, "description", "")).lower()
|
|
424
|
+
if options.get("parse_mode") and "can't parse entities" in description:
|
|
425
|
+
logger.warning("Telegram could not parse the formatting; resending as plain text")
|
|
426
|
+
plain = {k: v for k, v in options.items() if k != "parse_mode"}
|
|
427
|
+
return self.telebot.send_message(chat_id, text, **plain)
|
|
428
|
+
raise
|
|
429
|
+
|
|
430
|
+
# -------------------------------------------------------------- lifecycle
|
|
431
|
+
def publish_commands(self) -> bool:
|
|
432
|
+
"""Publish commands registered with a ``description`` to Telegram's command menu.
|
|
433
|
+
|
|
434
|
+
Called by :meth:`run`. Returns ``True`` if the menu was updated.
|
|
435
|
+
"""
|
|
436
|
+
if not self._menu:
|
|
437
|
+
return False
|
|
438
|
+
try:
|
|
439
|
+
self.telebot.set_my_commands([types.BotCommand(c, d) for c, d in self._menu.items()])
|
|
440
|
+
except Exception: # noqa: BLE001
|
|
441
|
+
logger.warning("Could not publish the command menu", exc_info=True)
|
|
442
|
+
return False
|
|
443
|
+
return True
|
|
444
|
+
|
|
445
|
+
def run(
|
|
446
|
+
self,
|
|
447
|
+
*,
|
|
448
|
+
skip_pending: bool = True,
|
|
449
|
+
timeout: int = 20,
|
|
450
|
+
long_polling_timeout: int = 20,
|
|
451
|
+
set_commands: bool = True,
|
|
452
|
+
) -> None:
|
|
453
|
+
"""Start long polling and keep going through network and API errors.
|
|
454
|
+
|
|
455
|
+
Blocks until interrupted (Ctrl+C).
|
|
456
|
+
|
|
457
|
+
Args:
|
|
458
|
+
skip_pending: Ignore messages sent while the bot was offline.
|
|
459
|
+
timeout: Connection timeout of each request, in seconds.
|
|
460
|
+
long_polling_timeout: Server-side wait of each ``getUpdates`` call.
|
|
461
|
+
set_commands: Publish described commands to the command menu first.
|
|
462
|
+
"""
|
|
463
|
+
self._register_non_text_fallback()
|
|
464
|
+
if set_commands:
|
|
465
|
+
self.publish_commands()
|
|
466
|
+
try:
|
|
467
|
+
logger.info("telegroq started as @%s", self.telebot.get_me().username)
|
|
468
|
+
except Exception as exc: # noqa: BLE001 - infinity_polling keeps retrying anyway
|
|
469
|
+
logger.warning("Could not reach Telegram yet (%s); will keep retrying.", exc)
|
|
470
|
+
try:
|
|
471
|
+
self.telebot.infinity_polling(
|
|
472
|
+
timeout=timeout,
|
|
473
|
+
long_polling_timeout=long_polling_timeout,
|
|
474
|
+
skip_pending=skip_pending,
|
|
475
|
+
logger_level=logging.ERROR,
|
|
476
|
+
)
|
|
477
|
+
except KeyboardInterrupt:
|
|
478
|
+
logger.info("Stopped by user")
|
|
479
|
+
finally:
|
|
480
|
+
self.stop()
|
|
481
|
+
|
|
482
|
+
def stop(self) -> None:
|
|
483
|
+
"""Stop polling (safe to call from another thread or handler)."""
|
|
484
|
+
self.telebot.stop_polling()
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def createBot(token: str, **kwargs: Any) -> TeleGroqBot:
|
|
488
|
+
"""Create a :class:`TeleGroqBot`.
|
|
489
|
+
|
|
490
|
+
Args:
|
|
491
|
+
token: Telegram bot token from @BotFather.
|
|
492
|
+
**kwargs: ``non_text_reply``, ``error_reply`` and anything ``telebot.TeleBot`` accepts.
|
|
493
|
+
"""
|
|
494
|
+
return TeleGroqBot(token, **kwargs)
|