pythonclaw 0.3.3__py3-none-any.whl → 0.6.0__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.
Files changed (92) hide show
  1. pythonclaw/__init__.py +1 -1
  2. pythonclaw/channels/discord_bot.py +72 -11
  3. pythonclaw/channels/telegram_bot.py +145 -15
  4. pythonclaw/channels/whatsapp_bot.py +304 -0
  5. pythonclaw/config.py +25 -0
  6. pythonclaw/core/__init__.py +1 -1
  7. pythonclaw/core/agent.py +439 -92
  8. pythonclaw/core/llm/anthropic_client.py +208 -21
  9. pythonclaw/core/llm/base.py +29 -0
  10. pythonclaw/core/llm/gemini_client.py +52 -1
  11. pythonclaw/core/llm/openai_compatible.py +66 -0
  12. pythonclaw/core/memory/manager.py +114 -13
  13. pythonclaw/core/memory/storage.py +83 -1
  14. pythonclaw/core/persistent_agent.py +33 -1
  15. pythonclaw/core/retrieval/__init__.py +1 -1
  16. pythonclaw/core/retrieval/dense.py +2 -2
  17. pythonclaw/core/retrieval/retriever.py +1 -1
  18. pythonclaw/core/session_store.py +1 -1
  19. pythonclaw/core/skill_loader.py +93 -10
  20. pythonclaw/core/skillhub.py +168 -150
  21. pythonclaw/core/tools.py +37 -0
  22. pythonclaw/init.py +2 -1
  23. pythonclaw/main.py +21 -13
  24. pythonclaw/onboard.py +62 -15
  25. pythonclaw/server.py +21 -2
  26. pythonclaw/session_manager.py +63 -12
  27. pythonclaw/templates/skills/communication/CATEGORY.md +3 -2
  28. pythonclaw/templates/skills/communication/email/SKILL.md +33 -22
  29. pythonclaw/templates/skills/communication/slack/SKILL.md +98 -0
  30. pythonclaw/templates/skills/communication/slack/slack_api.py +153 -0
  31. pythonclaw/templates/skills/data/CATEGORY.md +3 -2
  32. pythonclaw/templates/skills/data/csv_analyzer/SKILL.md +28 -18
  33. pythonclaw/templates/skills/data/finance/SKILL.md +25 -15
  34. pythonclaw/templates/skills/data/news/SKILL.md +31 -18
  35. pythonclaw/templates/skills/data/pdf_reader/SKILL.md +24 -13
  36. pythonclaw/templates/skills/data/scraper/SKILL.md +1 -0
  37. pythonclaw/templates/skills/data/weather/SKILL.md +46 -20
  38. pythonclaw/templates/skills/data/weather/weather.py +1 -1
  39. pythonclaw/templates/skills/data/youtube/SKILL.md +27 -16
  40. pythonclaw/templates/skills/dev/CATEGORY.md +3 -2
  41. pythonclaw/templates/skills/dev/code_runner/SKILL.md +39 -27
  42. pythonclaw/templates/skills/dev/code_runner/run_code.py +1 -1
  43. pythonclaw/templates/skills/dev/github/SKILL.md +48 -22
  44. pythonclaw/templates/skills/dev/http_request/SKILL.md +32 -22
  45. pythonclaw/templates/skills/google/CATEGORY.md +3 -2
  46. pythonclaw/templates/skills/google/workspace/SKILL.md +43 -36
  47. pythonclaw/templates/skills/google/workspace/check_setup.sh +52 -0
  48. pythonclaw/templates/skills/media/CATEGORY.md +5 -0
  49. pythonclaw/templates/skills/media/image_gen/SKILL.md +99 -0
  50. pythonclaw/templates/skills/media/image_gen/generate.py +103 -0
  51. pythonclaw/templates/skills/media/spotify/SKILL.md +124 -0
  52. pythonclaw/templates/skills/media/spotify/spotify_ctl.py +231 -0
  53. pythonclaw/templates/skills/media/tts/SKILL.md +83 -0
  54. pythonclaw/templates/skills/media/tts/speak.py +50 -0
  55. pythonclaw/templates/skills/meta/CATEGORY.md +3 -2
  56. pythonclaw/templates/skills/meta/skill_creator/SKILL.md +47 -119
  57. pythonclaw/templates/skills/productivity/CATEGORY.md +5 -0
  58. pythonclaw/templates/skills/productivity/notion/SKILL.md +99 -0
  59. pythonclaw/templates/skills/productivity/notion/notion_api.py +185 -0
  60. pythonclaw/templates/skills/productivity/obsidian/SKILL.md +110 -0
  61. pythonclaw/templates/skills/productivity/obsidian/obsidian_vault.py +165 -0
  62. pythonclaw/templates/skills/productivity/trello/SKILL.md +110 -0
  63. pythonclaw/templates/skills/productivity/trello/trello_api.py +141 -0
  64. pythonclaw/templates/skills/system/CATEGORY.md +3 -2
  65. pythonclaw/templates/skills/system/change_persona/SKILL.md +24 -18
  66. pythonclaw/templates/skills/system/change_setting/SKILL.md +47 -43
  67. pythonclaw/templates/skills/system/change_setting/update_config.py +1 -0
  68. pythonclaw/templates/skills/system/change_soul/SKILL.md +21 -16
  69. pythonclaw/templates/skills/system/model_usage/SKILL.md +73 -0
  70. pythonclaw/templates/skills/system/model_usage/usage_stats.py +73 -0
  71. pythonclaw/templates/skills/system/onboarding/SKILL.md +24 -29
  72. pythonclaw/templates/skills/system/random/SKILL.md +27 -9
  73. pythonclaw/templates/skills/system/session_logs/SKILL.md +80 -0
  74. pythonclaw/templates/skills/system/session_logs/search_sessions.py +55 -0
  75. pythonclaw/templates/skills/system/time/SKILL.md +27 -9
  76. pythonclaw/templates/skills/system/time/time_util.py +1 -1
  77. pythonclaw/templates/skills/text/CATEGORY.md +3 -2
  78. pythonclaw/templates/skills/text/translator/SKILL.md +33 -28
  79. pythonclaw/templates/skills/web/CATEGORY.md +3 -2
  80. pythonclaw/templates/skills/web/summarize/SKILL.md +84 -0
  81. pythonclaw/templates/skills/web/summarize/summarize_url.py +107 -0
  82. pythonclaw/templates/skills/web/tavily/SKILL.md +38 -37
  83. pythonclaw/templates/tools/TOOLS.md +43 -0
  84. pythonclaw/web/app.py +237 -28
  85. pythonclaw/web/static/index.html +419 -85
  86. {pythonclaw-0.3.3.dist-info → pythonclaw-0.6.0.dist-info}/METADATA +63 -38
  87. pythonclaw-0.6.0.dist-info/RECORD +120 -0
  88. pythonclaw-0.3.3.dist-info/RECORD +0 -95
  89. {pythonclaw-0.3.3.dist-info → pythonclaw-0.6.0.dist-info}/WHEEL +0 -0
  90. {pythonclaw-0.3.3.dist-info → pythonclaw-0.6.0.dist-info}/entry_points.txt +0 -0
  91. {pythonclaw-0.3.3.dist-info → pythonclaw-0.6.0.dist-info}/licenses/LICENSE +0 -0
  92. {pythonclaw-0.3.3.dist-info → pythonclaw-0.6.0.dist-info}/top_level.txt +0 -0
pythonclaw/__init__.py CHANGED
@@ -6,7 +6,7 @@ from .core.llm.base import LLMProvider
6
6
  from .core.llm.openai_compatible import OpenAICompatibleProvider
7
7
  from .init import init
8
8
 
9
- __version__ = "0.3.3"
9
+ __version__ = "0.5.0"
10
10
  __all__ = [
11
11
  "Agent",
12
12
  "LLMProvider",
@@ -1,7 +1,7 @@
1
1
  """
2
2
  Discord channel for PythonClaw.
3
3
 
4
- Session IDs: "discord:{user_id}" (DMs) or "discord:{channel_id}" (guilds)
4
+ Session IDs: "discord:dm:{user_id}" (DMs) or "discord:{channel_id}" (guilds)
5
5
 
6
6
  Commands
7
7
  --------
@@ -9,21 +9,29 @@ Commands
9
9
  !status — show session info
10
10
  !compact [hint] — compact conversation history
11
11
  <text> — forwarded to Agent.chat(), reply sent back
12
+ <image> — image attachments sent to LLM for analysis
12
13
 
13
14
  The bot responds to:
14
15
  - Direct messages (always)
15
- - Channel mentions (@bot message) in guilds
16
- - Optionally all messages in whitelisted channels
16
+ - Channel mentions (@bot message) in guilds (when requireMention=true)
17
+ - All messages in whitelisted channels (when requireMention=false)
17
18
 
18
19
  Access control
19
20
  --------------
20
21
  Set DISCORD_ALLOWED_USERS to a comma-separated list of Discord user IDs.
21
22
  Set DISCORD_ALLOWED_CHANNELS to restrict which guild channels the bot listens in.
22
23
  Leave empty to allow all.
24
+
25
+ Group behaviour
26
+ ---------------
27
+ Set ``channels.discord.requireMention`` to ``true`` to require @bot mention
28
+ in guild channels. Default is ``false`` (respond when mentioned OR in
29
+ whitelisted channels).
23
30
  """
24
31
 
25
32
  from __future__ import annotations
26
33
 
34
+ import base64
27
35
  import logging
28
36
  from typing import TYPE_CHECKING
29
37
 
@@ -52,11 +60,13 @@ class DiscordBot:
52
60
  token: str,
53
61
  allowed_users: list[int] | None = None,
54
62
  allowed_channels: list[int] | None = None,
63
+ require_mention: bool = False,
55
64
  ) -> None:
56
65
  self._sm = session_manager
57
66
  self._token = token
58
67
  self._allowed_users: set[int] = set(allowed_users) if allowed_users else set()
59
68
  self._allowed_channels: set[int] = set(allowed_channels) if allowed_channels else set()
69
+ self._require_mention = require_mention
60
70
 
61
71
  intents = discord.Intents.default()
62
72
  intents.message_content = True
@@ -113,20 +123,27 @@ class DiscordBot:
113
123
  is_dm = isinstance(message.channel, discord.DMChannel)
114
124
  is_mentioned = client.user in message.mentions if not is_dm else False
115
125
 
116
- # In guilds, only respond to mentions or whitelisted channels
117
- if not is_dm and not is_mentioned and not self._is_allowed_channel(message.channel.id):
118
- return
126
+ if not is_dm:
127
+ if self._require_mention and not is_mentioned:
128
+ return
129
+ if not self._require_mention and not is_mentioned:
130
+ if not self._is_allowed_channel(message.channel.id):
131
+ return
119
132
 
120
133
  if not self._is_allowed_user(message.author.id):
121
134
  await message.reply("Sorry, you are not authorised to use this bot.")
122
135
  return
123
136
 
124
137
  content = message.content.strip()
125
- # Remove bot mention from the beginning
126
138
  if is_mentioned and client.user:
127
139
  content = content.replace(f"<@{client.user.id}>", "").strip()
128
140
 
129
- if not content:
141
+ has_image = any(
142
+ a.content_type and a.content_type.startswith("image/")
143
+ for a in message.attachments
144
+ )
145
+
146
+ if not content and not has_image:
130
147
  return
131
148
 
132
149
  # Command dispatch
@@ -141,7 +158,35 @@ class DiscordBot:
141
158
  await self._cmd_compact(message, is_dm, hint)
142
159
  return
143
160
 
144
- await self._handle_chat(message, content, is_dm)
161
+ chat_input = content or ""
162
+ if has_image:
163
+ chat_input = await self._build_image_input(
164
+ message, content or "What's in this image?"
165
+ )
166
+
167
+ await self._handle_chat(message, chat_input, is_dm)
168
+
169
+ # ── Image handling ────────────────────────────────────────────────────────
170
+
171
+ @staticmethod
172
+ async def _build_image_input(message: discord.Message, caption: str) -> list:
173
+ """Download image attachments and build multimodal content array."""
174
+ parts: list[dict] = [{"type": "text", "text": caption}]
175
+ for att in message.attachments:
176
+ if att.content_type and att.content_type.startswith("image/"):
177
+ try:
178
+ data = await att.read()
179
+ b64 = base64.b64encode(data).decode()
180
+ media_type = att.content_type.split(";")[0]
181
+ parts.append({
182
+ "type": "image_url",
183
+ "image_url": {
184
+ "url": f"data:{media_type};base64,{b64}",
185
+ },
186
+ })
187
+ except Exception:
188
+ logger.warning("[Discord] Failed to download attachment %s", att.filename)
189
+ return parts
145
190
 
146
191
  # ── Command implementations ───────────────────────────────────────────────
147
192
 
@@ -180,12 +225,24 @@ class DiscordBot:
180
225
  for chunk in self._split_message(result or "(no result)"):
181
226
  await message.reply(chunk)
182
227
 
183
- async def _handle_chat(self, message: discord.Message, content: str, is_dm: bool) -> None:
228
+ async def _handle_chat(
229
+ self,
230
+ message: discord.Message,
231
+ content: str | list,
232
+ is_dm: bool,
233
+ ) -> None:
184
234
  sid = self._session_id(message.author.id if is_dm else message.channel.id, is_dm)
185
235
  agent = self._sm.get_or_create(sid)
236
+
237
+ if self._sm.is_locked(sid):
238
+ await message.reply("Processing previous message\u2026")
239
+
186
240
  async with message.channel.typing():
187
241
  try:
188
- response = agent.chat(content)
242
+ async with self._sm.acquire(sid):
243
+ import asyncio
244
+ loop = asyncio.get_event_loop()
245
+ response = await loop.run_in_executor(None, agent.chat, content)
189
246
  except Exception as exc:
190
247
  logger.exception("[Discord] Agent.chat() raised an exception")
191
248
  response = f"Sorry, something went wrong: {exc}"
@@ -219,11 +276,15 @@ def create_bot(session_manager: "SessionManager") -> "DiscordBot":
219
276
  allowed_channels = config.get_int_list(
220
277
  "channels", "discord", "allowedChannels", env="DISCORD_ALLOWED_CHANNELS",
221
278
  )
279
+ require_mention = config.get_bool(
280
+ "channels", "discord", "requireMention", default=False,
281
+ )
222
282
  return DiscordBot(
223
283
  session_manager=session_manager,
224
284
  token=token,
225
285
  allowed_users=allowed_users or None,
226
286
  allowed_channels=allowed_channels or None,
287
+ require_mention=require_mention,
227
288
  )
228
289
 
229
290
 
@@ -14,19 +14,27 @@ Commands
14
14
  /status — show session info (provider, skills, memory, tokens, compactions)
15
15
  /compact [hint] — compact conversation history
16
16
  <text> — forwarded to Agent.chat(), reply sent back
17
+ <photo> — image sent to LLM with optional caption
17
18
 
18
19
  Access control
19
20
  --------------
20
21
  Set TELEGRAM_ALLOWED_USERS to a comma-separated list of integer Telegram user
21
22
  IDs to restrict access. Leave empty (or unset) to allow all users.
23
+
24
+ Group behaviour
25
+ ---------------
26
+ Set ``channels.telegram.requireMention`` to ``true`` in pythonclaw.json to
27
+ require @bot mention in group chats. DMs always respond.
22
28
  """
23
29
 
24
30
  from __future__ import annotations
25
31
 
32
+ import asyncio
33
+ import base64
26
34
  import logging
27
35
  from typing import TYPE_CHECKING
28
36
 
29
- from telegram import Update
37
+ from telegram import BotCommand, ReactionTypeEmoji, Update
30
38
  from telegram.ext import (
31
39
  Application,
32
40
  CommandHandler,
@@ -56,11 +64,14 @@ class TelegramBot:
56
64
  session_manager: "SessionManager",
57
65
  token: str,
58
66
  allowed_users: list[int] | None = None,
67
+ require_mention: bool = False,
59
68
  ) -> None:
60
69
  self._sm = session_manager
61
70
  self._token = token
62
71
  self._allowed_users: set[int] = set(allowed_users) if allowed_users else set()
72
+ self._require_mention = require_mention
63
73
  self._app: Application | None = None
74
+ self._bot_username: str | None = None
64
75
 
65
76
  # ── Session ID convention ─────────────────────────────────────────────────
66
77
 
@@ -92,6 +103,29 @@ class TelegramBot:
92
103
  return False
93
104
  return True
94
105
 
106
+ def _is_group(self, update: Update) -> bool:
107
+ """Return True if the message is from a group/supergroup."""
108
+ return update.effective_chat.type in ("group", "supergroup")
109
+
110
+ def _is_mentioned(self, update: Update) -> bool:
111
+ """Check if the bot is @mentioned in the message text."""
112
+ text = update.message.text or update.message.caption or ""
113
+ if self._bot_username and f"@{self._bot_username}" in text:
114
+ return True
115
+ entities = update.message.entities or update.message.caption_entities or []
116
+ for ent in entities:
117
+ if ent.type == "mention" and self._bot_username:
118
+ mention = text[ent.offset:ent.offset + ent.length]
119
+ if mention.lower() == f"@{self._bot_username.lower()}":
120
+ return True
121
+ return False
122
+
123
+ def _strip_mention(self, text: str) -> str:
124
+ """Remove the @bot mention from message text."""
125
+ if self._bot_username:
126
+ text = text.replace(f"@{self._bot_username}", "").strip()
127
+ return text
128
+
95
129
  # ── Command handlers ──────────────────────────────────────────────────────
96
130
 
97
131
  async def _cmd_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
@@ -100,13 +134,14 @@ class TelegramBot:
100
134
  sid = self._session_id(update.effective_chat.id)
101
135
  self._sm.get_or_create(sid)
102
136
  await update.message.reply_text(
103
- "👋 Hi! I'm your PythonClaw agent.\n\n"
104
- "Just send me a message and I'll do my best to help.\n\n"
137
+ "\U0001f44b Hi! I'm your PythonClaw agent.\n\n"
138
+ "Just send me a message and I'll do my best to help.\n"
139
+ "You can also send photos and I'll analyze them.\n\n"
105
140
  "Commands:\n"
106
- " /start show this message\n"
107
- " /reset start a fresh session\n"
108
- " /status show session info\n"
109
- " /compact [hint] compact conversation history"
141
+ " /start \u2014 show this message\n"
142
+ " /reset \u2014 start a fresh session\n"
143
+ " /status \u2014 show session info\n"
144
+ " /compact [hint] \u2014 compact conversation history"
110
145
  )
111
146
 
112
147
  async def _cmd_reset(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
@@ -123,7 +158,7 @@ class TelegramBot:
123
158
  agent = self._sm.get_or_create(sid)
124
159
  from ..core.compaction import estimate_tokens
125
160
  await update.message.reply_text(
126
- f"📊 Session Status\n"
161
+ f"\U0001f4ca Session Status\n"
127
162
  f" Session ID : {sid}\n"
128
163
  f" Provider : {type(agent.provider).__name__}\n"
129
164
  f" Skills : {len(agent.loaded_skill_names)} loaded\n"
@@ -140,7 +175,7 @@ class TelegramBot:
140
175
  sid = self._session_id(update.effective_chat.id)
141
176
  agent = self._sm.get_or_create(sid)
142
177
  hint: str | None = " ".join(context.args).strip() or None if context.args else None
143
- await update.message.reply_text(" Compacting conversation history...")
178
+ await update.message.reply_text("\u23f3 Compacting conversation history...")
144
179
  try:
145
180
  result = agent.compact(instruction=hint)
146
181
  except Exception as exc:
@@ -148,41 +183,131 @@ class TelegramBot:
148
183
  for chunk in _split_message(result):
149
184
  await update.message.reply_text(chunk)
150
185
 
151
- # ── Message handler ───────────────────────────────────────────────────────
186
+ # ── Message handler (text + photos) ───────────────────────────────────────
152
187
 
153
188
  async def _handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
154
189
  if not await self._check_access(update, context):
155
190
  return
156
- user_text = (update.message.text or "").strip()
157
- if not user_text:
191
+
192
+ if self._is_group(update) and self._require_mention:
193
+ if not self._is_mentioned(update):
194
+ return
195
+
196
+ user_text = (update.message.text or update.message.caption or "").strip()
197
+ user_text = self._strip_mention(user_text)
198
+
199
+ has_photo = bool(update.message.photo)
200
+
201
+ if not user_text and not has_photo:
158
202
  return
203
+
159
204
  sid = self._session_id(update.effective_chat.id)
160
205
  agent = self._sm.get_or_create(sid)
161
- await update.message.chat.send_action("typing")
206
+
207
+ if self._sm.is_locked(sid):
208
+ await update.message.reply_text("\u23f3 Processing previous message\u2026")
209
+
162
210
  try:
163
- response = agent.chat(user_text)
211
+ await update.message.set_reaction([ReactionTypeEmoji("\U0001f440")])
212
+ except Exception:
213
+ pass
214
+
215
+ # Build multimodal input if photo is present
216
+ chat_input = user_text or ""
217
+ if has_photo:
218
+ chat_input = await self._build_image_input(
219
+ update, user_text or "What's in this image?"
220
+ )
221
+
222
+ typing_task = asyncio.create_task(
223
+ self._keep_typing(update.message.chat_id)
224
+ )
225
+ try:
226
+ async with self._sm.acquire(sid):
227
+ loop = asyncio.get_event_loop()
228
+ response = await loop.run_in_executor(None, agent.chat, chat_input)
164
229
  except Exception as exc:
165
230
  logger.exception("[Telegram] Agent.chat() raised an exception")
166
231
  response = f"Sorry, something went wrong: {exc}"
232
+ finally:
233
+ typing_task.cancel()
234
+
235
+ try:
236
+ await update.message.set_reaction([])
237
+ except Exception:
238
+ pass
239
+
167
240
  for chunk in _split_message(response or "(no response)"):
168
241
  await update.message.reply_text(chunk)
169
242
 
243
+ async def _build_image_input(self, update: Update, caption: str) -> list:
244
+ """Download photo and build a multimodal content array."""
245
+ photo = update.message.photo[-1] # highest resolution
246
+ file = await photo.get_file()
247
+ data = await file.download_as_bytearray()
248
+ b64 = base64.b64encode(bytes(data)).decode()
249
+
250
+ return [
251
+ {"type": "text", "text": caption},
252
+ {
253
+ "type": "image_url",
254
+ "image_url": {
255
+ "url": f"data:image/jpeg;base64,{b64}",
256
+ },
257
+ },
258
+ ]
259
+
260
+ async def _keep_typing(self, chat_id: int) -> None:
261
+ """Re-send the 'typing' chat action every 4 s until cancelled."""
262
+ try:
263
+ while True:
264
+ await self._app.bot.send_chat_action(chat_id=chat_id, action="typing")
265
+ await asyncio.sleep(4)
266
+ except asyncio.CancelledError:
267
+ pass
268
+ except Exception:
269
+ logger.debug("[Telegram] _keep_typing stopped unexpectedly", exc_info=True)
270
+
170
271
  # ── Lifecycle ─────────────────────────────────────────────────────────────
171
272
 
273
+ _BOT_COMMANDS = [
274
+ BotCommand("start", "Show welcome message"),
275
+ BotCommand("reset", "Start a fresh session"),
276
+ BotCommand("status", "Show session info"),
277
+ BotCommand("compact", "Compact conversation history"),
278
+ ]
279
+
172
280
  def build_application(self) -> Application:
173
281
  app = Application.builder().token(self._token).build()
174
282
  app.add_handler(CommandHandler("start", self._cmd_start))
175
283
  app.add_handler(CommandHandler("reset", self._cmd_reset))
176
284
  app.add_handler(CommandHandler("status", self._cmd_status))
177
285
  app.add_handler(CommandHandler("compact", self._cmd_compact))
178
- app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self._handle_message))
286
+ app.add_handler(MessageHandler(
287
+ (filters.TEXT | filters.PHOTO) & ~filters.COMMAND,
288
+ self._handle_message,
289
+ ))
179
290
  self._app = app
180
291
  return app
181
292
 
293
+ async def _register_commands(self) -> None:
294
+ """Register slash-commands with Telegram so they appear in the menu."""
295
+ try:
296
+ await self._app.bot.set_my_commands(self._BOT_COMMANDS)
297
+ me = await self._app.bot.get_me()
298
+ self._bot_username = me.username
299
+ logger.info(
300
+ "[Telegram] Registered %d bot commands, username=@%s",
301
+ len(self._BOT_COMMANDS), self._bot_username,
302
+ )
303
+ except Exception:
304
+ logger.warning("[Telegram] Failed to register bot commands", exc_info=True)
305
+
182
306
  def run_polling(self) -> None:
183
307
  """Blocking call — starts the bot using long polling (for standalone use)."""
184
308
  app = self.build_application()
185
309
  logger.info("[Telegram] Starting bot (polling mode)...")
310
+ app.post_init = lambda _app: self._register_commands()
186
311
  app.run_polling(drop_pending_updates=True)
187
312
 
188
313
  async def start_async(self) -> None:
@@ -191,6 +316,7 @@ class TelegramBot:
191
316
  logger.info("[Telegram] Initialising bot (async mode)...")
192
317
  await app.initialize()
193
318
  await app.start()
319
+ await self._register_commands()
194
320
  await app.updater.start_polling(drop_pending_updates=True)
195
321
 
196
322
  async def stop_async(self) -> None:
@@ -225,10 +351,14 @@ def create_bot(session_manager: "SessionManager") -> TelegramBot:
225
351
  allowed_users = config.get_int_list(
226
352
  "channels", "telegram", "allowedUsers", env="TELEGRAM_ALLOWED_USERS",
227
353
  )
354
+ require_mention = config.get_bool(
355
+ "channels", "telegram", "requireMention", default=False,
356
+ )
228
357
  return TelegramBot(
229
358
  session_manager=session_manager,
230
359
  token=token,
231
360
  allowed_users=allowed_users or None,
361
+ require_mention=require_mention,
232
362
  )
233
363
 
234
364