TeLLMgramBot 3.10.1__tar.gz → 3.10.2__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.1 → tellmgrambot-3.10.2}/PKG-INFO +7 -5
- {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/README.md +6 -4
- {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot/TeLLMgramBot.py +106 -86
- {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot/conversation.py +37 -4
- {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot/database.py +122 -34
- {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot/initialize.py +63 -22
- {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot/message_handlers.py +2 -0
- {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot/models.py +2 -0
- {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot/providers/base.py +5 -0
- {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot/providers/factory.py +1 -0
- {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot/providers/openai_provider.py +1 -0
- {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot/utils.py +12 -0
- {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot/web_utils.py +7 -0
- {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot.egg-info/PKG-INFO +7 -5
- {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/setup.py +1 -1
- {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/LICENSE +0 -0
- {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot/__init__.py +0 -0
- {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot/providers/__init__.py +0 -0
- {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot/providers/anthropic_provider.py +0 -0
- {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot.egg-info/SOURCES.txt +0 -0
- {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot.egg-info/dependency_links.txt +0 -0
- {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot.egg-info/requires.txt +0 -0
- {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot.egg-info/top_level.txt +0 -0
- {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: TeLLMgramBot
|
|
3
|
-
Version: 3.10.
|
|
3
|
+
Version: 3.10.2
|
|
4
4
|
Summary: LLM-powered Telegram bot (OpenAI + Anthropic)
|
|
5
5
|
Home-page: https://github.com/Digital-Heresy/TeLLMgramBot
|
|
6
6
|
Author: Digital Heresy
|
|
@@ -72,7 +72,7 @@ Simply set `chat_model` (and optionally `url_model`) in your `config.yaml` to an
|
|
|
72
72
|
TeLLMgramBot creates the following directories:
|
|
73
73
|
|
|
74
74
|
- **`configs`** - Bot configuration and model parameters
|
|
75
|
-
- `config.yaml` - Bot settings: owner, model names, token limits, search limits, nickname/initials
|
|
75
|
+
- `config.yaml` - Bot settings: owner, model names, token limits, search limits, nickname/initials, database name
|
|
76
76
|
- `models.yaml` - Token limits for each LLM model (pre-populated on first run)
|
|
77
77
|
- **`prompts`** - Bot personas and URL analysis templates
|
|
78
78
|
- `test_personality.prmpt` - Sample bot personality (multi-provider, can be renamed)
|
|
@@ -82,7 +82,7 @@ TeLLMgramBot creates the following directories:
|
|
|
82
82
|
- Logs include anonymized Telegram IDs for privacy. Console shows INFO-level TeLLMgramBot messages only.
|
|
83
83
|
- Bot keeps the 10 most recent logs, automatically pruning older ones.
|
|
84
84
|
- Pass `-v` or `--verbose` on startup for DEBUG-level logging.
|
|
85
|
-
- **`data`** - SQLite database (`conversations.db`) storing all messages, users, and chats
|
|
85
|
+
- **`data`** - SQLite database (default `conversations.db`, customizable via `db_name` config) storing all messages, users, and chats
|
|
86
86
|
- Users manage their data via `/forget` and `/private` commands.
|
|
87
87
|
|
|
88
88
|
### Environment Variables for Paths
|
|
@@ -133,10 +133,11 @@ The bot responds in groups when you:
|
|
|
133
133
|
|
|
134
134
|
If a message explicitly @mentions another account, the bot defers with "Looks like that's for @OtherBot!" instead.
|
|
135
135
|
|
|
136
|
-
|
|
136
|
+
### Private Chat Behavior
|
|
137
|
+
In private chats, the bot responds to all your messages. If you reply to an earlier message in the conversation that is not already in the bot's context window, that message is automatically surfaced as inline context so the bot can understand the full conversation thread.
|
|
137
138
|
|
|
138
139
|
### Read Receipt (Group Chats Only)
|
|
139
|
-
When triggered and not deferring
|
|
140
|
+
When the bot is triggered in a group and about to respond (not deferring to another bot), it immediately sends a 👀 emoji reaction on your message as a read receipt acknowledgement (falls back to "Got it!" text reply on older Telegram clients). This confirmation arrives before the full LLM response, providing quick feedback that the bot received your message.
|
|
140
141
|
|
|
141
142
|
## Bot Setup
|
|
142
143
|
1. Ensure API keys are set up and your Telegram bot is created via BotFather.
|
|
@@ -146,6 +147,7 @@ When triggered and not deferring, the bot immediately sends a 👀 reaction on y
|
|
|
146
147
|
- `chat_model`: LLM model for conversation (e.g. `gpt-4o-mini` or `claude-sonnet-4-6`)
|
|
147
148
|
- `url_model`: LLM model for URL analysis (e.g. `gpt-4o` or `claude-haiku-4-5`)
|
|
148
149
|
- `bot_nickname` / `bot_initials`: Names the bot responds to in groups
|
|
150
|
+
- `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.
|
|
149
151
|
- `token_limit`: Max tokens (optional; defaults to model's maximum)
|
|
150
152
|
- `search_limit`: Max search results (optional; defaults to 30)
|
|
151
153
|
4. **Disable group privacy mode in BotFather:**
|
|
@@ -40,7 +40,7 @@ Simply set `chat_model` (and optionally `url_model`) in your `config.yaml` to an
|
|
|
40
40
|
TeLLMgramBot creates the following directories:
|
|
41
41
|
|
|
42
42
|
- **`configs`** - Bot configuration and model parameters
|
|
43
|
-
- `config.yaml` - Bot settings: owner, model names, token limits, search limits, nickname/initials
|
|
43
|
+
- `config.yaml` - Bot settings: owner, model names, token limits, search limits, nickname/initials, database name
|
|
44
44
|
- `models.yaml` - Token limits for each LLM model (pre-populated on first run)
|
|
45
45
|
- **`prompts`** - Bot personas and URL analysis templates
|
|
46
46
|
- `test_personality.prmpt` - Sample bot personality (multi-provider, can be renamed)
|
|
@@ -50,7 +50,7 @@ TeLLMgramBot creates the following directories:
|
|
|
50
50
|
- Logs include anonymized Telegram IDs for privacy. Console shows INFO-level TeLLMgramBot messages only.
|
|
51
51
|
- Bot keeps the 10 most recent logs, automatically pruning older ones.
|
|
52
52
|
- Pass `-v` or `--verbose` on startup for DEBUG-level logging.
|
|
53
|
-
- **`data`** - SQLite database (`conversations.db`) storing all messages, users, and chats
|
|
53
|
+
- **`data`** - SQLite database (default `conversations.db`, customizable via `db_name` config) storing all messages, users, and chats
|
|
54
54
|
- Users manage their data via `/forget` and `/private` commands.
|
|
55
55
|
|
|
56
56
|
### Environment Variables for Paths
|
|
@@ -101,10 +101,11 @@ The bot responds in groups when you:
|
|
|
101
101
|
|
|
102
102
|
If a message explicitly @mentions another account, the bot defers with "Looks like that's for @OtherBot!" instead.
|
|
103
103
|
|
|
104
|
-
|
|
104
|
+
### Private Chat Behavior
|
|
105
|
+
In private chats, the bot responds to all your messages. If you reply to an earlier message in the conversation that is not already in the bot's context window, that message is automatically surfaced as inline context so the bot can understand the full conversation thread.
|
|
105
106
|
|
|
106
107
|
### Read Receipt (Group Chats Only)
|
|
107
|
-
When triggered and not deferring
|
|
108
|
+
When the bot is triggered in a group and about to respond (not deferring to another bot), it immediately sends a 👀 emoji reaction on your message as a read receipt acknowledgement (falls back to "Got it!" text reply on older Telegram clients). This confirmation arrives before the full LLM response, providing quick feedback that the bot received your message.
|
|
108
109
|
|
|
109
110
|
## Bot Setup
|
|
110
111
|
1. Ensure API keys are set up and your Telegram bot is created via BotFather.
|
|
@@ -114,6 +115,7 @@ When triggered and not deferring, the bot immediately sends a 👀 reaction on y
|
|
|
114
115
|
- `chat_model`: LLM model for conversation (e.g. `gpt-4o-mini` or `claude-sonnet-4-6`)
|
|
115
116
|
- `url_model`: LLM model for URL analysis (e.g. `gpt-4o` or `claude-haiku-4-5`)
|
|
116
117
|
- `bot_nickname` / `bot_initials`: Names the bot responds to in groups
|
|
118
|
+
- `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.
|
|
117
119
|
- `token_limit`: Max tokens (optional; defaults to model's maximum)
|
|
118
120
|
- `search_limit`: Max search results (optional; defaults to 30)
|
|
119
121
|
4. **Disable group privacy mode in BotFather:**
|
|
@@ -23,7 +23,8 @@ from .database import (
|
|
|
23
23
|
delete_private_messages_for_user,
|
|
24
24
|
delete_bot_replies_for_user,
|
|
25
25
|
get_shared_group_chat_ids,
|
|
26
|
-
|
|
26
|
+
message_id_exists,
|
|
27
|
+
update_message_tg_id,
|
|
27
28
|
search_messages,
|
|
28
29
|
upsert_chat,
|
|
29
30
|
upsert_user,
|
|
@@ -43,8 +44,6 @@ from .utils import exact_word_match, log_error
|
|
|
43
44
|
|
|
44
45
|
logger = logging.getLogger(__name__)
|
|
45
46
|
|
|
46
|
-
_FOREIGN_BOT_MESSAGE_CAP = 50
|
|
47
|
-
|
|
48
47
|
_SEARCH_TOOL = {
|
|
49
48
|
"name": "search_messages",
|
|
50
49
|
"description": (
|
|
@@ -68,6 +67,7 @@ _SEARCH_TOOL = {
|
|
|
68
67
|
},
|
|
69
68
|
}
|
|
70
69
|
|
|
70
|
+
|
|
71
71
|
class TelegramBot:
|
|
72
72
|
"""
|
|
73
73
|
The bridge between the Telegram interface and a Large Language Model (LLM).
|
|
@@ -75,6 +75,7 @@ class TelegramBot:
|
|
|
75
75
|
- URLs in [square brackets] can be supplied on how the bot should interpret it.
|
|
76
76
|
- For more information, see README or: https://github.com/Digital-Heresy/TeLLMgramBot
|
|
77
77
|
"""
|
|
78
|
+
|
|
78
79
|
async def _tele_info(self):
|
|
79
80
|
"""Fetch the Telegram bot's information before running application."""
|
|
80
81
|
try:
|
|
@@ -131,7 +132,6 @@ class TelegramBot:
|
|
|
131
132
|
await wipe_all_data()
|
|
132
133
|
self.conversations.clear()
|
|
133
134
|
self.token_warning.clear()
|
|
134
|
-
context.application.bot_data.pop('_foreign_bot_reply_seen', None)
|
|
135
135
|
await update.message.reply_text("All conversation data wiped.")
|
|
136
136
|
|
|
137
137
|
async def tele_nick_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|
@@ -149,7 +149,8 @@ class TelegramBot:
|
|
|
149
149
|
result = pattern.search(msg.text or "")
|
|
150
150
|
if result:
|
|
151
151
|
prompt = f"Please refer to me by my nickname, {result.group(1).strip()}, rather than my user name."
|
|
152
|
-
|
|
152
|
+
response, _ = await self.tele_handle_response(prompt, msg)
|
|
153
|
+
await msg.reply_text(response)
|
|
153
154
|
else:
|
|
154
155
|
await msg.reply_text("Please provide a valid nickname after the command like \"/nick B0b #2\".")
|
|
155
156
|
|
|
@@ -248,7 +249,7 @@ class TelegramBot:
|
|
|
248
249
|
"from group conversation contexts. Use /private off to disable."
|
|
249
250
|
)
|
|
250
251
|
|
|
251
|
-
async def tele_handle_response(self, text: str, msg: Message) -> str:
|
|
252
|
+
async def tele_handle_response(self, text: str, msg: Message, context_prefix: str = '') -> tuple[str, int | None]:
|
|
252
253
|
"""
|
|
253
254
|
Primary function for handling any response including Generative AI, ensuring:
|
|
254
255
|
- The owner started up the bot assistant for user interactions.
|
|
@@ -267,10 +268,14 @@ class TelegramBot:
|
|
|
267
268
|
Args:
|
|
268
269
|
text: The user's message text.
|
|
269
270
|
msg: The Telegram Message object containing chat/user context.
|
|
271
|
+
context_prefix: Optional context prepended to the message before the live
|
|
272
|
+
speaker annotation. Used for reply-to-thread context. Defaults to ''.
|
|
270
273
|
|
|
271
274
|
Returns:
|
|
272
|
-
|
|
273
|
-
|
|
275
|
+
Tuple of (response_text, assistant_db_id). response_text is the bot's response
|
|
276
|
+
string (or generic error if no handler matches or features are disabled).
|
|
277
|
+
assistant_db_id is the database row id of the stored assistant message,
|
|
278
|
+
or None if the bot is offline.
|
|
274
279
|
|
|
275
280
|
Side Effects:
|
|
276
281
|
- Adds user message to conversation history (keyed by chat_id).
|
|
@@ -280,10 +285,11 @@ class TelegramBot:
|
|
|
280
285
|
- Generates a token warning if conversation nears the limit.
|
|
281
286
|
- Prunes conversation if token count exceeds the threshold.
|
|
282
287
|
- Stores bot's response with bot identity and reply_to_id for tracking.
|
|
288
|
+
- Returns assistant_db_id so the caller can update tg_message_id after Telegram send completes.
|
|
283
289
|
"""
|
|
284
290
|
# Starting ensures we get some kind of user account details for logging
|
|
285
291
|
if not self._online:
|
|
286
|
-
return "I'd love to chat, but I am offline at the moment!"
|
|
292
|
+
return "I'd love to chat, but I am offline at the moment!", None
|
|
287
293
|
|
|
288
294
|
# Extract identity and context from the message
|
|
289
295
|
user_id = msg.from_user.id
|
|
@@ -322,12 +328,17 @@ class TelegramBot:
|
|
|
322
328
|
# Already loaded - check for new cross-chat messages since last load.
|
|
323
329
|
await conv.refresh_user_context(user_id, token_budget)
|
|
324
330
|
|
|
331
|
+
# Short-circuit for trivial greetings/queries - not persisted to conversation history
|
|
332
|
+
quick_reply = handle_greetings(text) or handle_common_queries(text)
|
|
333
|
+
if quick_reply:
|
|
334
|
+
return quick_reply, None
|
|
335
|
+
|
|
325
336
|
# Add the user's message to our conversation, respecting private mode
|
|
326
337
|
# Private mode only applies in private chats - group messages are never flagged private
|
|
327
338
|
user_private_mode = await get_private_mode(user_id)
|
|
328
339
|
is_private = (chat_type == 'private') and user_private_mode
|
|
329
340
|
user_msg_id = await conv.add_user_message(
|
|
330
|
-
text, user_id, username, first_name, last_name, is_private
|
|
341
|
+
text, user_id, username, first_name, last_name, is_private, context_prefix, msg.message_id
|
|
331
342
|
)
|
|
332
343
|
|
|
333
344
|
# Check if the user is asking about a [URL]
|
|
@@ -335,11 +346,7 @@ class TelegramBot:
|
|
|
335
346
|
|
|
336
347
|
# Form the assistant's message based on low level easy stuff or send to the LLM
|
|
337
348
|
reply = "Sorry, I couldn't process your message! Please contact my creator."
|
|
338
|
-
if
|
|
339
|
-
reply = handle_greetings(text)
|
|
340
|
-
elif handle_common_queries(text):
|
|
341
|
-
reply = handle_common_queries(text)
|
|
342
|
-
elif url_match and self.key_status.url_analysis_enabled:
|
|
349
|
+
if url_match and self.key_status.url_analysis_enabled:
|
|
343
350
|
await msg.reply_text("Sure, give me a moment to look at that URL...")
|
|
344
351
|
reply = await handle_url_ask(text, self.llm['url_model'])
|
|
345
352
|
elif self._online and self.key_status.chat_enabled:
|
|
@@ -364,7 +371,7 @@ class TelegramBot:
|
|
|
364
371
|
# Add the full reply (including any warning) to the conversation.
|
|
365
372
|
# Propagate is_private so bot replies in private-mode exchanges are also excluded
|
|
366
373
|
# from group context loading - otherwise the assistant's half of the conversation leaks.
|
|
367
|
-
await conv.add_assistant_message(
|
|
374
|
+
assistant_db_id = await conv.add_assistant_message(
|
|
368
375
|
reply,
|
|
369
376
|
self.telegram['bot_id'],
|
|
370
377
|
self.telegram['username'],
|
|
@@ -379,31 +386,59 @@ class TelegramBot:
|
|
|
379
386
|
if token_count > self.llm['prune_threshold']:
|
|
380
387
|
await conv.prune_conversation(self.llm['prune_back_to'])
|
|
381
388
|
|
|
382
|
-
return reply
|
|
389
|
+
return reply, assistant_db_id
|
|
383
390
|
|
|
384
|
-
async def
|
|
391
|
+
async def _build_reply_context(self, msg: Message) -> str:
|
|
385
392
|
"""
|
|
386
|
-
|
|
393
|
+
Build an inline context prefix when the user is replying to another message.
|
|
394
|
+
|
|
395
|
+
Used to surface context when the triggering message is a reply-to another message
|
|
396
|
+
not already in the conversation's memory window. Returns an annotated prefix string
|
|
397
|
+
with speaker name and timestamp if the replied-to message is not in context, or an
|
|
398
|
+
empty string if already present.
|
|
387
399
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
400
|
+
Uses a two-tier check to avoid redundant prepends:
|
|
401
|
+
1. Memory - reply_to_message.message_id in conv._loaded_message_ids (O(1)).
|
|
402
|
+
In-session bot replies are added here immediately after each send.
|
|
403
|
+
2. DB fallback - query for tg_message_id in the messages table.
|
|
404
|
+
Cross-session bot replies are findable here since tg_message_id is now
|
|
405
|
+
stored for assistant messages after each send.
|
|
406
|
+
|
|
407
|
+
Fires in all chat types (group and private) when reply_to_message exists and has text.
|
|
408
|
+
Correctly re-surfaces bot messages that are no longer in context (e.g. post-/forget).
|
|
391
409
|
|
|
392
410
|
Args:
|
|
393
|
-
msg: The incoming Telegram Message
|
|
394
|
-
|
|
411
|
+
msg: The incoming Telegram Message that triggered the bot.
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
Formatted prefix string, e.g.
|
|
415
|
+
"[BotB, 2026-04-05 14:30 UTC]: The ancient sword was forged in...\n"
|
|
416
|
+
or empty string if the replied-to message is already in context.
|
|
395
417
|
"""
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
418
|
+
reply = msg.reply_to_message
|
|
419
|
+
if not reply or not reply.text or not reply.from_user:
|
|
420
|
+
return ''
|
|
421
|
+
|
|
422
|
+
r_tg_id = reply.message_id
|
|
423
|
+
|
|
424
|
+
# Tier 1: memory check
|
|
425
|
+
conv = self.conversations.get(msg.chat.id)
|
|
426
|
+
if conv and r_tg_id in conv._loaded_message_ids:
|
|
427
|
+
return ''
|
|
428
|
+
|
|
429
|
+
# Tier 2: DB fallback
|
|
430
|
+
if await message_id_exists(msg.chat.id, r_tg_id):
|
|
431
|
+
return ''
|
|
432
|
+
|
|
433
|
+
# Not in context - build the annotated prefix and mark the ID as seen so
|
|
434
|
+
# subsequent replies to the same foreign message skip the prepend.
|
|
435
|
+
if conv:
|
|
436
|
+
conv._loaded_message_ids.add(r_tg_id)
|
|
437
|
+
sender = reply.from_user
|
|
438
|
+
name_parts = [p for p in [sender.first_name, sender.last_name] if p]
|
|
439
|
+
name = ' '.join(name_parts) if name_parts else (sender.username or 'unknown')
|
|
440
|
+
dt = format_dt(reply.date)
|
|
441
|
+
return f"[Replying to {name}, {dt}]: {reply.text}\n"
|
|
407
442
|
|
|
408
443
|
def _foreign_bot_mention(self, msg: Message) -> str | None:
|
|
409
444
|
"""
|
|
@@ -463,22 +498,21 @@ class TelegramBot:
|
|
|
463
498
|
"""
|
|
464
499
|
Route incoming Telegram messages to appropriate handlers based on chat type and trigger conditions.
|
|
465
500
|
|
|
466
|
-
In group/supergroup chats,
|
|
467
|
-
are immediately persisted to the database via _store_foreign_bot_message() and the handler returns
|
|
468
|
-
without generating an LLM response. This makes foreign bot messages searchable via the search tool.
|
|
469
|
-
|
|
470
|
-
For non-bot messages in group/supergroup chats, the bot responds when any of the following
|
|
471
|
-
conditions are met:
|
|
501
|
+
In group/supergroup chats, the bot responds when any of the following conditions are met:
|
|
472
502
|
- User mentions the bot by @username
|
|
473
503
|
- User mentions the bot by nickname (set via config)
|
|
474
504
|
- User mentions the bot by initials (set via config)
|
|
475
505
|
- User directly replies to one of the bot's messages (Telegram reply-to feature)
|
|
476
506
|
|
|
477
|
-
In all matching group scenarios, a read receipt acknowledgement (👀 reaction or "Got it!" fallback)
|
|
478
|
-
is sent immediately via _send_read_receipt() before generating the full LLM response.
|
|
479
|
-
|
|
480
507
|
In private chats, the bot responds to all messages (Telegram does not deliver bot-to-bot DMs).
|
|
481
508
|
|
|
509
|
+
When the triggering message is itself a reply to a non-bot message not already in context,
|
|
510
|
+
the replied-to message is prepended as inline context for the LLM via _build_reply_context().
|
|
511
|
+
This applies to both group and private chat contexts.
|
|
512
|
+
|
|
513
|
+
In all group matching scenarios, a read receipt acknowledgement (👀 reaction or "Got it!" fallback)
|
|
514
|
+
is sent immediately via _send_read_receipt() before generating the full LLM response.
|
|
515
|
+
|
|
482
516
|
Args:
|
|
483
517
|
update: The Telegram Update object containing the incoming message.
|
|
484
518
|
context: The Telegram context for sending replies.
|
|
@@ -488,44 +522,15 @@ class TelegramBot:
|
|
|
488
522
|
return
|
|
489
523
|
(msg, chat, user) = validated
|
|
490
524
|
|
|
491
|
-
#
|
|
492
|
-
if (
|
|
493
|
-
user.is_bot and
|
|
494
|
-
user.id != self.telegram['bot_id'] and
|
|
495
|
-
msg.text and
|
|
496
|
-
chat.type in ('group', 'supergroup')
|
|
497
|
-
):
|
|
498
|
-
await self._store_foreign_bot_message(msg, chat)
|
|
525
|
+
# Ignore messages from other bots in group chats - no response or storage needed.
|
|
526
|
+
if user.is_bot and user.id != self.telegram['bot_id'] and chat.type in ('group', 'supergroup'):
|
|
499
527
|
return
|
|
500
528
|
|
|
501
|
-
# Opportunistically capture foreign bot messages via reply_to_message.
|
|
502
|
-
# Telegram does not deliver bot messages as standalone updates; this extracts
|
|
503
|
-
# them when a user replies to another bot so they become searchable.
|
|
504
|
-
# Deduplicate: multiple users replying to the same bot message would each
|
|
505
|
-
# trigger capture; use a short-lived in-memory set keyed by (chat, bot, msg_id).
|
|
506
|
-
reply = msg.reply_to_message
|
|
507
|
-
if (
|
|
508
|
-
reply and
|
|
509
|
-
reply.from_user and
|
|
510
|
-
reply.from_user.is_bot and
|
|
511
|
-
reply.from_user.id != self.telegram['bot_id'] and
|
|
512
|
-
reply.text and
|
|
513
|
-
chat.type in ('group', 'supergroup')
|
|
514
|
-
):
|
|
515
|
-
recently_seen = context.application.bot_data.setdefault('_foreign_bot_reply_seen', {})
|
|
516
|
-
dedupe_key = (chat.id, reply.from_user.id, reply.message_id)
|
|
517
|
-
now = datetime.datetime.now(datetime.timezone.utc)
|
|
518
|
-
cutoff = now - datetime.timedelta(hours=1)
|
|
519
|
-
stale = [k for k, seen_at in recently_seen.items() if seen_at < cutoff]
|
|
520
|
-
for k in stale:
|
|
521
|
-
recently_seen.pop(k, None)
|
|
522
|
-
if dedupe_key not in recently_seen:
|
|
523
|
-
recently_seen[dedupe_key] = now
|
|
524
|
-
await self._store_foreign_bot_message(reply, chat)
|
|
525
|
-
|
|
526
529
|
# If it's a group text, only reply if the bot is named
|
|
527
530
|
# The real magic of how the bot behaves is in tele_handle_response()
|
|
528
531
|
response = "Sorry, I couldn't process your message! Please contact my creator."
|
|
532
|
+
assistant_db_id = None
|
|
533
|
+
reply_context = ''
|
|
529
534
|
if chat.type == 'supergroup' or chat.type == 'group':
|
|
530
535
|
is_reply_to_bot = (
|
|
531
536
|
msg.reply_to_message is not None and
|
|
@@ -537,8 +542,9 @@ class TelegramBot:
|
|
|
537
542
|
# account is also @mentioned (both bots may be intentionally addressed).
|
|
538
543
|
pattern = r'@?\b' + re.escape(self.telegram['username']) + r'\b'
|
|
539
544
|
new_text = re.sub(pattern, '', msg.text).strip()
|
|
545
|
+
reply_context = await self._build_reply_context(msg)
|
|
540
546
|
await self._send_read_receipt(msg, context)
|
|
541
|
-
response = await self.tele_handle_response(new_text, msg)
|
|
547
|
+
response, assistant_db_id = await self.tele_handle_response(new_text, msg, reply_context)
|
|
542
548
|
elif (
|
|
543
549
|
exact_word_match(self.telegram['nickname'], msg.text) or
|
|
544
550
|
exact_word_match(self.telegram['initials'], msg.text) or
|
|
@@ -550,19 +556,36 @@ class TelegramBot:
|
|
|
550
556
|
if foreign:
|
|
551
557
|
await msg.reply_text(f"Looks like that message is for {foreign}!")
|
|
552
558
|
return
|
|
559
|
+
reply_context = await self._build_reply_context(msg)
|
|
553
560
|
await self._send_read_receipt(msg, context)
|
|
554
|
-
response = await self.tele_handle_response(msg.text, msg)
|
|
561
|
+
response, assistant_db_id = await self.tele_handle_response(msg.text, msg, reply_context)
|
|
555
562
|
else:
|
|
556
563
|
return
|
|
557
564
|
elif chat.type == 'private':
|
|
558
|
-
|
|
565
|
+
reply_context = await self._build_reply_context(msg)
|
|
566
|
+
response, assistant_db_id = await self.tele_handle_response(msg.text, msg, reply_context)
|
|
559
567
|
else:
|
|
560
568
|
return
|
|
561
569
|
|
|
562
570
|
# Split into smaller chunks since Telegram messages have a maximum text length (likely 4096)
|
|
563
571
|
chunk_length = MessageLimit.MAX_TEXT_LENGTH - 1
|
|
564
|
-
|
|
565
|
-
|
|
572
|
+
chunks = [response[i:i+chunk_length] for i in range(0, len(response), chunk_length)]
|
|
573
|
+
conv = self.conversations.get(msg.chat.id)
|
|
574
|
+
# If reply context was surfaced on the first interaction (conv was None when
|
|
575
|
+
# _build_reply_context ran), record the ID now that the conversation exists.
|
|
576
|
+
if conv and reply_context and msg.reply_to_message:
|
|
577
|
+
conv._loaded_message_ids.add(msg.reply_to_message.message_id)
|
|
578
|
+
for chunk in chunks:
|
|
579
|
+
sent = await msg.reply_text(chunk)
|
|
580
|
+
if sent:
|
|
581
|
+
if conv:
|
|
582
|
+
conv._loaded_message_ids.add(sent.message_id)
|
|
583
|
+
# Persist this chunk's Telegram message ID on the assistant row.
|
|
584
|
+
# Each call overwrites the previous, so only the last chunk's ID is
|
|
585
|
+
# retained for cross-session tier 2 dedup. Tier 1 covers all chunks
|
|
586
|
+
# in-session via _loaded_message_ids.
|
|
587
|
+
if assistant_db_id:
|
|
588
|
+
await update_message_tg_id(assistant_db_id, sent.message_id)
|
|
566
589
|
await asyncio.sleep(0.12) # small pause to reduce risk of rate limiting
|
|
567
590
|
|
|
568
591
|
async def tele_validate(self, update: Update) -> tuple[Message, Chat, User] | None:
|
|
@@ -715,10 +738,7 @@ class TelegramBot:
|
|
|
715
738
|
def start_polling(self):
|
|
716
739
|
"""The main polling "loop" the user interacts with via Telegram."""
|
|
717
740
|
logger.info(f"TeLLMgramBot {self.telegram['username']} polling...")
|
|
718
|
-
self.telegram['app'].run_polling(
|
|
719
|
-
poll_interval=self.telegram['pollinterval'],
|
|
720
|
-
allowed_updates=Update.ALL_TYPES,
|
|
721
|
-
)
|
|
741
|
+
self.telegram['app'].run_polling(poll_interval=self.telegram['pollinterval'])
|
|
722
742
|
logger.info(f"TeLLMgramBot {self.telegram['username']} polling ended.")
|
|
723
743
|
|
|
724
744
|
# Initialization
|
|
@@ -30,6 +30,7 @@ logger = logging.getLogger(__name__)
|
|
|
30
30
|
# already fills nearly the entire budget.
|
|
31
31
|
MIN_GROUP_CONTEXT_TOKENS = 500
|
|
32
32
|
|
|
33
|
+
|
|
33
34
|
class Conversation:
|
|
34
35
|
"""
|
|
35
36
|
Manages a chat conversation including message history, token counting, and SQLite persistence.
|
|
@@ -38,6 +39,7 @@ class Conversation:
|
|
|
38
39
|
Works with any LLM provider (OpenAI, Anthropic). When the token limit is approached, oldest
|
|
39
40
|
messages are pruned from memory; the database retains the full history.
|
|
40
41
|
"""
|
|
42
|
+
|
|
41
43
|
def __init__(
|
|
42
44
|
self,
|
|
43
45
|
chat_id: int,
|
|
@@ -81,6 +83,13 @@ class Conversation:
|
|
|
81
83
|
# touching the database. Cleared on purge or clear_interaction().
|
|
82
84
|
self._private_message_ids: set[int] = set()
|
|
83
85
|
|
|
86
|
+
# Set of Telegram message IDs (tg_message_id) present in the loaded context.
|
|
87
|
+
# Populated by get_past_interaction() and refresh_user_context() from DB rows,
|
|
88
|
+
# and by add_user_message() for live session messages. Used as the memory-tier
|
|
89
|
+
# check before prepending reply-to-thread context: if reply_to_message.message_id
|
|
90
|
+
# is in this set, the message is already in context and no prepend is needed.
|
|
91
|
+
self._loaded_message_ids: set[int] = set()
|
|
92
|
+
|
|
84
93
|
def set_system_content(self, new_content: str):
|
|
85
94
|
"""
|
|
86
95
|
Replace the system content (bot's personality prompt) in the conversation.
|
|
@@ -143,6 +152,8 @@ class Conversation:
|
|
|
143
152
|
first_name: str | None = None,
|
|
144
153
|
last_name: str | None = None,
|
|
145
154
|
is_private: bool = False,
|
|
155
|
+
context_prefix: str = '',
|
|
156
|
+
tg_message_id: int | None = None,
|
|
146
157
|
) -> int:
|
|
147
158
|
"""
|
|
148
159
|
Add a user message to conversation memory and persist it to the database.
|
|
@@ -164,6 +175,10 @@ class Conversation:
|
|
|
164
175
|
is_private: If True, marks the message as private-mode (excluded from all context
|
|
165
176
|
loads) and flags it in the speaker annotation so the LLM understands
|
|
166
177
|
it should not reference this in group contexts.
|
|
178
|
+
context_prefix: Optional inline context prepended to the in-memory message before
|
|
179
|
+
the live prefix (e.g. reply-to-thread annotation). Not persisted to DB.
|
|
180
|
+
tg_message_id: Telegram message ID; added to _loaded_message_ids so future
|
|
181
|
+
reply-to-thread checks know this message is already in context.
|
|
167
182
|
|
|
168
183
|
Returns:
|
|
169
184
|
The database id of the newly inserted row, for use as reply_to_id.
|
|
@@ -171,11 +186,13 @@ class Conversation:
|
|
|
171
186
|
await upsert_user(user_id, username, first_name, last_name)
|
|
172
187
|
await upsert_chat(self.chat_id, self.chat_type, self.chat_title)
|
|
173
188
|
prefix = self._live_prefix(first_name, last_name, username, is_private)
|
|
174
|
-
msg_dict = {"role": "user", "content": prefix + content}
|
|
189
|
+
msg_dict = {"role": "user", "content": context_prefix + prefix + content}
|
|
175
190
|
self.messages.append(msg_dict)
|
|
176
191
|
if is_private:
|
|
177
192
|
self._private_message_ids.add(id(msg_dict))
|
|
178
|
-
|
|
193
|
+
if tg_message_id is not None:
|
|
194
|
+
self._loaded_message_ids.add(tg_message_id)
|
|
195
|
+
return await insert_message(self.chat_id, user_id, "user", content, is_private, tg_message_id=tg_message_id)
|
|
179
196
|
|
|
180
197
|
async def add_assistant_message(
|
|
181
198
|
self,
|
|
@@ -186,7 +203,7 @@ class Conversation:
|
|
|
186
203
|
bot_last_name: str | None = None,
|
|
187
204
|
is_private: bool = False,
|
|
188
205
|
reply_to_id: int | None = None,
|
|
189
|
-
):
|
|
206
|
+
) -> int:
|
|
190
207
|
"""
|
|
191
208
|
Add an assistant message to conversation memory and persist it to the database.
|
|
192
209
|
|
|
@@ -205,6 +222,10 @@ class Conversation:
|
|
|
205
222
|
is_private: If True, marks the message as private-mode (excluded from all context
|
|
206
223
|
loads). Should match the is_private flag of the user message it responds to.
|
|
207
224
|
reply_to_id: Database id of the user message this reply responds to.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
The database id (int) of the newly inserted assistant message row. Used by
|
|
228
|
+
callers to update tg_message_id after the Telegram send completes.
|
|
208
229
|
"""
|
|
209
230
|
await upsert_user(bot_user_id, bot_username, bot_first_name, bot_last_name)
|
|
210
231
|
await upsert_chat(self.chat_id, self.chat_type, self.chat_title)
|
|
@@ -212,7 +233,7 @@ class Conversation:
|
|
|
212
233
|
self.messages.append(msg_dict)
|
|
213
234
|
if is_private:
|
|
214
235
|
self._private_message_ids.add(id(msg_dict))
|
|
215
|
-
await insert_message(self.chat_id, bot_user_id, "assistant", content, is_private, reply_to_id)
|
|
236
|
+
return await insert_message(self.chat_id, bot_user_id, "assistant", content, is_private, reply_to_id)
|
|
216
237
|
|
|
217
238
|
async def get_message_token_count(self) -> int:
|
|
218
239
|
"""
|
|
@@ -320,6 +341,7 @@ class Conversation:
|
|
|
320
341
|
rows_inserted = 0
|
|
321
342
|
|
|
322
343
|
for row in reversed(rows):
|
|
344
|
+
tg_id = row.pop('_tg_id', None)
|
|
323
345
|
self.messages.insert(1, row)
|
|
324
346
|
token_count = await self.get_message_token_count()
|
|
325
347
|
if token_count > token_limit:
|
|
@@ -328,6 +350,8 @@ class Conversation:
|
|
|
328
350
|
token_limit_reached = True
|
|
329
351
|
break
|
|
330
352
|
rows_inserted += 1
|
|
353
|
+
if tg_id is not None:
|
|
354
|
+
self._loaded_message_ids.add(tg_id)
|
|
331
355
|
|
|
332
356
|
# Load shared group context for private chats when token budget allows.
|
|
333
357
|
# Private context fills the primary budget; group context fills the remainder.
|
|
@@ -343,6 +367,7 @@ class Conversation:
|
|
|
343
367
|
group_rows = await load_shared_group_context(shared_groups, exclude_private=True)
|
|
344
368
|
insert_at = 1 + rows_inserted
|
|
345
369
|
for row in reversed(group_rows):
|
|
370
|
+
tg_id = row.pop('_tg_id', None)
|
|
346
371
|
self.messages.insert(insert_at, row)
|
|
347
372
|
token_count = await self.get_message_token_count()
|
|
348
373
|
if token_count > token_limit:
|
|
@@ -351,6 +376,8 @@ class Conversation:
|
|
|
351
376
|
group_limit_reached = True
|
|
352
377
|
break
|
|
353
378
|
group_rows_inserted += 1
|
|
379
|
+
if tg_id is not None:
|
|
380
|
+
self._loaded_message_ids.add(tg_id)
|
|
354
381
|
|
|
355
382
|
has_context = rows_inserted > 0 or group_rows_inserted > 0
|
|
356
383
|
|
|
@@ -394,6 +421,7 @@ class Conversation:
|
|
|
394
421
|
- Modifies self.messages in-place, inserting new rows at self._history_end[user_id]
|
|
395
422
|
(after historical context, before any live session messages).
|
|
396
423
|
- Advances self._context_cursor[user_id] and self._history_end[user_id] on success.
|
|
424
|
+
- Updates self._loaded_message_ids with tg_message_id values from merged rows.
|
|
397
425
|
"""
|
|
398
426
|
if user_id not in self._context_cursor:
|
|
399
427
|
return False
|
|
@@ -416,12 +444,15 @@ class Conversation:
|
|
|
416
444
|
insert_pos = self._history_end.get(user_id, 1)
|
|
417
445
|
rows_inserted = 0
|
|
418
446
|
for row in reversed(new_rows):
|
|
447
|
+
tg_id = row.pop('_tg_id', None)
|
|
419
448
|
self.messages.insert(insert_pos, row)
|
|
420
449
|
token_count = await self.get_message_token_count()
|
|
421
450
|
if token_count > token_limit:
|
|
422
451
|
self.messages.pop(insert_pos)
|
|
423
452
|
break
|
|
424
453
|
rows_inserted += 1
|
|
454
|
+
if tg_id is not None:
|
|
455
|
+
self._loaded_message_ids.add(tg_id)
|
|
425
456
|
|
|
426
457
|
if rows_inserted > 0:
|
|
427
458
|
self._context_cursor[user_id] = current_max
|
|
@@ -445,12 +476,14 @@ class Conversation:
|
|
|
445
476
|
- Resets self._context_cursor to empty dict.
|
|
446
477
|
- Resets self._history_end to empty dict.
|
|
447
478
|
- Resets self._private_message_ids to empty set.
|
|
479
|
+
- Resets self._loaded_message_ids to empty set.
|
|
448
480
|
- Deletes all messages for this chat (private) or this user (group) from database.
|
|
449
481
|
"""
|
|
450
482
|
self.messages = [self.messages[0]]
|
|
451
483
|
self._context_cursor = {}
|
|
452
484
|
self._history_end = {}
|
|
453
485
|
self._private_message_ids = set()
|
|
486
|
+
self._loaded_message_ids = set()
|
|
454
487
|
if self.chat_type == 'private':
|
|
455
488
|
await delete_messages_for_chat(self.chat_id)
|
|
456
489
|
else:
|