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.
- pythonclaw/channels/discord_bot.py +65 -11
- pythonclaw/channels/telegram_bot.py +298 -30
- pythonclaw/channels/whatsapp_bot.py +60 -8
- pythonclaw/core/agent.py +347 -114
- pythonclaw/core/llm/anthropic_client.py +212 -22
- pythonclaw/core/llm/base.py +29 -0
- pythonclaw/core/llm/gemini_client.py +53 -1
- pythonclaw/core/llm/openai_compatible.py +71 -1
- 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.1.dist-info}/METADATA +9 -9
- {pythonclaw-0.5.0.dist-info → pythonclaw-0.6.1.dist-info}/RECORD +45 -45
- {pythonclaw-0.5.0.dist-info → pythonclaw-0.6.1.dist-info}/WHEEL +0 -0
- {pythonclaw-0.5.0.dist-info → pythonclaw-0.6.1.dist-info}/entry_points.txt +0 -0
- {pythonclaw-0.5.0.dist-info → pythonclaw-0.6.1.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
-
|
|
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,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
|
-
"
|
|
105
|
-
"Just send me a message and I'll do my best to help.\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
|
|
108
|
-
" /reset
|
|
109
|
-
" /status
|
|
110
|
-
" /compact [hint]
|
|
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"
|
|
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("
|
|
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
|
-
|
|
158
|
-
if
|
|
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("
|
|
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
|
|
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
|
-
|
|
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
|
|
184
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
269
|
-
|
|
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
|
|