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.
- pythonclaw/__init__.py +1 -1
- pythonclaw/channels/discord_bot.py +72 -11
- pythonclaw/channels/telegram_bot.py +145 -15
- pythonclaw/channels/whatsapp_bot.py +304 -0
- pythonclaw/config.py +25 -0
- pythonclaw/core/__init__.py +1 -1
- pythonclaw/core/agent.py +439 -92
- 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 +114 -13
- pythonclaw/core/memory/storage.py +83 -1
- pythonclaw/core/persistent_agent.py +33 -1
- pythonclaw/core/retrieval/__init__.py +1 -1
- pythonclaw/core/retrieval/dense.py +2 -2
- pythonclaw/core/retrieval/retriever.py +1 -1
- pythonclaw/core/session_store.py +1 -1
- pythonclaw/core/skill_loader.py +93 -10
- pythonclaw/core/skillhub.py +168 -150
- pythonclaw/core/tools.py +37 -0
- pythonclaw/init.py +2 -1
- pythonclaw/main.py +21 -13
- pythonclaw/onboard.py +62 -15
- pythonclaw/server.py +21 -2
- pythonclaw/session_manager.py +63 -12
- pythonclaw/templates/skills/communication/CATEGORY.md +3 -2
- pythonclaw/templates/skills/communication/email/SKILL.md +33 -22
- pythonclaw/templates/skills/communication/slack/SKILL.md +98 -0
- pythonclaw/templates/skills/communication/slack/slack_api.py +153 -0
- pythonclaw/templates/skills/data/CATEGORY.md +3 -2
- pythonclaw/templates/skills/data/csv_analyzer/SKILL.md +28 -18
- pythonclaw/templates/skills/data/finance/SKILL.md +25 -15
- pythonclaw/templates/skills/data/news/SKILL.md +31 -18
- pythonclaw/templates/skills/data/pdf_reader/SKILL.md +24 -13
- pythonclaw/templates/skills/data/scraper/SKILL.md +1 -0
- pythonclaw/templates/skills/data/weather/SKILL.md +46 -20
- pythonclaw/templates/skills/data/weather/weather.py +1 -1
- pythonclaw/templates/skills/data/youtube/SKILL.md +27 -16
- pythonclaw/templates/skills/dev/CATEGORY.md +3 -2
- pythonclaw/templates/skills/dev/code_runner/SKILL.md +39 -27
- pythonclaw/templates/skills/dev/code_runner/run_code.py +1 -1
- pythonclaw/templates/skills/dev/github/SKILL.md +48 -22
- pythonclaw/templates/skills/dev/http_request/SKILL.md +32 -22
- pythonclaw/templates/skills/google/CATEGORY.md +3 -2
- pythonclaw/templates/skills/google/workspace/SKILL.md +43 -36
- pythonclaw/templates/skills/google/workspace/check_setup.sh +52 -0
- pythonclaw/templates/skills/media/CATEGORY.md +5 -0
- pythonclaw/templates/skills/media/image_gen/SKILL.md +99 -0
- pythonclaw/templates/skills/media/image_gen/generate.py +103 -0
- pythonclaw/templates/skills/media/spotify/SKILL.md +124 -0
- pythonclaw/templates/skills/media/spotify/spotify_ctl.py +231 -0
- pythonclaw/templates/skills/media/tts/SKILL.md +83 -0
- pythonclaw/templates/skills/media/tts/speak.py +50 -0
- pythonclaw/templates/skills/meta/CATEGORY.md +3 -2
- pythonclaw/templates/skills/meta/skill_creator/SKILL.md +47 -119
- pythonclaw/templates/skills/productivity/CATEGORY.md +5 -0
- pythonclaw/templates/skills/productivity/notion/SKILL.md +99 -0
- pythonclaw/templates/skills/productivity/notion/notion_api.py +185 -0
- pythonclaw/templates/skills/productivity/obsidian/SKILL.md +110 -0
- pythonclaw/templates/skills/productivity/obsidian/obsidian_vault.py +165 -0
- pythonclaw/templates/skills/productivity/trello/SKILL.md +110 -0
- pythonclaw/templates/skills/productivity/trello/trello_api.py +141 -0
- pythonclaw/templates/skills/system/CATEGORY.md +3 -2
- pythonclaw/templates/skills/system/change_persona/SKILL.md +24 -18
- pythonclaw/templates/skills/system/change_setting/SKILL.md +47 -43
- pythonclaw/templates/skills/system/change_setting/update_config.py +1 -0
- pythonclaw/templates/skills/system/change_soul/SKILL.md +21 -16
- pythonclaw/templates/skills/system/model_usage/SKILL.md +73 -0
- pythonclaw/templates/skills/system/model_usage/usage_stats.py +73 -0
- pythonclaw/templates/skills/system/onboarding/SKILL.md +24 -29
- pythonclaw/templates/skills/system/random/SKILL.md +27 -9
- pythonclaw/templates/skills/system/session_logs/SKILL.md +80 -0
- pythonclaw/templates/skills/system/session_logs/search_sessions.py +55 -0
- pythonclaw/templates/skills/system/time/SKILL.md +27 -9
- pythonclaw/templates/skills/system/time/time_util.py +1 -1
- pythonclaw/templates/skills/text/CATEGORY.md +3 -2
- pythonclaw/templates/skills/text/translator/SKILL.md +33 -28
- pythonclaw/templates/skills/web/CATEGORY.md +3 -2
- pythonclaw/templates/skills/web/summarize/SKILL.md +84 -0
- pythonclaw/templates/skills/web/summarize/summarize_url.py +107 -0
- pythonclaw/templates/skills/web/tavily/SKILL.md +38 -37
- pythonclaw/templates/tools/TOOLS.md +43 -0
- pythonclaw/web/app.py +237 -28
- pythonclaw/web/static/index.html +419 -85
- {pythonclaw-0.3.3.dist-info → pythonclaw-0.6.0.dist-info}/METADATA +63 -38
- pythonclaw-0.6.0.dist-info/RECORD +120 -0
- pythonclaw-0.3.3.dist-info/RECORD +0 -95
- {pythonclaw-0.3.3.dist-info → pythonclaw-0.6.0.dist-info}/WHEEL +0 -0
- {pythonclaw-0.3.3.dist-info → pythonclaw-0.6.0.dist-info}/entry_points.txt +0 -0
- {pythonclaw-0.3.3.dist-info → pythonclaw-0.6.0.dist-info}/licenses/LICENSE +0 -0
- {pythonclaw-0.3.3.dist-info → pythonclaw-0.6.0.dist-info}/top_level.txt +0 -0
pythonclaw/__init__.py
CHANGED
|
@@ -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,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(
|
|
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
|
-
|
|
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
|
-
"
|
|
104
|
-
"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"
|
|
105
140
|
"Commands:\n"
|
|
106
|
-
" /start
|
|
107
|
-
" /reset
|
|
108
|
-
" /status
|
|
109
|
-
" /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"
|
|
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"
|
|
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("
|
|
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
|
-
|
|
157
|
-
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:
|
|
158
202
|
return
|
|
203
|
+
|
|
159
204
|
sid = self._session_id(update.effective_chat.id)
|
|
160
205
|
agent = self._sm.get_or_create(sid)
|
|
161
|
-
|
|
206
|
+
|
|
207
|
+
if self._sm.is_locked(sid):
|
|
208
|
+
await update.message.reply_text("\u23f3 Processing previous message\u2026")
|
|
209
|
+
|
|
162
210
|
try:
|
|
163
|
-
|
|
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(
|
|
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
|
|