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.
- pythonclaw/channels/discord_bot.py +65 -11
- pythonclaw/channels/telegram_bot.py +97 -22
- pythonclaw/channels/whatsapp_bot.py +60 -8
- pythonclaw/core/agent.py +299 -80
- pythonclaw/core/llm/anthropic_client.py +208 -21
- pythonclaw/core/llm/base.py +29 -0
- pythonclaw/core/llm/gemini_client.py +52 -1
- pythonclaw/core/llm/openai_compatible.py +66 -0
- pythonclaw/core/memory/manager.py +75 -0
- pythonclaw/core/memory/storage.py +83 -0
- pythonclaw/core/persistent_agent.py +27 -1
- pythonclaw/core/session_store.py +1 -1
- pythonclaw/core/skill_loader.py +13 -13
- pythonclaw/core/skillhub.py +166 -176
- pythonclaw/core/tools.py +37 -0
- pythonclaw/main.py +15 -10
- pythonclaw/onboard.py +14 -14
- pythonclaw/templates/skills/communication/CATEGORY.md +1 -1
- pythonclaw/templates/skills/communication/slack/SKILL.md +1 -1
- pythonclaw/templates/skills/data/CATEGORY.md +1 -1
- pythonclaw/templates/skills/dev/CATEGORY.md +1 -1
- pythonclaw/templates/skills/google/CATEGORY.md +1 -1
- pythonclaw/templates/skills/media/CATEGORY.md +1 -1
- pythonclaw/templates/skills/media/image_gen/SKILL.md +1 -1
- pythonclaw/templates/skills/media/spotify/SKILL.md +1 -1
- pythonclaw/templates/skills/media/tts/SKILL.md +1 -1
- pythonclaw/templates/skills/meta/CATEGORY.md +1 -1
- pythonclaw/templates/skills/productivity/CATEGORY.md +1 -1
- pythonclaw/templates/skills/productivity/notion/SKILL.md +1 -1
- pythonclaw/templates/skills/productivity/obsidian/SKILL.md +1 -1
- pythonclaw/templates/skills/productivity/trello/SKILL.md +1 -1
- pythonclaw/templates/skills/system/CATEGORY.md +1 -1
- pythonclaw/templates/skills/system/model_usage/SKILL.md +1 -1
- pythonclaw/templates/skills/system/session_logs/SKILL.md +1 -1
- pythonclaw/templates/skills/text/CATEGORY.md +1 -1
- pythonclaw/templates/skills/web/CATEGORY.md +1 -1
- pythonclaw/templates/skills/web/summarize/SKILL.md +1 -1
- pythonclaw/web/app.py +134 -29
- pythonclaw/web/static/index.html +276 -107
- {pythonclaw-0.5.0.dist-info → pythonclaw-0.6.0.dist-info}/METADATA +9 -9
- {pythonclaw-0.5.0.dist-info → pythonclaw-0.6.0.dist-info}/RECORD +45 -45
- {pythonclaw-0.5.0.dist-info → pythonclaw-0.6.0.dist-info}/WHEEL +0 -0
- {pythonclaw-0.5.0.dist-info → pythonclaw-0.6.0.dist-info}/entry_points.txt +0 -0
- {pythonclaw-0.5.0.dist-info → pythonclaw-0.6.0.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
"
|
|
105
|
-
"Just send me a message and I'll do my best to help.\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
|
|
108
|
-
" /reset
|
|
109
|
-
" /status
|
|
110
|
-
" /compact [hint]
|
|
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"
|
|
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("
|
|
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
|
-
|
|
158
|
-
if
|
|
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("
|
|
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
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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] = {}
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|