TeLLMgramBot 3.14.0__tar.gz → 3.14.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 (26) hide show
  1. {tellmgrambot-3.14.0 → tellmgrambot-3.14.2}/PKG-INFO +4 -3
  2. {tellmgrambot-3.14.0 → tellmgrambot-3.14.2}/README.md +3 -2
  3. {tellmgrambot-3.14.0 → tellmgrambot-3.14.2}/TeLLMgramBot/TeLLMgramBot.py +134 -77
  4. {tellmgrambot-3.14.0 → tellmgrambot-3.14.2}/TeLLMgramBot/conversation.py +1 -4
  5. {tellmgrambot-3.14.0 → tellmgrambot-3.14.2}/TeLLMgramBot/database.py +37 -13
  6. {tellmgrambot-3.14.0 → tellmgrambot-3.14.2}/TeLLMgramBot/initialize.py +35 -21
  7. {tellmgrambot-3.14.0 → tellmgrambot-3.14.2}/TeLLMgramBot.egg-info/PKG-INFO +4 -3
  8. {tellmgrambot-3.14.0 → tellmgrambot-3.14.2}/setup.py +1 -1
  9. {tellmgrambot-3.14.0 → tellmgrambot-3.14.2}/LICENSE +0 -0
  10. {tellmgrambot-3.14.0 → tellmgrambot-3.14.2}/TeLLMgramBot/__init__.py +0 -0
  11. {tellmgrambot-3.14.0 → tellmgrambot-3.14.2}/TeLLMgramBot/archive.py +0 -0
  12. {tellmgrambot-3.14.0 → tellmgrambot-3.14.2}/TeLLMgramBot/message_handlers.py +0 -0
  13. {tellmgrambot-3.14.0 → tellmgrambot-3.14.2}/TeLLMgramBot/models.py +0 -0
  14. {tellmgrambot-3.14.0 → tellmgrambot-3.14.2}/TeLLMgramBot/providers/__init__.py +0 -0
  15. {tellmgrambot-3.14.0 → tellmgrambot-3.14.2}/TeLLMgramBot/providers/anthropic_provider.py +0 -0
  16. {tellmgrambot-3.14.0 → tellmgrambot-3.14.2}/TeLLMgramBot/providers/base.py +0 -0
  17. {tellmgrambot-3.14.0 → tellmgrambot-3.14.2}/TeLLMgramBot/providers/factory.py +0 -0
  18. {tellmgrambot-3.14.0 → tellmgrambot-3.14.2}/TeLLMgramBot/providers/openai_provider.py +0 -0
  19. {tellmgrambot-3.14.0 → tellmgrambot-3.14.2}/TeLLMgramBot/tools.py +0 -0
  20. {tellmgrambot-3.14.0 → tellmgrambot-3.14.2}/TeLLMgramBot/utils.py +0 -0
  21. {tellmgrambot-3.14.0 → tellmgrambot-3.14.2}/TeLLMgramBot/web_utils.py +0 -0
  22. {tellmgrambot-3.14.0 → tellmgrambot-3.14.2}/TeLLMgramBot.egg-info/SOURCES.txt +0 -0
  23. {tellmgrambot-3.14.0 → tellmgrambot-3.14.2}/TeLLMgramBot.egg-info/dependency_links.txt +0 -0
  24. {tellmgrambot-3.14.0 → tellmgrambot-3.14.2}/TeLLMgramBot.egg-info/requires.txt +0 -0
  25. {tellmgrambot-3.14.0 → tellmgrambot-3.14.2}/TeLLMgramBot.egg-info/top_level.txt +0 -0
  26. {tellmgrambot-3.14.0 → tellmgrambot-3.14.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: TeLLMgramBot
3
- Version: 3.14.0
3
+ Version: 3.14.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
@@ -83,6 +83,7 @@ TeLLMgramBot creates the following directories:
83
83
  - A system appendix is automatically appended to every persona at runtime, teaching the LLM about cross-chat memory and search behavior. User messages include speaker annotations with chat context and timestamps so the LLM always knows who is speaking, in which chat, and when.
84
84
  - **`logs`** - Bot instance logs (one per startup, named after the bot's Telegram username or `instance_name` config, e.g. `my_bot_2026-03-29_10-30-45.log`)
85
85
  - Logs include anonymized Telegram IDs for privacy. Console shows INFO-level TeLLMgramBot messages only, prefixed with an `[identity label]` (the bot's Telegram username by default, or `instance_name` when configured).
86
+ - Log file timestamps are UTC in `[YYYY-MM-DD HH:MM:SS.mmm]` format.
86
87
  - Bot keeps the 10 most recent logs per bot instance, automatically pruning older ones.
87
88
  - Pass `-v` or `--verbose` on startup for DEBUG-level logging.
88
89
  - **`data`** - SQLite database (default `conversations.db`, customizable via `instance_name` config) storing all messages, users, and chats
@@ -167,9 +168,9 @@ When the bot is triggered in a group and about to respond (not deferring to anot
167
168
  ```python
168
169
  from TeLLMgramBot import TelegramBot
169
170
  telegram_bot = TelegramBot.set()
170
- telegram_bot.start_polling()
171
+ telegram_bot.poll()
171
172
  ```
172
- Once you see `TeLLMgramBot polling...`, the bot is online.
173
+ Once you see `TeLLMgramBot polling started`, the bot is online.
173
174
  6. Type `/help` in Telegram to see all available commands.
174
175
 
175
176
  ## Resources
@@ -51,6 +51,7 @@ TeLLMgramBot creates the following directories:
51
51
  - A system appendix is automatically appended to every persona at runtime, teaching the LLM about cross-chat memory and search behavior. User messages include speaker annotations with chat context and timestamps so the LLM always knows who is speaking, in which chat, and when.
52
52
  - **`logs`** - Bot instance logs (one per startup, named after the bot's Telegram username or `instance_name` config, e.g. `my_bot_2026-03-29_10-30-45.log`)
53
53
  - Logs include anonymized Telegram IDs for privacy. Console shows INFO-level TeLLMgramBot messages only, prefixed with an `[identity label]` (the bot's Telegram username by default, or `instance_name` when configured).
54
+ - Log file timestamps are UTC in `[YYYY-MM-DD HH:MM:SS.mmm]` format.
54
55
  - Bot keeps the 10 most recent logs per bot instance, automatically pruning older ones.
55
56
  - Pass `-v` or `--verbose` on startup for DEBUG-level logging.
56
57
  - **`data`** - SQLite database (default `conversations.db`, customizable via `instance_name` config) storing all messages, users, and chats
@@ -135,9 +136,9 @@ When the bot is triggered in a group and about to respond (not deferring to anot
135
136
  ```python
136
137
  from TeLLMgramBot import TelegramBot
137
138
  telegram_bot = TelegramBot.set()
138
- telegram_bot.start_polling()
139
+ telegram_bot.poll()
139
140
  ```
140
- Once you see `TeLLMgramBot polling...`, the bot is online.
141
+ Once you see `TeLLMgramBot polling started`, the bot is online.
141
142
  6. Type `/help` in Telegram to see all available commands.
142
143
 
143
144
  ## Resources
@@ -38,7 +38,6 @@ from .initialize import (
38
38
  INIT_BOT_CONFIG,
39
39
  ApiKeyStatus,
40
40
  bind_log_identity,
41
- init_logging,
42
41
  init_structure,
43
42
  )
44
43
  from .message_handlers import handle_greetings, handle_common_queries, handle_url_ask
@@ -410,7 +409,7 @@ class TelegramBot:
410
409
  "from group conversation contexts. Use /private off to disable."
411
410
  )
412
411
 
413
- async def tele_handle_response(self, text: str, msg: Message, context_prefix: str = '') -> tuple[str, int | None]:
412
+ async def tele_handle_response(self, text: str, msg: Message) -> tuple[str, int | None]:
414
413
  """
415
414
  Primary function for handling any response including Generative AI, ensuring:
416
415
  - The owner started up the bot assistant for user interactions.
@@ -429,8 +428,6 @@ class TelegramBot:
429
428
  Args:
430
429
  text: The user's message text.
431
430
  msg: The Telegram Message object containing chat/user context.
432
- context_prefix: Optional context prepended to the message before the live
433
- speaker annotation. Used for reply-to-thread context. Defaults to ''.
434
431
 
435
432
  Returns:
436
433
  Tuple of (response_text, assistant_db_id). response_text is the bot's response
@@ -442,6 +439,7 @@ class TelegramBot:
442
439
  - Adds user message to conversation history (keyed by chat_id).
443
440
  - Upserts user (first_name, last_name, username) and chat metadata.
444
441
  - Loads past interactions if this is the first message in this chat_id.
442
+ - Surfaces the replied-to message into context via _surface_replied_to_message().
445
443
  - May invoke LLM tool calling (search_messages) and perform a second LLM round-trip.
446
444
  - Generates a token warning if conversation nears the limit.
447
445
  - Prunes conversation if token count exceeds the threshold.
@@ -489,6 +487,9 @@ class TelegramBot:
489
487
  # Already loaded - check for new cross-chat messages since last load.
490
488
  await conv.refresh_user_context(user_id, token_budget)
491
489
 
490
+ # Surface the replied-to message into context before adding the triggering message.
491
+ await self._surface_replied_to_message(msg, conv)
492
+
492
493
  # Short-circuit for trivial greetings/queries - not persisted to conversation history
493
494
  quick_reply = handle_greetings(text) or handle_common_queries(text)
494
495
  if quick_reply:
@@ -499,7 +500,7 @@ class TelegramBot:
499
500
  user_private_mode = await get_private_mode(user_id)
500
501
  is_private = (chat_type == 'private') and user_private_mode
501
502
  user_msg_id = await conv.add_user_message(
502
- text, user_id, username, first_name, last_name, is_private, context_prefix, msg.message_id
503
+ text, user_id, username, first_name, last_name, is_private, msg.message_id
503
504
  )
504
505
 
505
506
  # Check if the user is asking about a [URL]
@@ -507,10 +508,10 @@ class TelegramBot:
507
508
 
508
509
  # Form the assistant's message based on low level easy stuff or send to the LLM
509
510
  reply = _MSG_PROCESS_ERROR
510
- if url_match and self.key_status.url_analysis_enabled:
511
+ if url_match and self._key_status.url_analysis_enabled:
511
512
  await msg.reply_text("Sure, give me a moment to look at that URL...")
512
513
  reply = await handle_url_ask(text, self.llm['url_model'], conv.system_content)
513
- elif self._online and self.key_status.chat_enabled:
514
+ elif self._online and self._key_status.chat_enabled:
514
515
  # This is the transition point between quick Telegram replies and the LLM.
515
516
  tools = self._build_tool_list(chat_type, username)
516
517
  result = await self.llm_completion(chat_id, tools)
@@ -549,65 +550,51 @@ class TelegramBot:
549
550
 
550
551
  return reply, assistant_db_id
551
552
 
552
- async def _build_reply_context(self, msg: Message) -> str:
553
+ async def _surface_replied_to_message(self, msg: Message, conv: Conversation) -> None:
553
554
  """
554
- Build an inline context prefix when the user is replying to another message.
555
-
556
- Used to surface context when the triggering message is a reply-to another message
557
- not already in the conversation's memory window. Returns an annotated prefix string
558
- with speaker name and timestamp if the replied-to message is not in context, or an
559
- empty string if already present.
560
-
561
- Deduplication applies only to our own bot's replies (to avoid repeating content
562
- already visible in context). For all other senders - foreign bots and human users -
563
- the prefix is always prepended so the LLM has an explicit anchor to the referenced
564
- message regardless of whether it appears elsewhere in the loaded context.
565
-
566
- Own-bot dedup uses two tiers:
567
- 1. Memory - reply_to_message.message_id in conv._loaded_message_ids (O(1)).
568
- In-session bot replies are added here immediately after each send.
569
- 2. DB fallback - query for tg_message_id in the messages table.
570
- Cross-session bot replies are findable here since tg_message_id is now
571
- stored for assistant messages after each send.
572
-
573
- Fires in all chat types (group and private) when reply_to_message exists and has text.
574
- from_user is not required — messages from channel-linked bots or anonymous admins
575
- may omit it; sender_chat.title or 'unknown' is used as the name fallback.
576
- Correctly re-surfaces bot messages that are no longer in context (e.g. post-/forget).
555
+ Persist and inject the replied-to message into conversation context.
556
+
557
+ Replaces the old prefix-string approach: instead of prepending replied-to content
558
+ to the triggering message, the replied-to message is injected as its own entry in
559
+ conv.messages (and stored to DB if not already there), matching the format used
560
+ for DB-loaded messages.
561
+
562
+ Three cases:
563
+ Own bot: inject as role='assistant' with raw content (no speaker prefix). If not
564
+ already in DB (checked via message_id_exists), persists via upsert_user (bot_id
565
+ as user), upsert_chat, and insert_message with created_at=reply.date. Covers
566
+ both post-/forget (not in DB) and outside-token-window (in DB but not loaded).
567
+ Foreign bot or regular user with from_user: if not already in DB, insert with
568
+ original timestamp (upsert_user, upsert_chat, insert_message with created_at).
569
+ Inject as role='user' with '[Replying to Name, dt]:' format for explicit context.
570
+ from_user=None (channel-linked bot, anonymous admin): inject only - no DB write
571
+ since there is no user_id to reference.
572
+
573
+ No-op if msg has no reply, the reply has no text, or the replied-to message is
574
+ already in conv._loaded_message_ids (already in context window).
577
575
 
578
576
  Args:
579
577
  msg: The incoming Telegram Message that triggered the bot.
580
-
581
- Returns:
582
- Formatted prefix string, e.g.
583
- "[BotB, 2026-04-05 14:30 UTC]: The ancient sword was forged in...\n"
584
- or empty string if the replied-to message is already in context.
578
+ conv: The active Conversation for this chat.
585
579
  """
586
580
  reply = msg.reply_to_message
587
581
  if not reply or not reply.text:
588
- return ''
582
+ return
589
583
 
590
584
  r_tg_id = reply.message_id
591
- conv = self.conversations.get(msg.chat.id)
585
+ if r_tg_id in conv._loaded_message_ids:
586
+ return
587
+
592
588
  is_our_reply = (
593
589
  reply.from_user is not None and
594
590
  reply.from_user.id == self.telegram['bot_id']
595
591
  )
592
+ is_foreign_bot = (
593
+ reply.from_user is not None and
594
+ reply.from_user.is_bot and
595
+ not is_our_reply
596
+ )
596
597
 
597
- if is_our_reply:
598
- # Dedup only our own bot's replies to avoid repeating content already visible.
599
- # Tier 1: in-session check via loaded message ID set
600
- if conv and r_tg_id in conv._loaded_message_ids:
601
- return ''
602
- # Tier 2: DB fallback for cross-session bot replies
603
- if await message_id_exists(msg.chat.id, r_tg_id):
604
- return ''
605
-
606
- # For own-bot replies, mark as seen to skip dedup on subsequent replies
607
- # to the same message within the session. Foreign/human IDs are tracked
608
- # by tele_handle_message after the response is sent.
609
- if is_our_reply and conv:
610
- conv._loaded_message_ids.add(r_tg_id)
611
598
  if reply.from_user:
612
599
  sender = reply.from_user
613
600
  name_parts = [p for p in [sender.first_name, sender.last_name] if p]
@@ -616,16 +603,76 @@ class TelegramBot:
616
603
  name = reply.sender_chat.title or reply.sender_chat.username or 'unknown'
617
604
  else:
618
605
  name = 'unknown'
606
+
619
607
  dt = format_dt(reply.date)
620
- return f"[Replying to {name}, {dt}]: {reply.text}\n"
608
+
609
+ if is_our_reply:
610
+ if not await message_id_exists(msg.chat.id, r_tg_id):
611
+ is_private = (
612
+ msg.chat.type == 'private' and
613
+ msg.from_user is not None and
614
+ await get_private_mode(msg.from_user.id)
615
+ )
616
+ await upsert_user(
617
+ self.telegram['bot_id'],
618
+ self.telegram['username'],
619
+ self.telegram.get('first_name'),
620
+ self.telegram.get('last_name'),
621
+ is_bot=True,
622
+ )
623
+ await upsert_chat(msg.chat.id, msg.chat.type, msg.chat.title or msg.chat.username or '')
624
+ await insert_message(
625
+ chat_id=msg.chat.id,
626
+ user_id=self.telegram['bot_id'],
627
+ role='assistant',
628
+ content=reply.text,
629
+ is_private=is_private,
630
+ tg_message_id=r_tg_id,
631
+ created_at=reply.date,
632
+ )
633
+ conv.messages.append({"role": "assistant", "content": reply.text})
634
+ conv._loaded_message_ids.add(r_tg_id)
635
+ return
636
+
637
+ # For regular users and foreign bots with a known user_id: persist to DB if new.
638
+ if reply.from_user is not None:
639
+ if not await message_id_exists(msg.chat.id, r_tg_id):
640
+ is_private = (
641
+ msg.chat.type == 'private' and
642
+ msg.from_user is not None and
643
+ await get_private_mode(msg.from_user.id)
644
+ )
645
+ await upsert_user(
646
+ reply.from_user.id,
647
+ reply.from_user.username,
648
+ reply.from_user.first_name,
649
+ reply.from_user.last_name,
650
+ is_bot=is_foreign_bot,
651
+ )
652
+ await upsert_chat(msg.chat.id, msg.chat.type, msg.chat.title or msg.chat.username or '')
653
+ await insert_message(
654
+ chat_id=msg.chat.id,
655
+ user_id=reply.from_user.id,
656
+ role='user',
657
+ content=reply.text,
658
+ is_private=is_private,
659
+ tg_message_id=r_tg_id,
660
+ created_at=reply.date,
661
+ )
662
+ if is_foreign_bot:
663
+ await prune_bot_messages(msg.chat.id)
664
+
665
+ conv.messages.append({"role": "user", "content": f"[Replying to {name}, {dt}]: {reply.text}"})
666
+ conv._loaded_message_ids.add(r_tg_id)
621
667
 
622
668
  async def _store_bot_message(self, msg: Message) -> None:
623
669
  """
624
670
  Persist a foreign bot's message to the DB for ambient group context.
625
671
 
626
672
  Registers the bot as a user (is_bot=True) and its chat, inserts the message
627
- with role='user', then prunes the per-chat bot message cap. Skips if the
628
- message has no text or is already stored (tg_message_id dedup).
673
+ with role='user' and original timestamp (created_at=msg.date), then prunes
674
+ the per-chat bot message cap. Skips if the message has no text or is already
675
+ stored (tg_message_id dedup).
629
676
 
630
677
  Args:
631
678
  msg: The Telegram Message from a foreign bot (msg.from_user.is_bot must be True).
@@ -644,6 +691,7 @@ class TelegramBot:
644
691
  content=msg.text,
645
692
  is_private=False,
646
693
  tg_message_id=msg.message_id,
694
+ created_at=msg.date,
647
695
  )
648
696
  await prune_bot_messages(msg.chat.id)
649
697
 
@@ -711,7 +759,9 @@ class TelegramBot:
711
759
 
712
760
  Path A - foreign bot message (standalone update): Stores the message for ambient context and returns without LLM response.
713
761
  Path B - human reply to foreign bot: Stores the replied-to message before normal routing (fires regardless of whether we are triggered).
714
- Nickname/initials triggers always engage even when foreign bots are also @mentioned.
762
+ In a foreign-bot reply thread, an exclusive foreign @mention yields the bot before
763
+ nickname/initials are evaluated - @username takes absolute precedence in that context.
764
+ Nickname/initials triggers engage unconditionally in all other contexts.
715
765
  Reply-to-bot trigger yields silently when the message is exclusively addressed to a foreign @account.
716
766
 
717
767
  In private chats, the bot responds to all messages (Telegram does not deliver bot-to-bot DMs).
@@ -753,43 +803,48 @@ class TelegramBot:
753
803
  # The real magic of how the bot behaves is in tele_handle_response()
754
804
  response = _MSG_PROCESS_ERROR
755
805
  assistant_db_id = None
756
- reply_context = ''
757
806
  if chat.type == 'supergroup' or chat.type == 'group':
758
807
  is_reply_to_bot = (
759
808
  msg.reply_to_message is not None and
760
809
  msg.reply_to_message.from_user is not None and
761
810
  msg.reply_to_message.from_user.id == self.telegram['bot_id']
762
811
  )
812
+ is_reply_to_foreign_bot = (
813
+ msg.reply_to_message is not None and
814
+ msg.reply_to_message.from_user is not None and
815
+ msg.reply_to_message.from_user.is_bot and
816
+ msg.reply_to_message.from_user.id != self.telegram['bot_id']
817
+ )
818
+ # In a foreign-bot reply thread, an exclusive @mention of another account
819
+ # takes absolute precedence over any nickname/initials match in the text.
820
+ if is_reply_to_foreign_bot and self._exclusive_foreign_mention(msg):
821
+ return
763
822
  if exact_word_match(self.telegram['username'], msg.text):
764
823
  # Explicit @username mention: strongest signal - respond even if another
765
824
  # bot is also @mentioned (both may be intentionally addressed).
766
825
  pattern = r'@?\b' + re.escape(self.telegram['username']) + r'\b'
767
826
  new_text = re.sub(pattern, '', msg.text).strip()
768
- reply_context = await self._build_reply_context(msg)
769
827
  await self._send_read_receipt(msg, context)
770
- response, assistant_db_id = await self.tele_handle_response(new_text, msg, reply_context)
828
+ response, assistant_db_id = await self.tele_handle_response(new_text, msg)
771
829
  elif (
772
830
  exact_word_match(self.telegram['nickname'], msg.text) or
773
831
  exact_word_match(self.telegram['initials'], msg.text)
774
832
  ):
775
833
  # Nickname/initials: always engage - no reliable way to distinguish
776
834
  # our name as addressee vs topic from text position alone.
777
- reply_context = await self._build_reply_context(msg)
778
835
  await self._send_read_receipt(msg, context)
779
- response, assistant_db_id = await self.tele_handle_response(msg.text, msg, reply_context)
836
+ response, assistant_db_id = await self.tele_handle_response(msg.text, msg)
780
837
  elif is_reply_to_bot:
781
838
  # Reply-to-bot: weaker signal - yield silently if the message is
782
839
  # exclusively addressed to a foreign account via @mention.
783
840
  if self._exclusive_foreign_mention(msg):
784
841
  return
785
- reply_context = await self._build_reply_context(msg)
786
842
  await self._send_read_receipt(msg, context)
787
- response, assistant_db_id = await self.tele_handle_response(msg.text, msg, reply_context)
843
+ response, assistant_db_id = await self.tele_handle_response(msg.text, msg)
788
844
  else:
789
845
  return
790
846
  elif chat.type == 'private':
791
- reply_context = await self._build_reply_context(msg)
792
- response, assistant_db_id = await self.tele_handle_response(msg.text, msg, reply_context)
847
+ response, assistant_db_id = await self.tele_handle_response(msg.text, msg)
793
848
  else:
794
849
  return
795
850
 
@@ -797,10 +852,6 @@ class TelegramBot:
797
852
  chunk_length = MessageLimit.MAX_TEXT_LENGTH - 1
798
853
  chunks = [response[i:i+chunk_length] for i in range(0, len(response), chunk_length)]
799
854
  conv = self.conversations.get(msg.chat.id)
800
- # If reply context was surfaced on the first interaction (conv was None when
801
- # _build_reply_context ran), record the ID now that the conversation exists.
802
- if conv and reply_context and msg.reply_to_message:
803
- conv._loaded_message_ids.add(msg.reply_to_message.message_id)
804
855
  for chunk in chunks:
805
856
  sent = await msg.reply_text(chunk)
806
857
  if sent:
@@ -1022,11 +1073,19 @@ class TelegramBot:
1022
1073
  log_error(e, f"{self.llm['chat_model']} Other")
1023
1074
  return "Sorry, something went wrong on my end. Please try again!"
1024
1075
 
1025
- def start_polling(self):
1026
- """The main polling "loop" the user interacts with via Telegram."""
1027
- logger.info(f"TeLLMgramBot {self.telegram['username']} polling...")
1076
+ async def tele_unknown_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
1077
+ """Reply to unrecognized commands so the LLM never sees them."""
1078
+ await update.message.reply_text("Unknown command. Use /help to see available commands.")
1079
+
1080
+ def poll(self):
1081
+ """
1082
+ Start the main polling loop for Telegram updates.
1083
+
1084
+ Blocks indefinitely, logging UTC datetime to file and console at startup and shutdown.
1085
+ """
1086
+ logger.info(f"TeLLMgramBot {self.telegram['username']} polling started at {datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}")
1028
1087
  self.telegram['app'].run_polling(poll_interval=self.telegram['pollinterval'])
1029
- logger.info(f"TeLLMgramBot {self.telegram['username']} polling ended.")
1088
+ logger.info(f"TeLLMgramBot {self.telegram['username']} polling ended at {datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}")
1030
1089
 
1031
1090
  # Initialization
1032
1091
  def __init__(self,
@@ -1079,10 +1138,7 @@ class TelegramBot:
1079
1138
  # Starting to initialize, not online yet
1080
1139
  self._online = False
1081
1140
  self._instance_name = instance_name
1082
-
1083
- # Bootstrap structure and logging; init_logging() is a no-op if init_structure already ran it.
1084
- self.key_status = key_status if key_status is not None else init_structure()[0]
1085
- init_logging()
1141
+ self._key_status = key_status if key_status is not None else init_structure()[0]
1086
1142
 
1087
1143
  # Initialize some variables
1088
1144
  self.token_warning = {} # Determines whether user has reached token limit by AI model
@@ -1122,6 +1178,7 @@ class TelegramBot:
1122
1178
  self.telegram['app'].add_handler(CommandHandler('nick', self.tele_nick_command))
1123
1179
  self.telegram['app'].add_handler(CommandHandler('forget', self.tele_forget_command))
1124
1180
  self.telegram['app'].add_handler(CommandHandler('private', self.tele_private_command))
1181
+ self.telegram['app'].add_handler(MessageHandler(filters.COMMAND, self.tele_unknown_command))
1125
1182
  self.telegram['app'].add_handler(MessageHandler(filters.TEXT & ~filters.UpdateType.EDITED_MESSAGE, self.tele_handle_message))
1126
1183
  self.telegram['app'].add_error_handler(self.tele_error)
1127
1184
 
@@ -152,7 +152,6 @@ class Conversation:
152
152
  first_name: str | None = None,
153
153
  last_name: str | None = None,
154
154
  is_private: bool = False,
155
- context_prefix: str = '',
156
155
  tg_message_id: int | None = None,
157
156
  ) -> int:
158
157
  """
@@ -175,8 +174,6 @@ class Conversation:
175
174
  is_private: If True, marks the message as private-mode (excluded from all context
176
175
  loads) and flags it in the speaker annotation so the LLM understands
177
176
  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
177
  tg_message_id: Telegram message ID; added to _loaded_message_ids so future
181
178
  reply-to-thread checks know this message is already in context.
182
179
 
@@ -186,7 +183,7 @@ class Conversation:
186
183
  await upsert_user(user_id, username, first_name, last_name)
187
184
  await upsert_chat(self.chat_id, self.chat_type, self.chat_title)
188
185
  prefix = self._live_prefix(first_name, last_name, username, is_private)
189
- msg_dict = {"role": "user", "content": context_prefix + prefix + content}
186
+ msg_dict = {"role": "user", "content": prefix + content}
190
187
  self.messages.append(msg_dict)
191
188
  if is_private:
192
189
  self._private_message_ids.add(id(msg_dict))
@@ -6,6 +6,7 @@ searching message history, and retrieving conversation context. The schema inclu
6
6
  table (indexed by chat_id and user_id, with is_private flag), a users table (profile data
7
7
  and private_mode flag), and a chats table for speaker/chat resolution in search results.
8
8
  """
9
+ import datetime
9
10
  import json
10
11
  import os
11
12
  from typing import Optional
@@ -183,6 +184,7 @@ async def insert_message(
183
184
  is_private: bool = False,
184
185
  reply_to_id: Optional[int] = None,
185
186
  tg_message_id: Optional[int] = None,
187
+ created_at: Optional[datetime.datetime] = None,
186
188
  ) -> int:
187
189
  """
188
190
  Persist a single message row and return its database id.
@@ -195,17 +197,28 @@ async def insert_message(
195
197
  is_private: If True, excluded from all context loading.
196
198
  reply_to_id: DB id of the replied-to message, or None.
197
199
  tg_message_id: Telegram message ID; enables cross-session reply-to dedup.
200
+ created_at: Original message timestamp; when provided, stored instead of NOW().
201
+ Used to preserve the original send time for replied-to messages.
198
202
 
199
203
  Returns:
200
204
  Inserted row id.
201
205
  """
202
206
  async with aiosqlite.connect(get_db_path()) as db:
203
- cursor = await db.execute(
204
- "INSERT INTO messages "
205
- "(chat_id, user_id, role, content, is_private, reply_to_id, tg_message_id) "
206
- "VALUES (?, ?, ?, ?, ?, ?, ?)",
207
- (chat_id, user_id, role, content, int(is_private), reply_to_id, tg_message_id),
208
- )
207
+ if created_at is not None:
208
+ created_at_str = created_at.strftime('%Y-%m-%dT%H:%M:%S.000Z')
209
+ cursor = await db.execute(
210
+ "INSERT INTO messages "
211
+ "(chat_id, user_id, role, content, is_private, reply_to_id, tg_message_id, created_at) "
212
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
213
+ (chat_id, user_id, role, content, int(is_private), reply_to_id, tg_message_id, created_at_str),
214
+ )
215
+ else:
216
+ cursor = await db.execute(
217
+ "INSERT INTO messages "
218
+ "(chat_id, user_id, role, content, is_private, reply_to_id, tg_message_id) "
219
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
220
+ (chat_id, user_id, role, content, int(is_private), reply_to_id, tg_message_id),
221
+ )
209
222
  await db.commit()
210
223
  return cursor.lastrowid
211
224
 
@@ -322,6 +335,7 @@ def _build_speaker_prefix(
322
335
  username: Optional[str],
323
336
  chat_title: Optional[str],
324
337
  created_at: Optional[str] = None,
338
+ is_bot: bool = False,
325
339
  ) -> str:
326
340
  """
327
341
  Build a speaker attribution prefix for a context message.
@@ -329,6 +343,8 @@ def _build_speaker_prefix(
329
343
  Returns '' for assistant/system messages or when no identity is resolvable.
330
344
  Same-chat produces "[Name, YYYY-MM-DD HH:MM UTC]: ";
331
345
  cross-chat produces "[Name (ChatTitle), YYYY-MM-DD HH:MM UTC]: ".
346
+ Bot senders (is_bot=True) are prefixed with "BOT: " inside the brackets so the
347
+ LLM can distinguish peer bots from human participants without a system prompt change.
332
348
 
333
349
  Args:
334
350
  role: Message role ('user', 'assistant', 'system').
@@ -340,6 +356,7 @@ def _build_speaker_prefix(
340
356
  username: Telegram @handle without @, may be None.
341
357
  chat_title: Group/channel display name, or None for private chats.
342
358
  created_at: ISO timestamp; date portion included in prefix if present.
359
+ is_bot: True for Telegram bot accounts; prepends "BOT: " to the name.
343
360
 
344
361
  Returns:
345
362
  Speaker prefix string, or '' for assistant/system/unknown-identity messages.
@@ -350,6 +367,8 @@ def _build_speaker_prefix(
350
367
  name = ' '.join(name_parts) if name_parts else username
351
368
  if not name:
352
369
  return ''
370
+ if is_bot:
371
+ name = f'BOT: {name}'
353
372
  date = (created_at[:16].replace('T', ' ') + ' UTC') if created_at else None
354
373
  if chat_id != current_chat_id:
355
374
  if chat_title:
@@ -382,6 +401,7 @@ async def load_full_user_context(
382
401
  User messages receive a speaker prefix (assistant messages do not) built by _build_speaker_prefix:
383
402
  "[Name, YYYY-MM-DD HH:MM UTC]: " for same-chat messages.
384
403
  "[Name (ChatTitle), YYYY-MM-DD HH:MM UTC]: " for cross-chat.
404
+ Bot senders (is_bot=True) are prefixed as "[BOT: Name, YYYY-MM-DD HH:MM UTC]: "
385
405
 
386
406
  The exclude_private flag is controlled by the caller and applies regardless of chat type.
387
407
  Conversation.get_past_interaction always passes exclude_private=True so that is_private=1
@@ -397,7 +417,7 @@ async def load_full_user_context(
397
417
  """
398
418
  raw_query = (
399
419
  "SELECT m.role, m.content, m.user_id, m.chat_id, "
400
- "u.first_name, u.last_name, u.username, c.chat_title, m.created_at, m.tg_message_id "
420
+ "u.first_name, u.last_name, u.username, c.chat_title, m.created_at, m.tg_message_id, u.is_bot "
401
421
  "FROM messages m "
402
422
  "LEFT JOIN users u ON m.user_id = u.user_id "
403
423
  "LEFT JOIN chats c ON m.chat_id = c.chat_id "
@@ -440,7 +460,8 @@ async def load_full_user_context(
440
460
  {
441
461
  "role": row[0],
442
462
  "content": _build_speaker_prefix(
443
- row[0], row[3], current_chat_id, row[4], row[5], row[6], row[7], row[8]
463
+ row[0], row[3], current_chat_id, row[4], row[5], row[6], row[7], row[8],
464
+ is_bot=bool(row[10]),
444
465
  ) + row[1],
445
466
  "_tg_id": row[9],
446
467
  "_sort_key": row[8],
@@ -511,7 +532,7 @@ async def load_new_cross_chat_context(
511
532
  Load the delta of cross-chat context since a given cursor (same three-arm query
512
533
  as load_full_user_context, restricted to id > since_id). User message content is prefixed
513
534
  with "[Name, YYYY-MM-DD HH:MM UTC]: " or "[Name (ChatTitle), YYYY-MM-DD HH:MM UTC]: "
514
- when user identity is available.
535
+ when user identity is available. Bot senders are prefixed as "[BOT: Name, ...]".
515
536
 
516
537
  Args:
517
538
  user_id: Requesting user's Telegram ID.
@@ -524,7 +545,7 @@ async def load_new_cross_chat_context(
524
545
  """
525
546
  query = (
526
547
  "SELECT m.role, m.content, m.user_id, m.chat_id, "
527
- "u.first_name, u.last_name, u.username, c.chat_title, m.created_at, m.tg_message_id "
548
+ "u.first_name, u.last_name, u.username, c.chat_title, m.created_at, m.tg_message_id, u.is_bot "
528
549
  "FROM messages m "
529
550
  "LEFT JOIN users u ON m.user_id = u.user_id "
530
551
  "LEFT JOIN chats c ON m.chat_id = c.chat_id "
@@ -544,7 +565,8 @@ async def load_new_cross_chat_context(
544
565
  {
545
566
  "role": row[0],
546
567
  "content": _build_speaker_prefix(
547
- row[0], row[3], current_chat_id, row[4], row[5], row[6], row[7], row[8]
568
+ row[0], row[3], current_chat_id, row[4], row[5], row[6], row[7], row[8],
569
+ is_bot=bool(row[10]),
548
570
  ) + row[1],
549
571
  "_tg_id": row[9],
550
572
  }
@@ -591,6 +613,7 @@ async def load_shared_group_context(
591
613
  Used to fill remaining token budget in a private chat with shared-group context.
592
614
  Batches queries to stay within SQLite's 999-parameter limit. All rows are treated
593
615
  as cross-chat so every user message gets a "[Name (ChatTitle), date]: " prefix.
616
+ Bot senders are prefixed as "[BOT: Name (ChatTitle), date]: ".
594
617
 
595
618
  Args:
596
619
  group_chat_ids: List of negative group chat_ids to include.
@@ -610,7 +633,7 @@ async def load_shared_group_context(
610
633
  placeholders = ','.join('?' * len(chunk))
611
634
  query = (
612
635
  f"SELECT m.role, m.content, m.created_at, m.id, "
613
- f"m.chat_id, u.first_name, u.last_name, u.username, c.chat_title, m.tg_message_id "
636
+ f"m.chat_id, u.first_name, u.last_name, u.username, c.chat_title, m.tg_message_id, u.is_bot "
614
637
  f"FROM messages m "
615
638
  f"LEFT JOIN users u ON m.user_id = u.user_id "
616
639
  f"LEFT JOIN chats c ON m.chat_id = c.chat_id "
@@ -627,7 +650,8 @@ async def load_shared_group_context(
627
650
  {
628
651
  "role": row[0],
629
652
  "content": _build_speaker_prefix(
630
- row[0], row[4], 0, row[5], row[6], row[7], row[8], row[2]
653
+ row[0], row[4], 0, row[5], row[6], row[7], row[8], row[2],
654
+ is_bot=bool(row[10]),
631
655
  ) + row[1],
632
656
  "_tg_id": row[9],
633
657
  }
@@ -4,6 +4,7 @@ import logging
4
4
  import os
5
5
  import re
6
6
  import sys
7
+ import time
7
8
  import traceback as _traceback
8
9
  from dataclasses import dataclass, field
9
10
  from glob import glob
@@ -20,10 +21,11 @@ _logging_initialized = False
20
21
 
21
22
  class _RedactedFileFormatter(logging.Formatter):
22
23
  """
23
- Logging formatter for file output that redacts Telegram user_id and chat_id values.
24
+ Logging formatter for file output that redacts Telegram user_id and chat_id values and uses UTC timestamps.
24
25
  Replaces negative 8+ digit integers (group/channel IDs) and positive 9+ digit integers
25
26
  (user/private chat IDs) with [id], leaving token counts and other short numbers intact.
26
27
  """
28
+ converter = time.gmtime
27
29
  _NEGATIVE_ID_PATTERN = re.compile(r'-\d{8,}')
28
30
  _POSITIVE_ID_PATTERN = re.compile(r'\b\d{9,}\b')
29
31
 
@@ -127,26 +129,31 @@ INIT_BOT_CONFIG_COMMENTS = {
127
129
  # Teaches an LLM how cross-chat memory works without requiring persona authors to include it.
128
130
  _SYSTEM_APPENDIX = (
129
131
  "## System\n"
130
- "- Your Telegram username is @(not yet known), nickname '(not yet known)', initials '(not yet known)'. Users in group chats may address you by any of these. Do not sign or close your messages with your name, initials, or username.\n"
131
- "- You have access to past messages across chats and a search tool - use them to inform your responses, but never quote or volunteer content from other chats unprompted, especially in group settings.\n"
132
- "- Always invoke the search tool before concluding a message does not exist.\n"
133
- "- Messages marked private are never shared between chats.\n"
134
- "- Never reveal or repeat Telegram user IDs, chat IDs, or any other internal numeric identifiers in your responses.\n"
135
- "- When your context includes a '[Replying to Name, ...]' prefix indicating you were triggered via a reply, acknowledge the original author by name in your response.\n"
132
+ "- You are @(not yet known), nickname '(not yet known)', initials '(not yet known)'. Users in group chats may address you by any of these. Do not sign messages with your name, initials, or username.\n"
133
+ "- You have cross-chat memory and a search tool. Use them to inform your responses; always search before concluding something was not said. Never quote or volunteer content from other chats unprompted, especially in group settings.\n"
134
+ "- Messages marked private via /private mode are never shared between chats.\n"
135
+ "- Never reveal Telegram user IDs, chat IDs, or internal numeric identifiers.\n"
136
+ "- A '[Replying to Name, ...]' prefix means you were triggered via a reply - use it as context but don't address that person by name, as Telegram already shows the attribution.\n"
137
+ "- In search results, messages labeled with your nickname or sent by your username are your own past responses.\n"
136
138
  "- Current date and time: (not yet known)\n"
137
139
  )
138
140
 
139
141
 
140
- def init_logging():
142
+ def init_logging(label: str = '') -> None:
141
143
  """
142
144
  Configure the Python logging system for TeLLMgramBot (console handler only).
143
145
 
144
- Sets up a console handler (INFO, TeLLMgramBot-only, no ID redaction). The file handler
145
- and console username prefix are added later by bind_log_identity() once the bot's Telegram
146
- username is known.
146
+ Sets up a console handler (INFO level, TeLLMgramBot-only, no ID redaction). When label is
147
+ provided, the formatter is set to '[label] %(levelname)s: %(message)s' immediately so all
148
+ startup log messages are correctly prefixed from the first line. The file handler and final
149
+ username-based prefix are added later by bind_log_identity() once the Telegram username
150
+ is known via _tele_info().
147
151
 
148
- Does not depend on TELLMGRAMBOT_LOGS_PATH or init_directories(). Only
149
- bind_log_identity() requires the logs path to have been resolved.
152
+ Does not depend on TELLMGRAMBOT_LOGS_PATH or init_directories(). Only bind_log_identity()
153
+ requires the logs path to have been resolved.
154
+
155
+ Args:
156
+ label: Optional instance label for the console prefix (e.g. 'TestBot').
150
157
  """
151
158
  global _logging_initialized
152
159
  if _logging_initialized:
@@ -157,7 +164,8 @@ def init_logging():
157
164
 
158
165
  console_handler = logging.StreamHandler()
159
166
  console_handler.setLevel(logging.INFO)
160
- console_handler.setFormatter(_ConsoleFormatter('%(levelname)s: %(message)s'))
167
+ fmt = f'[{label}] %(levelname)s: %(message)s' if label else '%(levelname)s: %(message)s'
168
+ console_handler.setFormatter(_ConsoleFormatter(fmt))
161
169
  console_handler.addFilter(lambda r: r.name.startswith('TeLLMgramBot'))
162
170
 
163
171
  root_logger.addHandler(console_handler)
@@ -170,10 +178,11 @@ def init_logging():
170
178
 
171
179
  def bind_log_identity(username: str, instance_name: Optional[str] = None):
172
180
  """
173
- Bind the bot's identity to the logging system.
181
+ Bind the bot's identity to the logging system with UTC timestamps.
174
182
 
175
183
  Updates the console handler formatter to prefix every line with [instance_name] and opens
176
- the per-instance log file named {instance_name}_{timestamp}.log.
184
+ the per-instance log file named {instance_name}_{timestamp}.log. File handler uses UTC
185
+ timestamps via _RedactedFileFormatter with format [YYYY-MM-DD HH:MM:SS.mmm] LEVEL logger_name: message.
177
186
 
178
187
  Must be called after the bot's Telegram username is resolved by _tele_info().
179
188
 
@@ -206,7 +215,10 @@ def bind_log_identity(username: str, instance_name: Optional[str] = None):
206
215
  log_path = os.path.join(logs_dir, generate_filename(label))
207
216
  file_handler = logging.FileHandler(log_path, encoding='utf-8')
208
217
  file_handler.setLevel(logging.DEBUG if verbose else logging.INFO)
209
- file_handler.setFormatter(_RedactedFileFormatter('%(asctime)s %(levelname)s %(name)s: %(message)s'))
218
+ file_handler.setFormatter(_RedactedFileFormatter(
219
+ '[%(asctime)s.%(msecs)03d] %(levelname)s %(name)s: %(message)s',
220
+ datefmt='%Y-%m-%d %H:%M:%S'
221
+ ))
210
222
  root_logger.addHandler(file_handler)
211
223
 
212
224
  # Prune old log files for this bot - keep the 10 most recent
@@ -396,7 +408,8 @@ def init_bot_config(file: str = 'config.yaml') -> dict:
396
408
  Creates a basic bot configuration file in TELLMGRAMBOT_CONFIGS_PATH with default values
397
409
  and inline parameter comments. Optional parameters (those with None values in INIT_BOT_CONFIG)
398
410
  are populated with descriptions from INIT_BOT_CONFIG_COMMENTS instead of values, helping
399
- users understand when to set them.
411
+ users understand when to set them. Calls init_logging() with the instance_name from config
412
+ so the console prefix is correct from the first startup log message.
400
413
 
401
414
  Args:
402
415
  file: Name of the configuration file to create (default: 'config.yaml').
@@ -417,6 +430,7 @@ def init_bot_config(file: str = 'config.yaml') -> dict:
417
430
  config = read_yaml(
418
431
  generate_file_path(os.environ[env_var], file, "bot configuration", text)
419
432
  ) or {}
433
+ init_logging((config.get('instance_name', '') or '').strip())
420
434
  for parameter, value in INIT_BOT_CONFIG.items():
421
435
  if parameter != 'persona_prompt' and parameter not in config:
422
436
  config[parameter] = value
@@ -524,9 +538,9 @@ def init_structure(
524
538
  system appendix automatically appended).
525
539
  """
526
540
  init_directories()
527
- init_logging()
528
541
 
529
- # Configurations for bot and LLM models
542
+ # Configurations for bot and LLM models; init_logging() is called inside init_bot_config()
543
+ # once instance_name is known so the console prefix is set from the first log line.
530
544
  config = init_bot_config(config_file)
531
545
  init_models_config()
532
546
 
@@ -586,7 +600,7 @@ def init_structure(
586
600
  await init_db()
587
601
  await run_archival(config)
588
602
  except Exception:
589
- logger.error("Background startup initialization/archive task failed", exc_info=True)
603
+ logger.error(f"Background startup initialization/archive task failed", exc_info=True)
590
604
  loop.create_task(_init_and_archive())
591
605
  else:
592
606
  asyncio.run(run_archival(config))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: TeLLMgramBot
3
- Version: 3.14.0
3
+ Version: 3.14.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
@@ -83,6 +83,7 @@ TeLLMgramBot creates the following directories:
83
83
  - A system appendix is automatically appended to every persona at runtime, teaching the LLM about cross-chat memory and search behavior. User messages include speaker annotations with chat context and timestamps so the LLM always knows who is speaking, in which chat, and when.
84
84
  - **`logs`** - Bot instance logs (one per startup, named after the bot's Telegram username or `instance_name` config, e.g. `my_bot_2026-03-29_10-30-45.log`)
85
85
  - Logs include anonymized Telegram IDs for privacy. Console shows INFO-level TeLLMgramBot messages only, prefixed with an `[identity label]` (the bot's Telegram username by default, or `instance_name` when configured).
86
+ - Log file timestamps are UTC in `[YYYY-MM-DD HH:MM:SS.mmm]` format.
86
87
  - Bot keeps the 10 most recent logs per bot instance, automatically pruning older ones.
87
88
  - Pass `-v` or `--verbose` on startup for DEBUG-level logging.
88
89
  - **`data`** - SQLite database (default `conversations.db`, customizable via `instance_name` config) storing all messages, users, and chats
@@ -167,9 +168,9 @@ When the bot is triggered in a group and about to respond (not deferring to anot
167
168
  ```python
168
169
  from TeLLMgramBot import TelegramBot
169
170
  telegram_bot = TelegramBot.set()
170
- telegram_bot.start_polling()
171
+ telegram_bot.poll()
171
172
  ```
172
- Once you see `TeLLMgramBot polling...`, the bot is online.
173
+ Once you see `TeLLMgramBot polling started`, the bot is online.
173
174
  6. Type `/help` in Telegram to see all available commands.
174
175
 
175
176
  ## Resources
@@ -5,7 +5,7 @@ with open("README.md", "r") as fh:
5
5
 
6
6
  setup(
7
7
  name='TeLLMgramBot',
8
- version='3.14.0',
8
+ version='3.14.2',
9
9
  packages=find_packages(),
10
10
  license='MIT',
11
11
  author='Digital Heresy',
File without changes
File without changes