TeLLMgramBot 3.10.4__tar.gz → 3.11.0__tar.gz
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.
- {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/PKG-INFO +5 -3
- {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/README.md +4 -2
- {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot/TeLLMgramBot.py +225 -66
- tellmgrambot-3.11.0/TeLLMgramBot/tools.py +273 -0
- {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot.egg-info/PKG-INFO +5 -3
- {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot.egg-info/SOURCES.txt +1 -0
- {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/setup.py +1 -1
- {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/LICENSE +0 -0
- {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot/__init__.py +0 -0
- {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot/conversation.py +0 -0
- {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot/database.py +0 -0
- {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot/initialize.py +0 -0
- {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot/message_handlers.py +0 -0
- {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot/models.py +0 -0
- {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot/providers/__init__.py +0 -0
- {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot/providers/anthropic_provider.py +0 -0
- {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot/providers/base.py +0 -0
- {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot/providers/factory.py +0 -0
- {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot/providers/openai_provider.py +0 -0
- {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot/utils.py +0 -0
- {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot/web_utils.py +0 -0
- {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot.egg-info/dependency_links.txt +0 -0
- {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot.egg-info/requires.txt +0 -0
- {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot.egg-info/top_level.txt +0 -0
- {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: TeLLMgramBot
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.11.0
|
|
4
4
|
Summary: LLM-powered Telegram bot (OpenAI + Anthropic)
|
|
5
5
|
Home-page: https://github.com/Digital-Heresy/TeLLMgramBot
|
|
6
6
|
Author: Digital Heresy
|
|
@@ -122,9 +122,10 @@ os.environ['TELLMGRAMBOT_VIRUSTOTAL_API_KEY'] = my_vault.get('virustotal_key')
|
|
|
122
122
|
|
|
123
123
|
### Available Commands
|
|
124
124
|
- `/nick <name>` - Set your nickname (for bot use in group chats).
|
|
125
|
-
- `/forget` - Clear your conversation history. In private chats, clears everything and resets all active sessions. In group chats, removes only your messages.
|
|
125
|
+
- `/forget` - Clear your conversation history. Shows a confirmation prompt before deletion. In private chats, clears everything and resets all active sessions. In group chats, removes only your messages.
|
|
126
126
|
- `/private` - Toggle private mode (private chats only). When ON, your messages are excluded from group context loading.
|
|
127
|
-
- `/
|
|
127
|
+
- `/tools` - List all registered tools available to this bot instance (admin-only, private chat only). Shows the built-in search_messages tool and any webhook tools defined in config.yaml.
|
|
128
|
+
- `/help` - Display available commands and usage information. In private chats, if you are a bot owner, also shows administrator-only commands (`/start`, `/stop`, `/wipe`, `/tools`).
|
|
128
129
|
|
|
129
130
|
### Group Chat Triggers
|
|
130
131
|
The bot responds in groups when you:
|
|
@@ -151,6 +152,7 @@ When the bot is triggered in a group and about to respond (not deferring to anot
|
|
|
151
152
|
- `db_name`: Optional custom database filename without extension (e.g. `MyBot` creates `MyBot.db`); omit for default `conversations.db`. Use distinct names when running multiple bot instances in the same directory.
|
|
152
153
|
- `token_limit`: Max tokens (optional; defaults to model's maximum)
|
|
153
154
|
- `search_limit`: Max search results (optional; defaults to 30)
|
|
155
|
+
- `tools`: Optional list of webhook tool definitions (admin-only, private chat only). See [docs/tools.md](docs/tools.md) for schema and examples.
|
|
154
156
|
4. **Disable group privacy mode in BotFather:**
|
|
155
157
|
```
|
|
156
158
|
/setprivacy -> select your bot -> Disable
|
|
@@ -90,9 +90,10 @@ os.environ['TELLMGRAMBOT_VIRUSTOTAL_API_KEY'] = my_vault.get('virustotal_key')
|
|
|
90
90
|
|
|
91
91
|
### Available Commands
|
|
92
92
|
- `/nick <name>` - Set your nickname (for bot use in group chats).
|
|
93
|
-
- `/forget` - Clear your conversation history. In private chats, clears everything and resets all active sessions. In group chats, removes only your messages.
|
|
93
|
+
- `/forget` - Clear your conversation history. Shows a confirmation prompt before deletion. In private chats, clears everything and resets all active sessions. In group chats, removes only your messages.
|
|
94
94
|
- `/private` - Toggle private mode (private chats only). When ON, your messages are excluded from group context loading.
|
|
95
|
-
- `/
|
|
95
|
+
- `/tools` - List all registered tools available to this bot instance (admin-only, private chat only). Shows the built-in search_messages tool and any webhook tools defined in config.yaml.
|
|
96
|
+
- `/help` - Display available commands and usage information. In private chats, if you are a bot owner, also shows administrator-only commands (`/start`, `/stop`, `/wipe`, `/tools`).
|
|
96
97
|
|
|
97
98
|
### Group Chat Triggers
|
|
98
99
|
The bot responds in groups when you:
|
|
@@ -119,6 +120,7 @@ When the bot is triggered in a group and about to respond (not deferring to anot
|
|
|
119
120
|
- `db_name`: Optional custom database filename without extension (e.g. `MyBot` creates `MyBot.db`); omit for default `conversations.db`. Use distinct names when running multiple bot instances in the same directory.
|
|
120
121
|
- `token_limit`: Max tokens (optional; defaults to model's maximum)
|
|
121
122
|
- `search_limit`: Max search results (optional; defaults to 30)
|
|
123
|
+
- `tools`: Optional list of webhook tool definitions (admin-only, private chat only). See [docs/tools.md](docs/tools.md) for schema and examples.
|
|
122
124
|
4. **Disable group privacy mode in BotFather:**
|
|
123
125
|
```
|
|
124
126
|
/setprivacy -> select your bot -> Disable
|
|
@@ -7,10 +7,10 @@ import os
|
|
|
7
7
|
import re
|
|
8
8
|
from math import floor
|
|
9
9
|
|
|
10
|
-
from telegram import Bot, Update, Message, Chat, User, ReactionTypeEmoji, MessageEntity
|
|
10
|
+
from telegram import Bot, Update, Message, Chat, User, ReactionTypeEmoji, MessageEntity, InlineKeyboardButton, InlineKeyboardMarkup
|
|
11
11
|
from telegram.constants import MessageLimit
|
|
12
12
|
from telegram.error import TelegramError
|
|
13
|
-
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
|
|
13
|
+
from telegram.ext import Application, CallbackQueryHandler, CommandHandler, MessageHandler, filters, ContextTypes
|
|
14
14
|
|
|
15
15
|
from .conversation import Conversation
|
|
16
16
|
from .utils import format_dt
|
|
@@ -38,12 +38,25 @@ from .initialize import (
|
|
|
38
38
|
)
|
|
39
39
|
from .message_handlers import handle_greetings, handle_common_queries, handle_url_ask
|
|
40
40
|
from .models import TokenLimits
|
|
41
|
+
from .tools import build_tool_registry, execute_webhook
|
|
41
42
|
from .providers.factory import get_provider
|
|
42
43
|
from .providers.base import ContextLengthExceededError, ProviderAuthError, ProviderConnectionError, ToolCall
|
|
43
44
|
from .utils import exact_word_match, log_error
|
|
44
45
|
|
|
45
46
|
logger = logging.getLogger(__name__)
|
|
46
47
|
|
|
48
|
+
# Dialog copy - centralised so tests never hard-code these strings
|
|
49
|
+
_MSG_ADMIN_ONLY = "Sorry, I can't do that for you."
|
|
50
|
+
_MSG_PROCESS_ERROR = "Sorry, I couldn't process your message! Please contact my creator."
|
|
51
|
+
_MSG_TOOL_RESULT_ERROR = "Sorry, I couldn't process the tool result."
|
|
52
|
+
_MSG_NOT_YOUR_PROMPT = "Sorry, this prompt is not for you!"
|
|
53
|
+
_MSG_WIPE_PROMPT = "ALL of my memories will be lost! Are you sure?"
|
|
54
|
+
_MSG_WIPE_COMPLETE = "Wipe complete. I hope you won't regret this..."
|
|
55
|
+
_MSG_WIPE_CANCELLED = "Wipe cancelled. Whew, you scared me for a moment!"
|
|
56
|
+
_MSG_FORGET_PROMPT = "Do you really want me to forget our memories together?"
|
|
57
|
+
_MSG_FORGET_COMPLETE = "Forget complete. Fresh start it is..."
|
|
58
|
+
_MSG_FORGET_CANCELLED = "Forget cancelled. Glad you changed your mind!"
|
|
59
|
+
|
|
47
60
|
_SEARCH_TOOL = {
|
|
48
61
|
"name": "search_messages",
|
|
49
62
|
"description": (
|
|
@@ -116,7 +129,8 @@ class TelegramBot:
|
|
|
116
129
|
"\n\nAdmin commands:\n"
|
|
117
130
|
"/start - Go online to receive new responses (default).\n"
|
|
118
131
|
"/stop - Go offline to prevent new responses.\n"
|
|
119
|
-
"/wipe - Permanently delete all conversation data
|
|
132
|
+
"/wipe - Permanently delete all conversation data.\n"
|
|
133
|
+
"/tools - List all registered tools available to this bot."
|
|
120
134
|
)
|
|
121
135
|
await update.message.reply_text(text)
|
|
122
136
|
|
|
@@ -124,9 +138,8 @@ class TelegramBot:
|
|
|
124
138
|
"""
|
|
125
139
|
Start the bot and resume handling new messages (admin-only).
|
|
126
140
|
|
|
127
|
-
Only usernames in self.telegram['owners'] can execute this command.
|
|
141
|
+
Only usernames in self.telegram['owners'] can execute this command; otherwise deny access.
|
|
128
142
|
If the sender is an owner, sets self._online to True and confirms via reply.
|
|
129
|
-
Otherwise, denies access with "Sorry, I can't do that for you."
|
|
130
143
|
|
|
131
144
|
Args:
|
|
132
145
|
update: Telegram Update object containing the /start message.
|
|
@@ -138,15 +151,14 @@ class TelegramBot:
|
|
|
138
151
|
greeting_text = f"Oh, hello {update.message.from_user.first_name}! Let me get to work!"
|
|
139
152
|
await update.message.reply_text(greeting_text)
|
|
140
153
|
else:
|
|
141
|
-
await update.message.reply_text(
|
|
154
|
+
await update.message.reply_text(_MSG_ADMIN_ONLY)
|
|
142
155
|
|
|
143
156
|
async def tele_stop_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|
144
157
|
"""
|
|
145
158
|
Stop the bot and pause handling new messages (admin-only).
|
|
146
159
|
|
|
147
|
-
Only usernames in self.telegram['owners'] can execute this command.
|
|
160
|
+
Only usernames in self.telegram['owners'] can execute this command; otherwise, deny access.
|
|
148
161
|
If the sender is an owner, sets self._online to False and confirms via reply.
|
|
149
|
-
Otherwise, denies access with "Sorry, I can't do that for you."
|
|
150
162
|
|
|
151
163
|
Args:
|
|
152
164
|
update: Telegram Update object containing the /stop message.
|
|
@@ -157,18 +169,41 @@ class TelegramBot:
|
|
|
157
169
|
self._online = False
|
|
158
170
|
await update.message.reply_text("Sure thing boss, cutting out!")
|
|
159
171
|
else:
|
|
160
|
-
await update.message.reply_text(
|
|
172
|
+
await update.message.reply_text(_MSG_ADMIN_ONLY)
|
|
161
173
|
|
|
162
|
-
async def
|
|
174
|
+
async def tele_tools_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|
163
175
|
"""
|
|
164
|
-
|
|
176
|
+
List all registered tools available to this bot instance (admin-only, private chat only).
|
|
165
177
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
178
|
+
Shows name and description for every tool the bot exposes, including the built-in
|
|
179
|
+
search_messages tool and any webhook tools defined in config.yaml. Restricted to
|
|
180
|
+
bot_owner in a private chat; any other caller receives a generic denial.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
update: Telegram Update object containing the /tools message.
|
|
184
|
+
context: Telegram context for sending the reply.
|
|
185
|
+
"""
|
|
186
|
+
uname = update.message.from_user.username
|
|
187
|
+
chat_type = update.message.chat.type
|
|
188
|
+
if uname not in self.telegram['owners'] or chat_type != 'private':
|
|
189
|
+
await update.message.reply_text(_MSG_ADMIN_ONLY)
|
|
190
|
+
return
|
|
191
|
+
def _first_sentence(text: str) -> str:
|
|
192
|
+
return text.split('. ')[0].rstrip('.!?') + '.'
|
|
193
|
+
|
|
194
|
+
lines = ["Registered tools:"]
|
|
195
|
+
lines.append(f"- search_messages - {_first_sentence(_SEARCH_TOOL['description'])}")
|
|
196
|
+
for schema in self.webhook_schemas:
|
|
197
|
+
lines.append(f"- {schema['name']} - {_first_sentence(schema['description'])}")
|
|
198
|
+
await update.message.reply_text('\n'.join(lines))
|
|
199
|
+
|
|
200
|
+
async def tele_wipe_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|
201
|
+
"""
|
|
202
|
+
Prompt for inline keyboard confirmation before wiping all conversation data (admin-only).
|
|
170
203
|
|
|
171
|
-
|
|
204
|
+
Only usernames in self.telegram['owners'] can execute this command, otherwise deny access.
|
|
205
|
+
Sends a confirmation message with Confirm and Cancel inline buttons, and the
|
|
206
|
+
actual wipe is handled by tele_wipe_callback() once the owner taps Confirm.
|
|
172
207
|
|
|
173
208
|
Args:
|
|
174
209
|
update: Telegram Update object containing the /wipe message.
|
|
@@ -176,12 +211,43 @@ class TelegramBot:
|
|
|
176
211
|
"""
|
|
177
212
|
uname = update.message.from_user.username
|
|
178
213
|
if uname not in self.telegram['owners']:
|
|
179
|
-
await update.message.reply_text(
|
|
214
|
+
await update.message.reply_text(_MSG_ADMIN_ONLY)
|
|
180
215
|
return
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
216
|
+
keyboard = InlineKeyboardMarkup([[
|
|
217
|
+
InlineKeyboardButton("Confirm", callback_data=f"wipe:confirm:{uname}"),
|
|
218
|
+
InlineKeyboardButton("Cancel", callback_data=f"wipe:cancel:{uname}"),
|
|
219
|
+
]])
|
|
220
|
+
await update.message.reply_text(_MSG_WIPE_PROMPT, reply_markup=keyboard)
|
|
221
|
+
|
|
222
|
+
async def tele_wipe_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|
223
|
+
"""
|
|
224
|
+
Handle the inline keyboard confirmation for /wipe.
|
|
225
|
+
|
|
226
|
+
Executes the wipe if the tapping user matches the original /wipe sender and they tapped Confirm.
|
|
227
|
+
If they tapped Cancel, the wipe is suppressed and the confirmation message is edited
|
|
228
|
+
to show a cancellation notice. Ignores taps from any other user with an ephemeral alert.
|
|
229
|
+
Confirm calls wipe_all_data() and clears all in-memory conversation state.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
update: Telegram Update containing the callback query.
|
|
233
|
+
context: Telegram context for answering the callback.
|
|
234
|
+
"""
|
|
235
|
+
query = update.callback_query
|
|
236
|
+
parts = query.data.split(':')
|
|
237
|
+
action, original_uname = parts[1], parts[2]
|
|
238
|
+
|
|
239
|
+
if query.from_user.username != original_uname:
|
|
240
|
+
await query.answer(_MSG_NOT_YOUR_PROMPT)
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
await query.answer()
|
|
244
|
+
if action == 'confirm':
|
|
245
|
+
await wipe_all_data()
|
|
246
|
+
self.conversations.clear()
|
|
247
|
+
self.token_warning.clear()
|
|
248
|
+
await query.edit_message_text(_MSG_WIPE_COMPLETE)
|
|
249
|
+
else:
|
|
250
|
+
await query.edit_message_text(_MSG_WIPE_CANCELLED)
|
|
185
251
|
|
|
186
252
|
async def tele_nick_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|
187
253
|
"""
|
|
@@ -205,24 +271,57 @@ class TelegramBot:
|
|
|
205
271
|
|
|
206
272
|
async def tele_forget_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|
207
273
|
"""
|
|
208
|
-
|
|
274
|
+
Prompt for inline keyboard confirmation before removing the requester's conversation history.
|
|
209
275
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
rows across all chats, leaving other users' messages intact.
|
|
276
|
+
Executes the forget if the tapping user matches the original /forget sender
|
|
277
|
+
and they tapped Confirm. If they tapped Cancel, the forget is suppressed and
|
|
278
|
+
the confirmation message is edited to show a cancellation notice. Ignores taps
|
|
279
|
+
from any other user with an ephemeral alert.
|
|
215
280
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
not affected.
|
|
281
|
+
Args:
|
|
282
|
+
update: Telegram Update object containing the /forget message.
|
|
283
|
+
context: Telegram context for sending the reply.
|
|
220
284
|
"""
|
|
221
285
|
validated = await self.tele_validate(update)
|
|
222
286
|
if not validated:
|
|
223
287
|
return
|
|
224
288
|
(msg, chat, user) = validated
|
|
225
|
-
|
|
289
|
+
keyboard = InlineKeyboardMarkup([[
|
|
290
|
+
InlineKeyboardButton("Confirm", callback_data=f"forget:confirm:{user.id}"),
|
|
291
|
+
InlineKeyboardButton("Cancel", callback_data=f"forget:cancel:{user.id}"),
|
|
292
|
+
]])
|
|
293
|
+
await msg.reply_text(_MSG_FORGET_PROMPT, reply_markup=keyboard)
|
|
294
|
+
|
|
295
|
+
async def tele_forget_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|
296
|
+
"""
|
|
297
|
+
Handle the inline keyboard confirmation for /forget.
|
|
298
|
+
|
|
299
|
+
Executes the forget if the tapping user matches the original /forget sender and
|
|
300
|
+
they tapped Confirm. Cancels gracefully if they tapped Cancel. Ignores taps
|
|
301
|
+
from any other user with an ephemeral alert.
|
|
302
|
+
- In private chats: deletes bot replies linked to this user, all user rows across chats,
|
|
303
|
+
then remaining chat-level rows (pre-migration).
|
|
304
|
+
- In group chats: bot replies and user rows only, leaving other participants intact.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
update: Telegram Update containing the callback query.
|
|
308
|
+
context: Telegram context for answering the callback.
|
|
309
|
+
"""
|
|
310
|
+
query = update.callback_query
|
|
311
|
+
parts = query.data.split(':')
|
|
312
|
+
action, original_user_id = parts[1], int(parts[2])
|
|
313
|
+
|
|
314
|
+
if query.from_user.id != original_user_id:
|
|
315
|
+
await query.answer(_MSG_NOT_YOUR_PROMPT)
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
await query.answer()
|
|
319
|
+
if action == 'cancel':
|
|
320
|
+
await query.edit_message_text(_MSG_FORGET_CANCELLED)
|
|
321
|
+
return
|
|
322
|
+
|
|
323
|
+
chat = update.effective_chat
|
|
324
|
+
user_id = original_user_id
|
|
226
325
|
chat_id = chat.id
|
|
227
326
|
chat_type = chat.type
|
|
228
327
|
|
|
@@ -249,7 +348,7 @@ class TelegramBot:
|
|
|
249
348
|
self.conversations.pop(evict_id, None)
|
|
250
349
|
self.token_warning.pop(evict_id, None)
|
|
251
350
|
|
|
252
|
-
await
|
|
351
|
+
await query.edit_message_text(_MSG_FORGET_COMPLETE)
|
|
253
352
|
|
|
254
353
|
async def tele_private_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|
255
354
|
"""
|
|
@@ -394,16 +493,16 @@ class TelegramBot:
|
|
|
394
493
|
url_match = re.search(r'\[http(s)?://\S+]', text)
|
|
395
494
|
|
|
396
495
|
# Form the assistant's message based on low level easy stuff or send to the LLM
|
|
397
|
-
reply =
|
|
496
|
+
reply = _MSG_PROCESS_ERROR
|
|
398
497
|
if url_match and self.key_status.url_analysis_enabled:
|
|
399
498
|
await msg.reply_text("Sure, give me a moment to look at that URL...")
|
|
400
499
|
reply = await handle_url_ask(text, self.llm['url_model'])
|
|
401
500
|
elif self._online and self.key_status.chat_enabled:
|
|
402
501
|
# This is the transition point between quick Telegram replies and the LLM.
|
|
403
|
-
tools =
|
|
502
|
+
tools = self._build_tool_list(chat_type, username)
|
|
404
503
|
result = await self.llm_completion(chat_id, tools)
|
|
405
504
|
if isinstance(result, ToolCall):
|
|
406
|
-
reply = await self._resolve_tool_call(result, conv, user_id)
|
|
505
|
+
reply = await self._resolve_tool_call(result, conv, user_id, username=username, chat_type=chat_type)
|
|
407
506
|
else:
|
|
408
507
|
reply = result
|
|
409
508
|
|
|
@@ -577,7 +676,7 @@ class TelegramBot:
|
|
|
577
676
|
|
|
578
677
|
# If it's a group text, only reply if the bot is named
|
|
579
678
|
# The real magic of how the bot behaves is in tele_handle_response()
|
|
580
|
-
response =
|
|
679
|
+
response = _MSG_PROCESS_ERROR
|
|
581
680
|
assistant_db_id = None
|
|
582
681
|
reply_context = ''
|
|
583
682
|
if chat.type == 'supergroup' or chat.type == 'group':
|
|
@@ -671,26 +770,49 @@ class TelegramBot:
|
|
|
671
770
|
if msg:
|
|
672
771
|
await msg.reply_text("Sorry, I ran into an error! Please contact my creator.")
|
|
673
772
|
|
|
674
|
-
|
|
773
|
+
def _build_tool_list(self, chat_type: str, username: str | None) -> list:
|
|
675
774
|
"""
|
|
676
|
-
|
|
775
|
+
Build the tool list to pass to the LLM for this request.
|
|
677
776
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
777
|
+
Webhook tool schemas are only injected when two conditions are both true:
|
|
778
|
+
(1) the chat is a private chat, and (2) the requesting user is bot_owner.
|
|
779
|
+
In all other contexts only _SEARCH_TOOL is included so webhook tool names,
|
|
780
|
+
descriptions, and endpoint structure are never visible to group participants
|
|
781
|
+
or non-owner users.
|
|
682
782
|
|
|
683
783
|
Args:
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
chat_id: Telegram chat ID of the current conversation (always included
|
|
687
|
-
in accessible_chat_ids for search, ensuring current chat is searchable).
|
|
784
|
+
chat_type: Telegram chat type string ('private', 'group', 'supergroup', etc.).
|
|
785
|
+
username: Telegram username of the requesting user (without @), or None.
|
|
688
786
|
|
|
689
787
|
Returns:
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
788
|
+
List of provider-compatible tool schema dicts.
|
|
789
|
+
"""
|
|
790
|
+
tools = [_SEARCH_TOOL]
|
|
791
|
+
if chat_type == 'private' and username in self.telegram['owners']:
|
|
792
|
+
tools = tools + self.webhook_schemas
|
|
793
|
+
return tools
|
|
794
|
+
|
|
795
|
+
async def _handle_tool_call(
|
|
796
|
+
self, tool_call: ToolCall, user_id: int, chat_id: int,
|
|
797
|
+
username: str | None = None, chat_type: str = 'private',
|
|
798
|
+
) -> str:
|
|
799
|
+
"""
|
|
800
|
+
Execute a tool call requested by the LLM and return the result string.
|
|
801
|
+
|
|
802
|
+
Routes 'search_messages' to the built-in search handler. Webhook tools are
|
|
803
|
+
routed to execute_webhook() after a defense-in-depth permission check (primary
|
|
804
|
+
gate is _build_tool_list() which only injects webhook schemas for owner+private).
|
|
805
|
+
|
|
806
|
+
Args:
|
|
807
|
+
tool_call: The ToolCall returned by the provider.
|
|
808
|
+
user_id: Telegram user ID of the requesting user (for search access control).
|
|
809
|
+
chat_id: Telegram chat ID of the current conversation.
|
|
810
|
+
username: Telegram username of the requesting user (for webhook permission check).
|
|
811
|
+
chat_type: Telegram chat type string (for webhook permission check).
|
|
812
|
+
|
|
813
|
+
Returns either:
|
|
814
|
+
search_messages: JSON-encoded list of result dicts, JSON error object, or ambiguity sentinel.
|
|
815
|
+
webhook tools: framed '[Tool result from <name>]:' string, or error string.
|
|
694
816
|
"""
|
|
695
817
|
if tool_call.name == 'search_messages':
|
|
696
818
|
args = tool_call.arguments
|
|
@@ -715,9 +837,25 @@ class TelegramBot:
|
|
|
715
837
|
except ValueError:
|
|
716
838
|
pass
|
|
717
839
|
return json.dumps(results)
|
|
840
|
+
|
|
841
|
+
tool_def = self.webhook_defs.get(tool_call.name)
|
|
842
|
+
if tool_def:
|
|
843
|
+
# Defense-in-depth guard: primary gate is _build_tool_list() not injecting schemas
|
|
844
|
+
# for non-owner or non-private contexts; this catches any edge case where a tool
|
|
845
|
+
# call arrives despite the schema not being injected.
|
|
846
|
+
if chat_type != 'private' or username not in self.telegram['owners']:
|
|
847
|
+
return "[Permission denied: webhook tools are only available in a private chat with the bot owner]"
|
|
848
|
+
return await execute_webhook(tool_def, tool_call.arguments)
|
|
849
|
+
|
|
718
850
|
return json.dumps({"error": f"Unknown tool: {tool_call.name}"})
|
|
719
851
|
|
|
720
|
-
async def _resolve_tool_call(self,
|
|
852
|
+
async def _resolve_tool_call(self,
|
|
853
|
+
tool_call: ToolCall,
|
|
854
|
+
conv: Conversation,
|
|
855
|
+
user_id: int,
|
|
856
|
+
username: str | None = None,
|
|
857
|
+
chat_type: str = 'private',
|
|
858
|
+
) -> str:
|
|
721
859
|
"""
|
|
722
860
|
Execute a tool call and perform the second LLM round-trip with results injected.
|
|
723
861
|
|
|
@@ -730,19 +868,21 @@ class TelegramBot:
|
|
|
730
868
|
tool_call: The ToolCall returned by the first LLM call.
|
|
731
869
|
conv: The active Conversation for this chat.
|
|
732
870
|
user_id: Telegram user ID of the requesting user (for search access control).
|
|
871
|
+
username: Telegram username passed through to _handle_tool_call for webhook permission.
|
|
872
|
+
chat_type: Telegram chat type passed through to _handle_tool_call for webhook permission.
|
|
733
873
|
|
|
734
874
|
Returns:
|
|
735
875
|
The final LLM response string, or a fallback error message on failure.
|
|
736
876
|
"""
|
|
737
|
-
tool_result = await self._handle_tool_call(tool_call, user_id, conv.chat_id)
|
|
877
|
+
tool_result = await self._handle_tool_call(tool_call, user_id, conv.chat_id, username=username, chat_type=chat_type)
|
|
738
878
|
ephemeral = list(conv.messages) + [
|
|
739
879
|
{"role": "assistant", "content": "Let me handle that."},
|
|
740
|
-
{"role": "user", "content": f"Tool result:\n{tool_result}\n\nPlease respond to my request based on this result."},
|
|
880
|
+
{"role": "user", "content": f"Tool result:\n{tool_result}\n\nPlease respond to my original request based on this result. Be concise - do not repeat raw data from the tool result verbatim."},
|
|
741
881
|
]
|
|
742
882
|
provider = get_provider(self.llm['chat_model'])
|
|
743
883
|
try:
|
|
744
884
|
result = await provider.complete(self.llm['chat_model'], ephemeral)
|
|
745
|
-
return result if isinstance(result, str) else
|
|
885
|
+
return result if isinstance(result, str) else _MSG_TOOL_RESULT_ERROR
|
|
746
886
|
except Exception as e:
|
|
747
887
|
log_error(e, 'ToolCall')
|
|
748
888
|
return "Sorry, I had trouble handling that request."
|
|
@@ -803,6 +943,8 @@ class TelegramBot:
|
|
|
803
943
|
persona_prompt = INIT_BOT_CONFIG['persona_prompt'],
|
|
804
944
|
key_status: ApiKeyStatus | None = None,
|
|
805
945
|
log_name: str = 'tellmgrambot',
|
|
946
|
+
webhook_schemas: list | None = None,
|
|
947
|
+
webhook_defs: dict | None = None,
|
|
806
948
|
):
|
|
807
949
|
"""
|
|
808
950
|
Initialize the Telegram bot with LLM configuration and API keys.
|
|
@@ -819,6 +961,10 @@ class TelegramBot:
|
|
|
819
961
|
persona_temp: LLM temperature (0.0-2.0). If None, defaults to 1.0.
|
|
820
962
|
persona_prompt: System prompt defining the bot's behavior and personality.
|
|
821
963
|
key_status: ApiKeyStatus object indicating available features. If None, calls init_structure().
|
|
964
|
+
webhook_schemas: Provider-compatible tool schema dicts for webhook tools (from build_tool_registry).
|
|
965
|
+
If None, no webhook tools are registered.
|
|
966
|
+
webhook_defs: Resolved webhook tool definitions keyed by tool name (from build_tool_registry).
|
|
967
|
+
If None, no webhook tools are registered.
|
|
822
968
|
|
|
823
969
|
Side Effects:
|
|
824
970
|
- Normalises bot_owner to list[str] and stores in self.telegram['owners'].
|
|
@@ -836,6 +982,8 @@ class TelegramBot:
|
|
|
836
982
|
# Initialize some variables
|
|
837
983
|
self.token_warning = {} # Determines whether user has reached token limit by AI model
|
|
838
984
|
self.conversations = {} # Provides Conversation class per user based on bot response
|
|
985
|
+
self.webhook_schemas = webhook_schemas or [] # Provider-compatible schemas for webhook tools
|
|
986
|
+
self.webhook_defs = webhook_defs or {} # Resolved tool definitions keyed by name
|
|
839
987
|
owners = bot_owner if isinstance(bot_owner, list) else [bot_owner]
|
|
840
988
|
self.telegram = {
|
|
841
989
|
'bot_id' : 0, # overwritten by _tele_info(); 0 is a safe sentinel
|
|
@@ -862,7 +1010,10 @@ class TelegramBot:
|
|
|
862
1010
|
self.telegram['app'].add_handler(CommandHandler('help', self.tele_commands))
|
|
863
1011
|
self.telegram['app'].add_handler(CommandHandler('start', self.tele_start_command))
|
|
864
1012
|
self.telegram['app'].add_handler(CommandHandler('stop', self.tele_stop_command))
|
|
1013
|
+
self.telegram['app'].add_handler(CommandHandler('tools', self.tele_tools_command))
|
|
865
1014
|
self.telegram['app'].add_handler(CommandHandler('wipe', self.tele_wipe_command))
|
|
1015
|
+
self.telegram['app'].add_handler(CallbackQueryHandler(self.tele_wipe_callback, pattern=r'^wipe:'))
|
|
1016
|
+
self.telegram['app'].add_handler(CallbackQueryHandler(self.tele_forget_callback, pattern=r'^forget:'))
|
|
866
1017
|
self.telegram['app'].add_handler(CommandHandler('nick', self.tele_nick_command))
|
|
867
1018
|
self.telegram['app'].add_handler(CommandHandler('forget', self.tele_forget_command))
|
|
868
1019
|
self.telegram['app'].add_handler(CommandHandler('private', self.tele_private_command))
|
|
@@ -926,17 +1077,25 @@ class TelegramBot:
|
|
|
926
1077
|
# Bootstrap directories, logging, config, prompt (with appendix), and API keys in one call.
|
|
927
1078
|
key_status, config, prompt = init_structure(config_file, prompt_file, log_name)
|
|
928
1079
|
|
|
1080
|
+
# Build the webhook tool registry from the optional 'tools:' block in config.yaml.
|
|
1081
|
+
webhook_schemas, webhook_defs = build_tool_registry(
|
|
1082
|
+
config.get('tools', []),
|
|
1083
|
+
allow_local=config.get('allow_local_webhooks', False),
|
|
1084
|
+
)
|
|
1085
|
+
|
|
929
1086
|
# Apply parameters to bot:
|
|
930
1087
|
return TelegramBot(
|
|
931
|
-
bot_owner
|
|
932
|
-
bot_nickname
|
|
933
|
-
bot_initials
|
|
934
|
-
chat_model
|
|
935
|
-
url_model
|
|
936
|
-
token_limit
|
|
937
|
-
search_limit
|
|
938
|
-
persona_temp
|
|
939
|
-
persona_prompt
|
|
940
|
-
key_status
|
|
941
|
-
log_name
|
|
1088
|
+
bot_owner = config['bot_owner'],
|
|
1089
|
+
bot_nickname = config['bot_nickname'],
|
|
1090
|
+
bot_initials = config['bot_initials'],
|
|
1091
|
+
chat_model = config['chat_model'],
|
|
1092
|
+
url_model = config['url_model'],
|
|
1093
|
+
token_limit = config['token_limit'],
|
|
1094
|
+
search_limit = config['search_limit'],
|
|
1095
|
+
persona_temp = config['persona_temp'],
|
|
1096
|
+
persona_prompt = prompt,
|
|
1097
|
+
key_status = key_status,
|
|
1098
|
+
log_name = log_name,
|
|
1099
|
+
webhook_schemas = webhook_schemas,
|
|
1100
|
+
webhook_defs = webhook_defs,
|
|
942
1101
|
)
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Webhook tool registry and executor for TeLLMgramBot.
|
|
3
|
+
|
|
4
|
+
Provides build_tool_registry() for startup parsing of config.yaml 'tools:' entries
|
|
5
|
+
and execute_webhook() for dispatching HTTP tool calls at runtime.
|
|
6
|
+
"""
|
|
7
|
+
import ipaddress
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import socket
|
|
12
|
+
from urllib.parse import quote, urlparse
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_RESPONSE_LOG_LIMIT = 200
|
|
19
|
+
_RESPONSE_BODY_LIMIT = 4000
|
|
20
|
+
_ENV_VAR_PATTERN = re.compile(r'\$([A-Z_][A-Z0-9_]*)')
|
|
21
|
+
_PLACEHOLDER_PATTERN = re.compile(r'\{(\w+)\}')
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _expand_env_vars(value: str) -> tuple:
|
|
25
|
+
"""
|
|
26
|
+
Expand $UPPER_CASE_VAR references in a header value string.
|
|
27
|
+
|
|
28
|
+
Returns a two-tuple (expanded_value, first_missing_var_or_None). If any
|
|
29
|
+
referenced variable is not set, the first missing name is returned so the
|
|
30
|
+
caller can disable the tool rather than continue with an incomplete header.
|
|
31
|
+
"""
|
|
32
|
+
missing = None
|
|
33
|
+
|
|
34
|
+
def _sub(m):
|
|
35
|
+
nonlocal missing
|
|
36
|
+
var = m.group(1)
|
|
37
|
+
val = os.environ.get(var)
|
|
38
|
+
if val is None and missing is None:
|
|
39
|
+
missing = var
|
|
40
|
+
return val or ''
|
|
41
|
+
|
|
42
|
+
return _ENV_VAR_PATTERN.sub(_sub, value), missing
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _is_blocked_address(hostname: str) -> bool:
|
|
46
|
+
"""
|
|
47
|
+
Return True if the hostname is, or resolves to, a loopback or link-local address.
|
|
48
|
+
|
|
49
|
+
Raw IP literals are checked without DNS. Hostnames are resolved via
|
|
50
|
+
socket.getaddrinfo; resolution failures are treated as not-blocked to avoid
|
|
51
|
+
incorrectly disabling tools when DNS is temporarily unavailable.
|
|
52
|
+
|
|
53
|
+
Only loopback (127.x.x.x, ::1) and link-local (169.254.x.x, fe80::/10) are
|
|
54
|
+
blocked. Private LAN addresses (192.168.x.x, 10.x.x.x, etc.) are permitted.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
addr = ipaddress.ip_address(hostname)
|
|
58
|
+
return addr.is_loopback or addr.is_link_local
|
|
59
|
+
except ValueError:
|
|
60
|
+
pass
|
|
61
|
+
try:
|
|
62
|
+
for _, _, _, _, sockaddr in socket.getaddrinfo(hostname, None):
|
|
63
|
+
try:
|
|
64
|
+
addr = ipaddress.ip_address(sockaddr[0])
|
|
65
|
+
if addr.is_loopback or addr.is_link_local:
|
|
66
|
+
return True
|
|
67
|
+
except ValueError:
|
|
68
|
+
continue
|
|
69
|
+
except OSError:
|
|
70
|
+
pass
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_tool_registry(
|
|
75
|
+
tools_config: list,
|
|
76
|
+
allow_local: bool = False,
|
|
77
|
+
) -> tuple:
|
|
78
|
+
"""
|
|
79
|
+
Parse raw tool config entries and build the webhook tool registry.
|
|
80
|
+
|
|
81
|
+
Validates all required fields ('name', 'description', 'webhook') and skips
|
|
82
|
+
entries missing any required field. Expands $ENV_VAR references in header
|
|
83
|
+
values at startup; tools whose header values reference missing environment
|
|
84
|
+
variables are excluded with a logged warning (graceful-degradation pattern).
|
|
85
|
+
Non-dict entries, non-dict headers values, and non-dict parameter definitions
|
|
86
|
+
are skipped with warnings. Duplicate tool names are deduplicated (first wins).
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
tools_config: List of raw tool definition dicts from config.yaml 'tools:' key.
|
|
90
|
+
Pass an empty list or None to produce an empty registry.
|
|
91
|
+
allow_local: If True, the executor permits webhook URLs targeting loopback or
|
|
92
|
+
link-local addresses. Mirrors the 'allow_local_webhooks' config key.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Tuple of (schemas, defs) where:
|
|
96
|
+
- schemas: list of provider-compatible tool schema dicts (same shape as _SEARCH_TOOL).
|
|
97
|
+
- defs: dict mapping tool name -> resolved definition dict with expanded headers.
|
|
98
|
+
"""
|
|
99
|
+
schemas = []
|
|
100
|
+
defs = {}
|
|
101
|
+
|
|
102
|
+
for entry in (tools_config or []):
|
|
103
|
+
if not isinstance(entry, dict):
|
|
104
|
+
logger.warning("Webhook tool entry is not a dict; skipping.")
|
|
105
|
+
continue
|
|
106
|
+
name = entry.get('name')
|
|
107
|
+
if not name:
|
|
108
|
+
logger.warning("Webhook tool entry missing 'name'; skipping.")
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
# Expand $ENV_VAR references in header values; disable tool on first missing var
|
|
112
|
+
raw_headers = entry.get('headers') or {}
|
|
113
|
+
if not isinstance(raw_headers, dict):
|
|
114
|
+
logger.warning(f"Webhook tool '{name}': 'headers' must be a dict; treating as empty.")
|
|
115
|
+
raw_headers = {}
|
|
116
|
+
expanded_headers = {}
|
|
117
|
+
disabled = False
|
|
118
|
+
for header_name, header_value in raw_headers.items():
|
|
119
|
+
expanded, missing = _expand_env_vars(str(header_value))
|
|
120
|
+
if missing:
|
|
121
|
+
logger.warning(
|
|
122
|
+
f"Webhook tool '{name}': header '{header_name}' references "
|
|
123
|
+
f"missing env var ${missing}; tool disabled."
|
|
124
|
+
)
|
|
125
|
+
disabled = True
|
|
126
|
+
break
|
|
127
|
+
expanded_headers[header_name] = expanded
|
|
128
|
+
if disabled:
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
# Validate required fields
|
|
132
|
+
webhook_url = entry.get('webhook', '')
|
|
133
|
+
if not webhook_url:
|
|
134
|
+
logger.warning(f"Webhook tool '{name}': missing 'webhook' URL; skipping.")
|
|
135
|
+
continue
|
|
136
|
+
description = entry.get('description', '')
|
|
137
|
+
if not description:
|
|
138
|
+
logger.warning(f"Webhook tool '{name}': missing 'description'; skipping.")
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
# Duplicate name check - warn and skip to keep schemas/defs in sync
|
|
142
|
+
if name in defs:
|
|
143
|
+
logger.warning(f"Webhook tool '{name}': duplicate name; skipping.")
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
# Build the provider-compatible schema from the parameters block
|
|
147
|
+
raw_params = entry.get('parameters') or {}
|
|
148
|
+
properties = {}
|
|
149
|
+
required = []
|
|
150
|
+
if isinstance(raw_params, dict):
|
|
151
|
+
for param_name, param_def in raw_params.items():
|
|
152
|
+
if not isinstance(param_def, dict):
|
|
153
|
+
logger.warning(
|
|
154
|
+
f"Webhook tool '{name}': parameter '{param_name}' definition "
|
|
155
|
+
f"is not a dict; skipping parameter."
|
|
156
|
+
)
|
|
157
|
+
continue
|
|
158
|
+
prop = {'type': param_def.get('type', 'string')}
|
|
159
|
+
if 'description' in param_def:
|
|
160
|
+
prop['description'] = param_def['description']
|
|
161
|
+
properties[param_name] = prop
|
|
162
|
+
if param_def.get('required', False):
|
|
163
|
+
required.append(param_name)
|
|
164
|
+
|
|
165
|
+
schemas.append({
|
|
166
|
+
'name': name,
|
|
167
|
+
'description': description,
|
|
168
|
+
'parameters': {
|
|
169
|
+
'type': 'object',
|
|
170
|
+
'properties': properties,
|
|
171
|
+
'required': required,
|
|
172
|
+
},
|
|
173
|
+
})
|
|
174
|
+
defs[name] = {
|
|
175
|
+
'name': name,
|
|
176
|
+
'webhook': webhook_url,
|
|
177
|
+
'method': str(entry.get('method', 'POST')).upper(),
|
|
178
|
+
'headers': expanded_headers,
|
|
179
|
+
'normalize_args': entry.get('normalize_args'),
|
|
180
|
+
'allow_local': allow_local,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return schemas, defs
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
async def execute_webhook(tool_def: dict, arguments: dict) -> str:
|
|
187
|
+
"""
|
|
188
|
+
Execute a webhook tool call and return a framed result string for LLM injection.
|
|
189
|
+
|
|
190
|
+
Applies argument normalization, URL template substitution ({param} -> value),
|
|
191
|
+
and SSRF protection before firing the HTTP request. The response body is wrapped
|
|
192
|
+
with a '[Tool result from <name>]:' framing line so the LLM can distinguish
|
|
193
|
+
tool output from user instructions (prompt injection guard).
|
|
194
|
+
|
|
195
|
+
Request headers are never logged at any level. Response bodies are logged at
|
|
196
|
+
DEBUG only, truncated to 200 characters. Only tool name, HTTP method, base URL
|
|
197
|
+
(no query string), and status code are logged at INFO. The response body sent
|
|
198
|
+
to the LLM is truncated to 4000 characters to prevent overwhelming the context.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
tool_def: Resolved tool definition dict from build_tool_registry defs.
|
|
202
|
+
arguments: Arguments dict from the LLM ToolCall.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
'[Tool result from <name>]:\n<body>' on success (2xx), or a descriptive
|
|
206
|
+
'[Tool error: ...]' string on failure (non-2xx, timeout, network error,
|
|
207
|
+
missing placeholder, SSRF block). Never raises.
|
|
208
|
+
"""
|
|
209
|
+
name = tool_def['name']
|
|
210
|
+
url_template = tool_def['webhook']
|
|
211
|
+
method = tool_def['method']
|
|
212
|
+
headers = tool_def['headers']
|
|
213
|
+
normalize = tool_def.get('normalize_args')
|
|
214
|
+
allow_local = tool_def.get('allow_local', False)
|
|
215
|
+
|
|
216
|
+
args = dict(arguments)
|
|
217
|
+
|
|
218
|
+
# Normalize string arguments if requested (e.g. for case-sensitive resource IDs)
|
|
219
|
+
if normalize == 'lowercase':
|
|
220
|
+
args = {k: v.lower() if isinstance(v, str) else v for k, v in args.items()}
|
|
221
|
+
|
|
222
|
+
# Substitute {param} placeholders; remove them so they are not also sent in body/query
|
|
223
|
+
url = url_template
|
|
224
|
+
substituted = set()
|
|
225
|
+
for placeholder in _PLACEHOLDER_PATTERN.findall(url_template):
|
|
226
|
+
if placeholder not in args:
|
|
227
|
+
return f"[Tool error: required URL parameter '{placeholder}' was not provided]"
|
|
228
|
+
url = url.replace(f'{{{placeholder}}}', quote(str(args[placeholder]), safe=''))
|
|
229
|
+
substituted.add(placeholder)
|
|
230
|
+
for p in substituted:
|
|
231
|
+
args.pop(p, None)
|
|
232
|
+
|
|
233
|
+
# SSRF protection: block loopback and link-local unless explicitly allowed
|
|
234
|
+
parsed = urlparse(url)
|
|
235
|
+
if parsed.scheme not in ('http', 'https') or not parsed.hostname:
|
|
236
|
+
return f"[Tool error: '{name}' has an invalid or unsupported webhook URL]"
|
|
237
|
+
hostname = parsed.hostname
|
|
238
|
+
if not allow_local and _is_blocked_address(hostname):
|
|
239
|
+
logger.warning(f"Webhook '{name}': SSRF block - '{hostname}' is loopback or link-local")
|
|
240
|
+
return (
|
|
241
|
+
f"[Tool error: '{name}' targets a loopback or link-local address. "
|
|
242
|
+
f"Set allow_local_webhooks: true in config.yaml to permit local addresses.]"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
host_part = f"{parsed.hostname}:{parsed.port}" if parsed.port else parsed.hostname
|
|
246
|
+
base_url = f"{parsed.scheme}://{host_part}{parsed.path}"
|
|
247
|
+
logger.info(f"Webhook '{name}': {method} {base_url}")
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
251
|
+
if method == 'GET':
|
|
252
|
+
resp = await client.get(url, params=args if args else None, headers=headers)
|
|
253
|
+
else:
|
|
254
|
+
resp = await client.request(method, url, json=args if args else None, headers=headers)
|
|
255
|
+
|
|
256
|
+
if not (200 <= resp.status_code < 300):
|
|
257
|
+
logger.warning(f"Webhook '{name}': status {resp.status_code}")
|
|
258
|
+
return f"[Tool error: '{name}' returned HTTP {resp.status_code}]"
|
|
259
|
+
|
|
260
|
+
logger.info(f"Webhook '{name}': status {resp.status_code}")
|
|
261
|
+
logger.debug(f"Webhook '{name}' response: {resp.text[:_RESPONSE_LOG_LIMIT]}")
|
|
262
|
+
|
|
263
|
+
body = resp.text
|
|
264
|
+
if len(body) > _RESPONSE_BODY_LIMIT:
|
|
265
|
+
body = body[:_RESPONSE_BODY_LIMIT] + f"\n[Response truncated at {_RESPONSE_BODY_LIMIT} characters]"
|
|
266
|
+
return f"[Tool result from {name}]:\n{body}"
|
|
267
|
+
|
|
268
|
+
except httpx.TimeoutException:
|
|
269
|
+
logger.warning(f"Webhook '{name}': request timed out")
|
|
270
|
+
return f"[Tool error: '{name}' timed out]"
|
|
271
|
+
except httpx.RequestError as e:
|
|
272
|
+
logger.warning(f"Webhook '{name}': {type(e).__name__}")
|
|
273
|
+
return f"[Tool error: '{name}' request failed ({type(e).__name__})]"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: TeLLMgramBot
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.11.0
|
|
4
4
|
Summary: LLM-powered Telegram bot (OpenAI + Anthropic)
|
|
5
5
|
Home-page: https://github.com/Digital-Heresy/TeLLMgramBot
|
|
6
6
|
Author: Digital Heresy
|
|
@@ -122,9 +122,10 @@ os.environ['TELLMGRAMBOT_VIRUSTOTAL_API_KEY'] = my_vault.get('virustotal_key')
|
|
|
122
122
|
|
|
123
123
|
### Available Commands
|
|
124
124
|
- `/nick <name>` - Set your nickname (for bot use in group chats).
|
|
125
|
-
- `/forget` - Clear your conversation history. In private chats, clears everything and resets all active sessions. In group chats, removes only your messages.
|
|
125
|
+
- `/forget` - Clear your conversation history. Shows a confirmation prompt before deletion. In private chats, clears everything and resets all active sessions. In group chats, removes only your messages.
|
|
126
126
|
- `/private` - Toggle private mode (private chats only). When ON, your messages are excluded from group context loading.
|
|
127
|
-
- `/
|
|
127
|
+
- `/tools` - List all registered tools available to this bot instance (admin-only, private chat only). Shows the built-in search_messages tool and any webhook tools defined in config.yaml.
|
|
128
|
+
- `/help` - Display available commands and usage information. In private chats, if you are a bot owner, also shows administrator-only commands (`/start`, `/stop`, `/wipe`, `/tools`).
|
|
128
129
|
|
|
129
130
|
### Group Chat Triggers
|
|
130
131
|
The bot responds in groups when you:
|
|
@@ -151,6 +152,7 @@ When the bot is triggered in a group and about to respond (not deferring to anot
|
|
|
151
152
|
- `db_name`: Optional custom database filename without extension (e.g. `MyBot` creates `MyBot.db`); omit for default `conversations.db`. Use distinct names when running multiple bot instances in the same directory.
|
|
152
153
|
- `token_limit`: Max tokens (optional; defaults to model's maximum)
|
|
153
154
|
- `search_limit`: Max search results (optional; defaults to 30)
|
|
155
|
+
- `tools`: Optional list of webhook tool definitions (admin-only, private chat only). See [docs/tools.md](docs/tools.md) for schema and examples.
|
|
154
156
|
4. **Disable group privacy mode in BotFather:**
|
|
155
157
|
```
|
|
156
158
|
/setprivacy -> select your bot -> Disable
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|