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 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)