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.
Files changed (25) hide show
  1. {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/PKG-INFO +5 -3
  2. {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/README.md +4 -2
  3. {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot/TeLLMgramBot.py +225 -66
  4. tellmgrambot-3.11.0/TeLLMgramBot/tools.py +273 -0
  5. {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot.egg-info/PKG-INFO +5 -3
  6. {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot.egg-info/SOURCES.txt +1 -0
  7. {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/setup.py +1 -1
  8. {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/LICENSE +0 -0
  9. {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot/__init__.py +0 -0
  10. {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot/conversation.py +0 -0
  11. {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot/database.py +0 -0
  12. {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot/initialize.py +0 -0
  13. {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot/message_handlers.py +0 -0
  14. {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot/models.py +0 -0
  15. {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot/providers/__init__.py +0 -0
  16. {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot/providers/anthropic_provider.py +0 -0
  17. {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot/providers/base.py +0 -0
  18. {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot/providers/factory.py +0 -0
  19. {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot/providers/openai_provider.py +0 -0
  20. {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot/utils.py +0 -0
  21. {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot/web_utils.py +0 -0
  22. {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot.egg-info/dependency_links.txt +0 -0
  23. {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot.egg-info/requires.txt +0 -0
  24. {tellmgrambot-3.10.4 → tellmgrambot-3.11.0}/TeLLMgramBot.egg-info/top_level.txt +0 -0
  25. {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.10.4
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
- - `/help` - Display available commands and usage information. In private chats, if you are a bot owner, also shows administrator-only commands (`/start`, `/stop`, `/wipe`).
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
- - `/help` - Display available commands and usage information. In private chats, if you are a bot owner, also shows administrator-only commands (`/start`, `/stop`, `/wipe`).
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 (irreversible)."
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("Sorry, I can't do that for you.")
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("Sorry, I can't do that for you.")
172
+ await update.message.reply_text(_MSG_ADMIN_ONLY)
161
173
 
162
- async def tele_wipe_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
174
+ async def tele_tools_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
163
175
  """
164
- Permanently delete all conversation data from the database (admin-only).
176
+ List all registered tools available to this bot instance (admin-only, private chat only).
165
177
 
166
- Only usernames in self.telegram['owners'] can execute this command.
167
- If the sender is an owner, deletes all messages, users, and chats via wipe_all_data(),
168
- clears in-memory conversations and token warnings, and confirms via reply.
169
- Otherwise, denies access with "Sorry, I can't do that for you."
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
- This action is irreversible - all conversation history is permanently lost.
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("Sorry, I can't do that for you.")
214
+ await update.message.reply_text(_MSG_ADMIN_ONLY)
180
215
  return
181
- await wipe_all_data()
182
- self.conversations.clear()
183
- self.token_warning.clear()
184
- await update.message.reply_text("All conversation data wiped.")
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
- Remove conversation history from the database (behavior depends on chat type).
274
+ Prompt for inline keyboard confirmation before removing the requester's conversation history.
209
275
 
210
- In private chats: deletes all bot replies whose reply_to_id points to this user's messages,
211
- then deletes the user's rows across all chats (full wipe including group messages),
212
- then deletes any remaining messages in the private chat (pre-migration rows with NULL reply_to_id).
213
- In group chats: deletes bot replies linked to this user's messages, then deletes the user's
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
- In both cases, evicts only the in-memory Conversation objects that contain this
217
- user's data - scoped to the current chat, the user's private chat, and any other
218
- chats where their context was previously merged. Other users' active sessions are
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
- user_id = user.id
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 msg.reply_text("My memories of our conversations are wiped!")
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 = "Sorry, I couldn't process your message! Please contact my creator."
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 = [_SEARCH_TOOL]
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 = "Sorry, I couldn't process your message! Please contact my creator."
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
- async def _handle_tool_call(self, tool_call: ToolCall, user_id: int, chat_id: int) -> str:
773
+ def _build_tool_list(self, chat_type: str, username: str | None) -> list:
675
774
  """
676
- Execute a tool call requested by the LLM and return the result as a JSON string.
775
+ Build the tool list to pass to the LLM for this request.
677
776
 
678
- Supports the 'search_messages' tool. Search results are scoped to chats accessible
679
- to the requesting user: their private chat, the current chat (always included so the
680
- current group is always searchable), and shared groups where the user and bot have
681
- both sent messages.
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
- tool_call: The ToolCall returned by the provider.
685
- user_id: Telegram user ID of the requesting user (for access control).
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
- JSON-encoded list of result dicts with keys: speaker, chat, content, timestamp.
691
- JSON-encoded ambiguity sentinel dict (with '_ambiguous': True) when speaker_query
692
- or chat_query matched multiple entities; the LLM uses this to ask the user to clarify.
693
- Returns a JSON error object on failure.
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, tool_call: ToolCall, conv: Conversation, user_id: int) -> str:
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 "Sorry, I couldn't process the tool result."
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 = config['bot_owner'],
932
- bot_nickname = config['bot_nickname'],
933
- bot_initials = config['bot_initials'],
934
- chat_model = config['chat_model'],
935
- url_model = config['url_model'],
936
- token_limit = config['token_limit'],
937
- search_limit = config['search_limit'],
938
- persona_temp = config['persona_temp'],
939
- persona_prompt = prompt,
940
- key_status = key_status,
941
- log_name = 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.10.4
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
- - `/help` - Display available commands and usage information. In private chats, if you are a bot owner, also shows administrator-only commands (`/start`, `/stop`, `/wipe`).
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
@@ -8,6 +8,7 @@ TeLLMgramBot/database.py
8
8
  TeLLMgramBot/initialize.py
9
9
  TeLLMgramBot/message_handlers.py
10
10
  TeLLMgramBot/models.py
11
+ TeLLMgramBot/tools.py
11
12
  TeLLMgramBot/utils.py
12
13
  TeLLMgramBot/web_utils.py
13
14
  TeLLMgramBot.egg-info/PKG-INFO
@@ -5,7 +5,7 @@ with open("README.md", "r") as fh:
5
5
 
6
6
  setup(
7
7
  name='TeLLMgramBot',
8
- version='3.10.4',
8
+ version='3.11.0',
9
9
  packages=find_packages(),
10
10
  license='MIT',
11
11
  author='Digital Heresy',
File without changes
File without changes