pythonclaw 0.5.0__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 (45) hide show
  1. pythonclaw/channels/discord_bot.py +65 -11
  2. pythonclaw/channels/telegram_bot.py +97 -22
  3. pythonclaw/channels/whatsapp_bot.py +60 -8
  4. pythonclaw/core/agent.py +299 -80
  5. pythonclaw/core/llm/anthropic_client.py +208 -21
  6. pythonclaw/core/llm/base.py +29 -0
  7. pythonclaw/core/llm/gemini_client.py +52 -1
  8. pythonclaw/core/llm/openai_compatible.py +66 -0
  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.0.dist-info}/METADATA +9 -9
  41. {pythonclaw-0.5.0.dist-info → pythonclaw-0.6.0.dist-info}/RECORD +45 -45
  42. {pythonclaw-0.5.0.dist-info → pythonclaw-0.6.0.dist-info}/WHEEL +0 -0
  43. {pythonclaw-0.5.0.dist-info → pythonclaw-0.6.0.dist-info}/entry_points.txt +0 -0
  44. {pythonclaw-0.5.0.dist-info → pythonclaw-0.6.0.dist-info}/licenses/LICENSE +0 -0
  45. {pythonclaw-0.5.0.dist-info → pythonclaw-0.6.0.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,16 +14,23 @@ 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
28
35
  from typing import TYPE_CHECKING
29
36
 
@@ -57,11 +64,14 @@ class TelegramBot:
57
64
  session_manager: "SessionManager",
58
65
  token: str,
59
66
  allowed_users: list[int] | None = None,
67
+ require_mention: bool = False,
60
68
  ) -> None:
61
69
  self._sm = session_manager
62
70
  self._token = token
63
71
  self._allowed_users: set[int] = set(allowed_users) if allowed_users else set()
72
+ self._require_mention = require_mention
64
73
  self._app: Application | None = None
74
+ self._bot_username: str | None = None
65
75
 
66
76
  # ── Session ID convention ─────────────────────────────────────────────────
67
77
 
@@ -93,6 +103,29 @@ class TelegramBot:
93
103
  return False
94
104
  return True
95
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
+
96
129
  # ── Command handlers ──────────────────────────────────────────────────────
97
130
 
98
131
  async def _cmd_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
@@ -101,13 +134,14 @@ class TelegramBot:
101
134
  sid = self._session_id(update.effective_chat.id)
102
135
  self._sm.get_or_create(sid)
103
136
  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"
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"
106
140
  "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"
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"
111
145
  )
112
146
 
113
147
  async def _cmd_reset(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
@@ -124,7 +158,7 @@ class TelegramBot:
124
158
  agent = self._sm.get_or_create(sid)
125
159
  from ..core.compaction import estimate_tokens
126
160
  await update.message.reply_text(
127
- f"📊 Session Status\n"
161
+ f"\U0001f4ca Session Status\n"
128
162
  f" Session ID : {sid}\n"
129
163
  f" Provider : {type(agent.provider).__name__}\n"
130
164
  f" Skills : {len(agent.loaded_skill_names)} loaded\n"
@@ -141,7 +175,7 @@ class TelegramBot:
141
175
  sid = self._session_id(update.effective_chat.id)
142
176
  agent = self._sm.get_or_create(sid)
143
177
  hint: str | None = " ".join(context.args).strip() or None if context.args else None
144
- await update.message.reply_text(" Compacting conversation history...")
178
+ await update.message.reply_text("\u23f3 Compacting conversation history...")
145
179
  try:
146
180
  result = agent.compact(instruction=hint)
147
181
  except Exception as exc:
@@ -149,43 +183,55 @@ class TelegramBot:
149
183
  for chunk in _split_message(result):
150
184
  await update.message.reply_text(chunk)
151
185
 
152
- # ── Message handler ───────────────────────────────────────────────────────
186
+ # ── Message handler (text + photos) ───────────────────────────────────────
153
187
 
154
188
  async def _handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
155
189
  if not await self._check_access(update, context):
156
190
  return
157
- user_text = (update.message.text or "").strip()
158
- 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:
159
202
  return
203
+
160
204
  sid = self._session_id(update.effective_chat.id)
161
205
  agent = self._sm.get_or_create(sid)
162
206
 
163
207
  if self._sm.is_locked(sid):
164
- await update.message.reply_text(" Processing previous message")
208
+ await update.message.reply_text("\u23f3 Processing previous message\u2026")
165
209
 
166
- # React to the message so the user knows the bot saw it
167
210
  try:
168
- await update.message.set_reaction([ReactionTypeEmoji("👀")])
211
+ await update.message.set_reaction([ReactionTypeEmoji("\U0001f440")])
169
212
  except Exception:
170
- pass # reaction API may fail on older bot API or in groups
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
+ )
171
221
 
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
222
  typing_task = asyncio.create_task(
176
223
  self._keep_typing(update.message.chat_id)
177
224
  )
178
225
  try:
179
226
  async with self._sm.acquire(sid):
180
227
  loop = asyncio.get_event_loop()
181
- response = await loop.run_in_executor(None, agent.chat, user_text)
228
+ response = await loop.run_in_executor(None, agent.chat, chat_input)
182
229
  except Exception as exc:
183
230
  logger.exception("[Telegram] Agent.chat() raised an exception")
184
231
  response = f"Sorry, something went wrong: {exc}"
185
232
  finally:
186
233
  typing_task.cancel()
187
234
 
188
- # Clear the "seen" reaction once we reply
189
235
  try:
190
236
  await update.message.set_reaction([])
191
237
  except Exception:
@@ -194,6 +240,23 @@ class TelegramBot:
194
240
  for chunk in _split_message(response or "(no response)"):
195
241
  await update.message.reply_text(chunk)
196
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
+
197
260
  async def _keep_typing(self, chat_id: int) -> None:
198
261
  """Re-send the 'typing' chat action every 4 s until cancelled."""
199
262
  try:
@@ -220,7 +283,10 @@ class TelegramBot:
220
283
  app.add_handler(CommandHandler("reset", self._cmd_reset))
221
284
  app.add_handler(CommandHandler("status", self._cmd_status))
222
285
  app.add_handler(CommandHandler("compact", self._cmd_compact))
223
- 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
+ ))
224
290
  self._app = app
225
291
  return app
226
292
 
@@ -228,7 +294,12 @@ class TelegramBot:
228
294
  """Register slash-commands with Telegram so they appear in the menu."""
229
295
  try:
230
296
  await self._app.bot.set_my_commands(self._BOT_COMMANDS)
231
- logger.info("[Telegram] Registered %d bot commands", len(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
+ )
232
303
  except Exception:
233
304
  logger.warning("[Telegram] Failed to register bot commands", exc_info=True)
234
305
 
@@ -280,10 +351,14 @@ def create_bot(session_manager: "SessionManager") -> TelegramBot:
280
351
  allowed_users = config.get_int_list(
281
352
  "channels", "telegram", "allowedUsers", env="TELEGRAM_ALLOWED_USERS",
282
353
  )
354
+ require_mention = config.get_bool(
355
+ "channels", "telegram", "requireMention", default=False,
356
+ )
283
357
  return TelegramBot(
284
358
  session_manager=session_manager,
285
359
  token=token,
286
360
  allowed_users=allowed_users or None,
361
+ require_mention=require_mention,
287
362
  )
288
363
 
289
364
 
@@ -20,16 +20,23 @@ Commands
20
20
  !status -- show session info
21
21
  !compact [hint] -- compact conversation history
22
22
  <text> -- forwarded to Agent.chat(), reply sent back
23
+ <image> -- image sent to LLM for analysis
23
24
 
24
25
  Access control
25
26
  --------------
26
27
  Set ``channels.whatsapp.allowedNumbers`` in ``pythonclaw.json`` to a list of
27
28
  E.164 phone numbers (without "+") to restrict access. Leave empty to allow
28
29
  everyone.
30
+
31
+ Group behaviour
32
+ ---------------
33
+ Set ``channels.whatsapp.requireMention`` to ``true`` to require @bot mention
34
+ in group chats. DMs always respond.
29
35
  """
30
36
 
31
37
  from __future__ import annotations
32
38
 
39
+ import base64
33
40
  import logging
34
41
  import threading
35
42
  from typing import TYPE_CHECKING
@@ -61,6 +68,7 @@ class WhatsAppBot:
61
68
  verify_token: str,
62
69
  callback_url: str | None = None,
63
70
  allowed_numbers: list[str] | None = None,
71
+ require_mention: bool = False,
64
72
  ) -> None:
65
73
  self._sm = session_manager
66
74
  self._phone_id = phone_id
@@ -68,8 +76,9 @@ class WhatsAppBot:
68
76
  self._verify_token = verify_token
69
77
  self._callback_url = callback_url
70
78
  self._allowed_numbers: set[str] = set(allowed_numbers) if allowed_numbers else set()
79
+ self._require_mention = require_mention
71
80
  self._wa = None # set in mount()
72
- self._locks: dict[str, threading.Lock] = {} # per-session threading locks
81
+ self._locks: dict[str, threading.Lock] = {}
73
82
 
74
83
  # ── Session ID convention ─────────────────────────────────────────────────
75
84
 
@@ -92,10 +101,7 @@ class WhatsAppBot:
92
101
  # ── Mount on FastAPI ──────────────────────────────────────────────────────
93
102
 
94
103
  def mount(self, app: "FastAPI") -> None:
95
- """Attach the PyWa webhook handler to *app*.
96
-
97
- Must be called before the FastAPI server starts accepting requests.
98
- """
104
+ """Attach the PyWa webhook handler to *app*."""
99
105
  try:
100
106
  from pywa import WhatsApp, types
101
107
  except ImportError:
@@ -116,7 +122,7 @@ class WhatsAppBot:
116
122
 
117
123
  wa = WhatsApp(**wa_kwargs)
118
124
  self._wa = wa
119
- bot = self # closure reference
125
+ bot = self
120
126
 
121
127
  @wa.on_message
122
128
  def _on_message(client: WhatsApp, msg: types.Message) -> None:
@@ -126,7 +132,20 @@ class WhatsAppBot:
126
132
  return
127
133
 
128
134
  text = (msg.text or "").strip()
129
- if not text:
135
+ has_image = msg.has_media and getattr(msg, "image", None) is not None
136
+
137
+ # Group mention check
138
+ is_group = getattr(msg, "is_group", False)
139
+ if is_group and bot._require_mention:
140
+ mentioned = False
141
+ if hasattr(msg, "mentioned") and msg.mentioned:
142
+ mentioned = True
143
+ elif text and bot._phone_id:
144
+ mentioned = bot._phone_id in text
145
+ if not mentioned:
146
+ return
147
+
148
+ if not text and not has_image:
130
149
  return
131
150
 
132
151
  sid = bot._session_id(wa_id)
@@ -161,13 +180,20 @@ class WhatsAppBot:
161
180
  msg.reply(chunk)
162
181
  return
163
182
 
183
+ # Build input (text or multimodal)
184
+ chat_input = text or "What's in this image?"
185
+ if has_image:
186
+ chat_input = _build_wa_image_input(
187
+ client, msg, text or "What's in this image?"
188
+ )
189
+
164
190
  lock = bot._get_lock(sid)
165
191
  if lock.locked():
166
192
  msg.reply("Processing previous message...")
167
193
 
168
194
  try:
169
195
  with lock:
170
- response = agent.chat(text)
196
+ response = agent.chat(chat_input)
171
197
  except Exception as exc:
172
198
  logger.exception("[WhatsApp] Agent.chat() raised an exception")
173
199
  response = f"Sorry, something went wrong: {exc}"
@@ -201,6 +227,27 @@ def _split_message(text: str, limit: int = 4096) -> list[str]:
201
227
  return chunks
202
228
 
203
229
 
230
+ def _build_wa_image_input(client, msg, caption: str) -> list:
231
+ """Download WhatsApp image and build multimodal content array."""
232
+ try:
233
+ image = msg.image
234
+ data = image.download(in_memory=True)
235
+ b64 = base64.b64encode(data).decode()
236
+ media_type = getattr(image, "mime_type", "image/jpeg")
237
+ return [
238
+ {"type": "text", "text": caption},
239
+ {
240
+ "type": "image_url",
241
+ "image_url": {
242
+ "url": f"data:{media_type};base64,{b64}",
243
+ },
244
+ },
245
+ ]
246
+ except Exception:
247
+ logger.warning("[WhatsApp] Failed to download image")
248
+ return caption
249
+
250
+
204
251
  def create_bot(session_manager: "SessionManager") -> WhatsAppBot:
205
252
  """Create a WhatsAppBot from pythonclaw.json / env vars."""
206
253
  phone_id = config.get_str(
@@ -239,6 +286,10 @@ def create_bot(session_manager: "SessionManager") -> WhatsAppBot:
239
286
  env="WHATSAPP_ALLOWED_NUMBERS",
240
287
  )
241
288
 
289
+ require_mention = config.get_bool(
290
+ "channels", "whatsapp", "requireMention", default=False,
291
+ )
292
+
242
293
  return WhatsAppBot(
243
294
  session_manager=session_manager,
244
295
  phone_id=phone_id,
@@ -246,6 +297,7 @@ def create_bot(session_manager: "SessionManager") -> WhatsAppBot:
246
297
  verify_token=verify_token,
247
298
  callback_url=callback_url,
248
299
  allowed_numbers=allowed_numbers or None,
300
+ require_mention=require_mention,
249
301
  )
250
302
 
251
303