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.
Files changed (24) hide show
  1. {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/PKG-INFO +7 -5
  2. {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/README.md +6 -4
  3. {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot/TeLLMgramBot.py +106 -86
  4. {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot/conversation.py +37 -4
  5. {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot/database.py +122 -34
  6. {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot/initialize.py +63 -22
  7. {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot/message_handlers.py +2 -0
  8. {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot/models.py +2 -0
  9. {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot/providers/base.py +5 -0
  10. {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot/providers/factory.py +1 -0
  11. {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot/providers/openai_provider.py +1 -0
  12. {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot/utils.py +12 -0
  13. {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot/web_utils.py +7 -0
  14. {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot.egg-info/PKG-INFO +7 -5
  15. {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/setup.py +1 -1
  16. {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/LICENSE +0 -0
  17. {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot/__init__.py +0 -0
  18. {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot/providers/__init__.py +0 -0
  19. {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot/providers/anthropic_provider.py +0 -0
  20. {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot.egg-info/SOURCES.txt +0 -0
  21. {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot.egg-info/dependency_links.txt +0 -0
  22. {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot.egg-info/requires.txt +0 -0
  23. {tellmgrambot-3.10.1 → tellmgrambot-3.10.2}/TeLLMgramBot.egg-info/top_level.txt +0 -0
  24. {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.1
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
- The bot also automatically captures and indexes messages from other bots in the group, making them searchable and available as context in private chats. For example, you can ask "What did Bot B say about the project?" and the bot will search across all shared groups.
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, the bot immediately sends a 👀 reaction on your message (falls back to "Got it!" on older clients) before generating the full LLM response.
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
- The bot also automatically captures and indexes messages from other bots in the group, making them searchable and available as context in private chats. For example, you can ask "What did Bot B say about the project?" and the bot will search across all shared groups.
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, the bot immediately sends a 👀 reaction on your message (falls back to "Got it!" on older clients) before generating the full LLM response.
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
- prune_foreign_bot_messages,
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
- await msg.reply_text(await self.tele_handle_response(prompt, msg))
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
- The bot's response string. If no handler matches or features are disabled,
273
- returns a generic error message.
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 handle_greetings(text):
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 _store_foreign_bot_message(self, msg: Message, chat: Chat) -> None:
391
+ async def _build_reply_context(self, msg: Message) -> str:
385
392
  """
386
- Persist a group message from another bot to the database.
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
- Called when a bot message is received in a group chat. Stores the message with
389
- is_foreign_bot=True so it is searchable via the LLM search tool. Enforces the
390
- per-chat cap (_FOREIGN_BOT_MESSAGE_CAP) by pruning the oldest rows after insert.
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 from the foreign bot.
394
- chat: The group chat the message was sent in.
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
- bot = msg.from_user
397
- await upsert_user(bot.id, bot.username, bot.first_name, bot.last_name)
398
- await upsert_chat(chat.id, chat.type, chat.title)
399
- await insert_message(
400
- chat_id=chat.id,
401
- user_id=bot.id,
402
- role='user',
403
- content=msg.text,
404
- is_foreign_bot=True,
405
- )
406
- await prune_foreign_bot_messages(chat.id, _FOREIGN_BOT_MESSAGE_CAP)
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, messages from other bots (user.is_bot=True, user.id != our bot_id)
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
- # Store foreign bot messages in group chats and return - no LLM response needed.
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
- response = await self.tele_handle_response(msg.text, msg)
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
- for chunk in [response[i:i+chunk_length] for i in range(0, len(response), chunk_length)]:
565
- await msg.reply_text(chunk)
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
- return await insert_message(self.chat_id, user_id, "user", content, is_private)
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: