pythonclaw 0.5.0__py3-none-any.whl → 0.6.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.
Files changed (45) hide show
  1. pythonclaw/channels/discord_bot.py +65 -11
  2. pythonclaw/channels/telegram_bot.py +298 -30
  3. pythonclaw/channels/whatsapp_bot.py +60 -8
  4. pythonclaw/core/agent.py +347 -114
  5. pythonclaw/core/llm/anthropic_client.py +212 -22
  6. pythonclaw/core/llm/base.py +29 -0
  7. pythonclaw/core/llm/gemini_client.py +53 -1
  8. pythonclaw/core/llm/openai_compatible.py +71 -1
  9. pythonclaw/core/memory/manager.py +75 -0
  10. pythonclaw/core/memory/storage.py +83 -0
  11. pythonclaw/core/persistent_agent.py +27 -1
  12. pythonclaw/core/session_store.py +1 -1
  13. pythonclaw/core/skill_loader.py +13 -13
  14. pythonclaw/core/skillhub.py +166 -176
  15. pythonclaw/core/tools.py +37 -0
  16. pythonclaw/main.py +15 -10
  17. pythonclaw/onboard.py +14 -14
  18. pythonclaw/templates/skills/communication/CATEGORY.md +1 -1
  19. pythonclaw/templates/skills/communication/slack/SKILL.md +1 -1
  20. pythonclaw/templates/skills/data/CATEGORY.md +1 -1
  21. pythonclaw/templates/skills/dev/CATEGORY.md +1 -1
  22. pythonclaw/templates/skills/google/CATEGORY.md +1 -1
  23. pythonclaw/templates/skills/media/CATEGORY.md +1 -1
  24. pythonclaw/templates/skills/media/image_gen/SKILL.md +1 -1
  25. pythonclaw/templates/skills/media/spotify/SKILL.md +1 -1
  26. pythonclaw/templates/skills/media/tts/SKILL.md +1 -1
  27. pythonclaw/templates/skills/meta/CATEGORY.md +1 -1
  28. pythonclaw/templates/skills/productivity/CATEGORY.md +1 -1
  29. pythonclaw/templates/skills/productivity/notion/SKILL.md +1 -1
  30. pythonclaw/templates/skills/productivity/obsidian/SKILL.md +1 -1
  31. pythonclaw/templates/skills/productivity/trello/SKILL.md +1 -1
  32. pythonclaw/templates/skills/system/CATEGORY.md +1 -1
  33. pythonclaw/templates/skills/system/model_usage/SKILL.md +1 -1
  34. pythonclaw/templates/skills/system/session_logs/SKILL.md +1 -1
  35. pythonclaw/templates/skills/text/CATEGORY.md +1 -1
  36. pythonclaw/templates/skills/web/CATEGORY.md +1 -1
  37. pythonclaw/templates/skills/web/summarize/SKILL.md +1 -1
  38. pythonclaw/web/app.py +134 -29
  39. pythonclaw/web/static/index.html +276 -107
  40. {pythonclaw-0.5.0.dist-info → pythonclaw-0.6.1.dist-info}/METADATA +9 -9
  41. {pythonclaw-0.5.0.dist-info → pythonclaw-0.6.1.dist-info}/RECORD +45 -45
  42. {pythonclaw-0.5.0.dist-info → pythonclaw-0.6.1.dist-info}/WHEEL +0 -0
  43. {pythonclaw-0.5.0.dist-info → pythonclaw-0.6.1.dist-info}/entry_points.txt +0 -0
  44. {pythonclaw-0.5.0.dist-info → pythonclaw-0.6.1.dist-info}/licenses/LICENSE +0 -0
  45. {pythonclaw-0.5.0.dist-info → pythonclaw-0.6.1.dist-info}/top_level.txt +0 -0
@@ -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,17 @@ 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)
186
236
 
187
237
  if self._sm.is_locked(sid):
188
- await message.reply("Processing previous message")
238
+ await message.reply("Processing previous message\u2026")
189
239
 
190
240
  async with message.channel.typing():
191
241
  try:
@@ -226,11 +276,15 @@ def create_bot(session_manager: "SessionManager") -> "DiscordBot":
226
276
  allowed_channels = config.get_int_list(
227
277
  "channels", "discord", "allowedChannels", env="DISCORD_ALLOWED_CHANNELS",
228
278
  )
279
+ require_mention = config.get_bool(
280
+ "channels", "discord", "requireMention", default=False,
281
+ )
229
282
  return DiscordBot(
230
283
  session_manager=session_manager,
231
284
  token=token,
232
285
  allowed_users=allowed_users or None,
233
286
  allowed_channels=allowed_channels or None,
287
+ require_mention=require_mention,
234
288
  )
235
289
 
236
290
 
@@ -14,17 +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
 
26
32
  import asyncio
33
+ import base64
27
34
  import logging
35
+ import queue as _queue
36
+ import re
37
+ import time
28
38
  from typing import TYPE_CHECKING
29
39
 
30
40
  from telegram import BotCommand, ReactionTypeEmoji, Update
@@ -57,11 +67,14 @@ class TelegramBot:
57
67
  session_manager: "SessionManager",
58
68
  token: str,
59
69
  allowed_users: list[int] | None = None,
70
+ require_mention: bool = False,
60
71
  ) -> None:
61
72
  self._sm = session_manager
62
73
  self._token = token
63
74
  self._allowed_users: set[int] = set(allowed_users) if allowed_users else set()
75
+ self._require_mention = require_mention
64
76
  self._app: Application | None = None
77
+ self._bot_username: str | None = None
65
78
 
66
79
  # ── Session ID convention ─────────────────────────────────────────────────
67
80
 
@@ -93,6 +106,29 @@ class TelegramBot:
93
106
  return False
94
107
  return True
95
108
 
109
+ def _is_group(self, update: Update) -> bool:
110
+ """Return True if the message is from a group/supergroup."""
111
+ return update.effective_chat.type in ("group", "supergroup")
112
+
113
+ def _is_mentioned(self, update: Update) -> bool:
114
+ """Check if the bot is @mentioned in the message text."""
115
+ text = update.message.text or update.message.caption or ""
116
+ if self._bot_username and f"@{self._bot_username}" in text:
117
+ return True
118
+ entities = update.message.entities or update.message.caption_entities or []
119
+ for ent in entities:
120
+ if ent.type == "mention" and self._bot_username:
121
+ mention = text[ent.offset:ent.offset + ent.length]
122
+ if mention.lower() == f"@{self._bot_username.lower()}":
123
+ return True
124
+ return False
125
+
126
+ def _strip_mention(self, text: str) -> str:
127
+ """Remove the @bot mention from message text."""
128
+ if self._bot_username:
129
+ text = text.replace(f"@{self._bot_username}", "").strip()
130
+ return text
131
+
96
132
  # ── Command handlers ──────────────────────────────────────────────────────
97
133
 
98
134
  async def _cmd_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
@@ -101,13 +137,14 @@ class TelegramBot:
101
137
  sid = self._session_id(update.effective_chat.id)
102
138
  self._sm.get_or_create(sid)
103
139
  await update.message.reply_text(
104
- "👋 Hi! I'm your PythonClaw agent.\n\n"
105
- "Just send me a message and I'll do my best to help.\n\n"
140
+ "\U0001f44b Hi! I'm your PythonClaw agent.\n\n"
141
+ "Just send me a message and I'll do my best to help.\n"
142
+ "You can also send photos and I'll analyze them.\n\n"
106
143
  "Commands:\n"
107
- " /start show this message\n"
108
- " /reset start a fresh session\n"
109
- " /status show session info\n"
110
- " /compact [hint] compact conversation history"
144
+ " /start \u2014 show this message\n"
145
+ " /reset \u2014 start a fresh session\n"
146
+ " /status \u2014 show session info\n"
147
+ " /compact [hint] \u2014 compact conversation history"
111
148
  )
112
149
 
113
150
  async def _cmd_reset(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
@@ -124,7 +161,7 @@ class TelegramBot:
124
161
  agent = self._sm.get_or_create(sid)
125
162
  from ..core.compaction import estimate_tokens
126
163
  await update.message.reply_text(
127
- f"📊 Session Status\n"
164
+ f"\U0001f4ca Session Status\n"
128
165
  f" Session ID : {sid}\n"
129
166
  f" Provider : {type(agent.provider).__name__}\n"
130
167
  f" Skills : {len(agent.loaded_skill_names)} loaded\n"
@@ -141,7 +178,7 @@ class TelegramBot:
141
178
  sid = self._session_id(update.effective_chat.id)
142
179
  agent = self._sm.get_or_create(sid)
143
180
  hint: str | None = " ".join(context.args).strip() or None if context.args else None
144
- await update.message.reply_text(" Compacting conversation history...")
181
+ await update.message.reply_text("\u23f3 Compacting conversation history...")
145
182
  try:
146
183
  result = agent.compact(instruction=hint)
147
184
  except Exception as exc:
@@ -149,50 +186,239 @@ class TelegramBot:
149
186
  for chunk in _split_message(result):
150
187
  await update.message.reply_text(chunk)
151
188
 
152
- # ── Message handler ───────────────────────────────────────────────────────
189
+ # ── Message handler (text + photos) ───────────────────────────────────────
153
190
 
154
191
  async def _handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
155
192
  if not await self._check_access(update, context):
156
193
  return
157
- user_text = (update.message.text or "").strip()
158
- if not user_text:
194
+
195
+ if self._is_group(update) and self._require_mention:
196
+ if not self._is_mentioned(update):
197
+ return
198
+
199
+ user_text = (update.message.text or update.message.caption or "").strip()
200
+ user_text = self._strip_mention(user_text)
201
+
202
+ has_photo = bool(update.message.photo)
203
+
204
+ if not user_text and not has_photo:
159
205
  return
206
+
160
207
  sid = self._session_id(update.effective_chat.id)
161
208
  agent = self._sm.get_or_create(sid)
162
209
 
163
210
  if self._sm.is_locked(sid):
164
- await update.message.reply_text(" Processing previous message")
211
+ await update.message.reply_text("\u23f3 Processing previous message\u2026")
165
212
 
166
- # React to the message so the user knows the bot saw it
167
213
  try:
168
- await update.message.set_reaction([ReactionTypeEmoji("👀")])
214
+ await update.message.set_reaction([ReactionTypeEmoji("\U0001f440")])
169
215
  except Exception:
170
- pass # reaction API may fail on older bot API or in groups
216
+ pass
217
+
218
+ chat_input = user_text or ""
219
+ if has_photo:
220
+ chat_input = await self._build_image_input(
221
+ update, user_text or "What's in this image?"
222
+ )
223
+
224
+ token_queue: _queue.Queue[str] = _queue.Queue()
171
225
 
172
- # Keep "typing…" visible for the entire duration of processing.
173
- # Telegram's typing indicator expires after ~5 s, so we resend it
174
- # on a loop until the agent finishes.
175
226
  typing_task = asyncio.create_task(
176
227
  self._keep_typing(update.message.chat_id)
177
228
  )
178
229
  try:
179
230
  async with self._sm.acquire(sid):
180
231
  loop = asyncio.get_event_loop()
181
- response = await loop.run_in_executor(None, agent.chat, user_text)
232
+ future = loop.run_in_executor(
233
+ None, agent.chat_stream, chat_input, token_queue.put,
234
+ )
235
+ await self._flush_stream(update, token_queue, future)
182
236
  except Exception as exc:
183
- logger.exception("[Telegram] Agent.chat() raised an exception")
184
- response = f"Sorry, something went wrong: {exc}"
237
+ logger.exception("[Telegram] Agent error")
238
+ await update.message.reply_text(f"Sorry, something went wrong: {exc}")
185
239
  finally:
186
240
  typing_task.cancel()
187
241
 
188
- # Clear the "seen" reaction once we reply
189
242
  try:
190
243
  await update.message.set_reaction([])
191
244
  except Exception:
192
245
  pass
193
246
 
194
- for chunk in _split_message(response or "(no response)"):
195
- await update.message.reply_text(chunk)
247
+ # Max wall-clock time for a single agent invocation (seconds).
248
+ _AGENT_TIMEOUT = 180
249
+
250
+ async def _flush_stream(
251
+ self,
252
+ update: Update,
253
+ token_queue: "_queue.Queue[str]",
254
+ future: "asyncio.Future[str]",
255
+ ) -> None:
256
+ """Progressively stream tokens to Telegram via edit-in-place.
257
+
258
+ Uses send-then-edit (like OpenClaw): one live message that gets
259
+ updated as tokens arrive (~1.5 s throttle). Tool-call markers
260
+ produce a short status line and start a fresh message.
261
+
262
+ Safeguards against hangs:
263
+ - **Heartbeat**: if no tokens arrive for 15 s, sends a "still
264
+ working" notification so the user knows the bot is alive.
265
+ - **Overall timeout**: after ``_AGENT_TIMEOUT`` seconds the
266
+ future is abandoned and a timeout message is sent.
267
+ """
268
+ buf: list[str] = []
269
+ live_msg = None
270
+ live_text = ""
271
+ sent_any = False
272
+ THROTTLE = 1.5
273
+ HEARTBEAT_INTERVAL = 15.0
274
+ last_edit = time.monotonic()
275
+ last_token_time = time.monotonic()
276
+ start_time = time.monotonic()
277
+ heartbeat_sent = False
278
+ _MARKER = re.compile(r'`\[calling:\s*([^\]]+)\]`')
279
+
280
+ while not future.done():
281
+ # ── Overall timeout guard ─────────────────────────────────
282
+ if (time.monotonic() - start_time) > self._AGENT_TIMEOUT:
283
+ logger.warning(
284
+ "[Telegram] Agent timeout after %ds", self._AGENT_TIMEOUT,
285
+ )
286
+ try:
287
+ await update.message.reply_text(
288
+ "\u23f0 The operation timed out. "
289
+ "Please try a simpler request."
290
+ )
291
+ except Exception:
292
+ pass
293
+ return
294
+
295
+ # ── Drain token queue ─────────────────────────────────────
296
+ drained = False
297
+ while True:
298
+ try:
299
+ buf.append(token_queue.get_nowait())
300
+ drained = True
301
+ last_token_time = time.monotonic()
302
+ heartbeat_sent = False
303
+ except _queue.Empty:
304
+ break
305
+
306
+ # ── Heartbeat: notify user during long silences ───────────
307
+ if (
308
+ not drained
309
+ and not heartbeat_sent
310
+ and (time.monotonic() - last_token_time) > HEARTBEAT_INTERVAL
311
+ ):
312
+ try:
313
+ await update.message.reply_text(
314
+ "\u23f3 Still working\u2026"
315
+ )
316
+ except Exception:
317
+ pass
318
+ heartbeat_sent = True
319
+
320
+ if not drained:
321
+ await asyncio.sleep(0.3)
322
+ continue
323
+
324
+ raw = "".join(buf)
325
+ now = time.monotonic()
326
+
327
+ # ── Tool-call marker → status line + new message ──────────
328
+ marker = _MARKER.search(raw)
329
+ if marker:
330
+ before = _clean_response(raw[:marker.start()])
331
+ if before and before != live_text:
332
+ try:
333
+ if live_msg:
334
+ await live_msg.edit_text(before[:4096])
335
+ else:
336
+ await update.message.reply_text(before[:4096])
337
+ except Exception:
338
+ pass
339
+ live_msg = None
340
+ live_text = ""
341
+ tools = marker.group(1)
342
+ try:
343
+ await update.message.reply_text(
344
+ f"\U0001f527 {tools}\u2026"
345
+ )
346
+ except Exception:
347
+ pass
348
+ sent_any = True
349
+ buf = [raw[marker.end():].lstrip()]
350
+ last_edit = now
351
+ continue
352
+
353
+ # ── Regular text → edit-in-place ──────────────────────────
354
+ text = _clean_response(raw)
355
+ if text and text != live_text and (now - last_edit) >= THROTTLE:
356
+ try:
357
+ if live_msg is None:
358
+ live_msg = await update.message.reply_text(
359
+ text[:4096],
360
+ )
361
+ live_text = text[:4096]
362
+ elif len(text) <= 4096:
363
+ await live_msg.edit_text(text)
364
+ live_text = text
365
+ else:
366
+ await live_msg.edit_text(text[:4096])
367
+ live_msg = None
368
+ live_text = ""
369
+ buf = [text[4096:]]
370
+ sent_any = True
371
+ except Exception:
372
+ pass
373
+ last_edit = now
374
+
375
+ await asyncio.sleep(0.3)
376
+
377
+ # ── Final drain ───────────────────────────────────────────────
378
+ response = future.result()
379
+ while True:
380
+ try:
381
+ buf.append(token_queue.get_nowait())
382
+ except _queue.Empty:
383
+ break
384
+
385
+ remaining = _clean_response("".join(buf).strip())
386
+ if remaining and remaining != live_text:
387
+ try:
388
+ if live_msg and len(remaining) <= 4096:
389
+ await live_msg.edit_text(remaining)
390
+ elif live_msg:
391
+ await live_msg.edit_text(remaining[:4096])
392
+ for chunk in _split_message(remaining[4096:]):
393
+ await update.message.reply_text(chunk)
394
+ else:
395
+ for chunk in _split_message(remaining):
396
+ await update.message.reply_text(chunk)
397
+ sent_any = True
398
+ except Exception:
399
+ pass
400
+
401
+ if not sent_any:
402
+ text = _clean_response(response or "(no response)")
403
+ for chunk in _split_message(text):
404
+ await update.message.reply_text(chunk)
405
+
406
+ async def _build_image_input(self, update: Update, caption: str) -> list:
407
+ """Download photo and build a multimodal content array."""
408
+ photo = update.message.photo[-1] # highest resolution
409
+ file = await photo.get_file()
410
+ data = await file.download_as_bytearray()
411
+ b64 = base64.b64encode(bytes(data)).decode()
412
+
413
+ return [
414
+ {"type": "text", "text": caption},
415
+ {
416
+ "type": "image_url",
417
+ "image_url": {
418
+ "url": f"data:image/jpeg;base64,{b64}",
419
+ },
420
+ },
421
+ ]
196
422
 
197
423
  async def _keep_typing(self, chat_id: int) -> None:
198
424
  """Re-send the 'typing' chat action every 4 s until cancelled."""
@@ -220,7 +446,10 @@ class TelegramBot:
220
446
  app.add_handler(CommandHandler("reset", self._cmd_reset))
221
447
  app.add_handler(CommandHandler("status", self._cmd_status))
222
448
  app.add_handler(CommandHandler("compact", self._cmd_compact))
223
- app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self._handle_message))
449
+ app.add_handler(MessageHandler(
450
+ (filters.TEXT | filters.PHOTO) & ~filters.COMMAND,
451
+ self._handle_message,
452
+ ))
224
453
  self._app = app
225
454
  return app
226
455
 
@@ -228,7 +457,12 @@ class TelegramBot:
228
457
  """Register slash-commands with Telegram so they appear in the menu."""
229
458
  try:
230
459
  await self._app.bot.set_my_commands(self._BOT_COMMANDS)
231
- logger.info("[Telegram] Registered %d bot commands", len(self._BOT_COMMANDS))
460
+ me = await self._app.bot.get_me()
461
+ self._bot_username = me.username
462
+ logger.info(
463
+ "[Telegram] Registered %d bot commands, username=@%s",
464
+ len(self._BOT_COMMANDS), self._bot_username,
465
+ )
232
466
  except Exception:
233
467
  logger.warning("[Telegram] Failed to register bot commands", exc_info=True)
234
468
 
@@ -259,14 +493,44 @@ class TelegramBot:
259
493
 
260
494
  # ── Utility ───────────────────────────────────────────────────────────────────
261
495
 
496
+ _LEAKED_TOOL_RE = re.compile(
497
+ r'<\s*\|?\s*(?:DSML|antml)\s*\|\s*function_calls[^>]*>'
498
+ r'[\s\S]*?'
499
+ r'<\s*/\s*\|?\s*(?:DSML|antml)\s*\|\s*function_calls\s*>',
500
+ re.IGNORECASE,
501
+ )
502
+
503
+
504
+ def _clean_response(text: str) -> str:
505
+ """Strip leaked tool-call XML/DSML markup from LLM output."""
506
+ text = _LEAKED_TOOL_RE.sub('', text)
507
+ text = re.sub(r'\n{3,}', '\n\n', text)
508
+ return text.strip()
509
+
510
+
262
511
  def _split_message(text: str, limit: int = 4096) -> list[str]:
263
- """Split a long string into chunks that fit within Telegram's message limit."""
512
+ """Split text into chunks respecting natural boundaries.
513
+
514
+ Tries paragraph breaks first, then newlines, then word boundaries,
515
+ and only falls back to a hard character cut as a last resort.
516
+ """
264
517
  if len(text) <= limit:
265
518
  return [text]
266
- chunks = []
519
+ chunks: list[str] = []
520
+ min_break = limit // 3
267
521
  while text:
268
- chunks.append(text[:limit])
269
- text = text[limit:]
522
+ if len(text) <= limit:
523
+ chunks.append(text)
524
+ break
525
+ split_at = text.rfind('\n\n', min_break, limit)
526
+ if split_at < min_break:
527
+ split_at = text.rfind('\n', min_break, limit)
528
+ if split_at < min_break:
529
+ split_at = text.rfind(' ', min_break, limit)
530
+ if split_at < min_break:
531
+ split_at = limit
532
+ chunks.append(text[:split_at].rstrip())
533
+ text = text[split_at:].lstrip()
270
534
  return chunks
271
535
 
272
536
 
@@ -280,10 +544,14 @@ def create_bot(session_manager: "SessionManager") -> TelegramBot:
280
544
  allowed_users = config.get_int_list(
281
545
  "channels", "telegram", "allowedUsers", env="TELEGRAM_ALLOWED_USERS",
282
546
  )
547
+ require_mention = config.get_bool(
548
+ "channels", "telegram", "requireMention", default=False,
549
+ )
283
550
  return TelegramBot(
284
551
  session_manager=session_manager,
285
552
  token=token,
286
553
  allowed_users=allowed_users or None,
554
+ require_mention=require_mention,
287
555
  )
288
556
 
289
557