TeLLMgramBot 3.13.7__tar.gz → 3.14.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {tellmgrambot-3.13.7 → tellmgrambot-3.14.0}/PKG-INFO +2 -2
  2. {tellmgrambot-3.13.7 → tellmgrambot-3.14.0}/README.md +1 -1
  3. {tellmgrambot-3.13.7 → tellmgrambot-3.14.0}/TeLLMgramBot/TeLLMgramBot.py +133 -50
  4. {tellmgrambot-3.13.7 → tellmgrambot-3.14.0}/TeLLMgramBot/database.py +44 -5
  5. {tellmgrambot-3.13.7 → tellmgrambot-3.14.0}/TeLLMgramBot/initialize.py +1 -0
  6. {tellmgrambot-3.13.7 → tellmgrambot-3.14.0}/TeLLMgramBot/tools.py +6 -2
  7. {tellmgrambot-3.13.7 → tellmgrambot-3.14.0}/TeLLMgramBot.egg-info/PKG-INFO +2 -2
  8. {tellmgrambot-3.13.7 → tellmgrambot-3.14.0}/setup.py +1 -1
  9. {tellmgrambot-3.13.7 → tellmgrambot-3.14.0}/LICENSE +0 -0
  10. {tellmgrambot-3.13.7 → tellmgrambot-3.14.0}/TeLLMgramBot/__init__.py +0 -0
  11. {tellmgrambot-3.13.7 → tellmgrambot-3.14.0}/TeLLMgramBot/archive.py +0 -0
  12. {tellmgrambot-3.13.7 → tellmgrambot-3.14.0}/TeLLMgramBot/conversation.py +0 -0
  13. {tellmgrambot-3.13.7 → tellmgrambot-3.14.0}/TeLLMgramBot/message_handlers.py +0 -0
  14. {tellmgrambot-3.13.7 → tellmgrambot-3.14.0}/TeLLMgramBot/models.py +0 -0
  15. {tellmgrambot-3.13.7 → tellmgrambot-3.14.0}/TeLLMgramBot/providers/__init__.py +0 -0
  16. {tellmgrambot-3.13.7 → tellmgrambot-3.14.0}/TeLLMgramBot/providers/anthropic_provider.py +0 -0
  17. {tellmgrambot-3.13.7 → tellmgrambot-3.14.0}/TeLLMgramBot/providers/base.py +0 -0
  18. {tellmgrambot-3.13.7 → tellmgrambot-3.14.0}/TeLLMgramBot/providers/factory.py +0 -0
  19. {tellmgrambot-3.13.7 → tellmgrambot-3.14.0}/TeLLMgramBot/providers/openai_provider.py +0 -0
  20. {tellmgrambot-3.13.7 → tellmgrambot-3.14.0}/TeLLMgramBot/utils.py +0 -0
  21. {tellmgrambot-3.13.7 → tellmgrambot-3.14.0}/TeLLMgramBot/web_utils.py +0 -0
  22. {tellmgrambot-3.13.7 → tellmgrambot-3.14.0}/TeLLMgramBot.egg-info/SOURCES.txt +0 -0
  23. {tellmgrambot-3.13.7 → tellmgrambot-3.14.0}/TeLLMgramBot.egg-info/dependency_links.txt +0 -0
  24. {tellmgrambot-3.13.7 → tellmgrambot-3.14.0}/TeLLMgramBot.egg-info/requires.txt +0 -0
  25. {tellmgrambot-3.13.7 → tellmgrambot-3.14.0}/TeLLMgramBot.egg-info/top_level.txt +0 -0
  26. {tellmgrambot-3.13.7 → tellmgrambot-3.14.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: TeLLMgramBot
3
- Version: 3.13.7
3
+ Version: 3.14.0
4
4
  Summary: LLM-powered Telegram bot (OpenAI + Anthropic)
5
5
  Home-page: https://github.com/Digital-Heresy/TeLLMgramBot
6
6
  Author: Digital Heresy
@@ -135,7 +135,7 @@ The bot responds in groups when you:
135
135
  - Mention the bot by nickname or initials (configured via `config.yaml`)
136
136
  - Reply directly to one of the bot's messages
137
137
 
138
- If a message explicitly @mentions another account, the bot defers with "Looks like that's for @OtherBot!" instead.
138
+ When multiple bots are @mentioned in the same message, the bot coexists: if you mention the bot's nickname or initials, or reply to its message, the bot always engages (you may be intentionally addressing both bots). If the only trigger is a reply to the bot's message AND the message exclusively addresses a different bot via @mention (no mention of this bot), the bot yields silently - this supports threaded context without redundant responses.
139
139
 
140
140
  ### Private Chat Behavior
141
141
  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.
@@ -103,7 +103,7 @@ The bot responds in groups when you:
103
103
  - Mention the bot by nickname or initials (configured via `config.yaml`)
104
104
  - Reply directly to one of the bot's messages
105
105
 
106
- If a message explicitly @mentions another account, the bot defers with "Looks like that's for @OtherBot!" instead.
106
+ When multiple bots are @mentioned in the same message, the bot coexists: if you mention the bot's nickname or initials, or reply to its message, the bot always engages (you may be intentionally addressing both bots). If the only trigger is a reply to the bot's message AND the message exclusively addresses a different bot via @mention (no mention of this bot), the bot yields silently - this supports threaded context without redundant responses.
107
107
 
108
108
  ### Private Chat Behavior
109
109
  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.
@@ -27,6 +27,7 @@ from .database import (
27
27
  get_shared_group_chat_ids,
28
28
  message_id_exists,
29
29
  update_message_tg_id,
30
+ prune_bot_messages,
30
31
  search_messages,
31
32
  upsert_chat,
32
33
  upsert_user,
@@ -67,9 +68,9 @@ _SEARCH_TOOL = {
67
68
  "Search the full message history across the user's private chat and shared group chats. "
68
69
  "Results include both raw messages and archived summaries of older content. "
69
70
  "Use whenever the user asks who said something, what someone said, or what was discussed. "
70
- "Always search before claiming a person has no message history -- do not assume from context alone. "
71
- "Run the search immediately when it would help answer the question -- do not ask the user for permission to search. "
72
- "All filters are optional -- omit them to retrieve recent messages broadly. "
71
+ "Always search before claiming a person has no message history - do not assume from context alone. "
72
+ "Run the search immediately when it would help answer the question - do not ask the user for permission to search. "
73
+ "All filters are optional - omit them to retrieve recent messages broadly. "
73
74
  "Results are ordered most-recent-first by default; use ascending=true for oldest-first."
74
75
  ),
75
76
  "parameters": {
@@ -180,11 +181,13 @@ class TelegramBot:
180
181
 
181
182
  async def tele_tools_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
182
183
  """
183
- List all registered tools available to this bot instance (admin-only, private chat only).
184
+ List tools available to this bot instance (admin-only, private/group/supergroup).
184
185
 
185
186
  Shows name and description for every tool the bot exposes, including the built-in
186
187
  search_messages tool and any webhook tools defined in bot config. Restricted to
187
- bot_owner in a private chat; any other caller receives a generic denial.
188
+ usernames in bot_owner list. Works in private, group, and supergroup chats;
189
+ channels always receive an admin-only denial. In group/supergroup chats, only
190
+ tools with allow_groups=True are shown.
188
191
 
189
192
  Args:
190
193
  update: Telegram Update object containing the /tools message.
@@ -192,15 +195,15 @@ class TelegramBot:
192
195
  """
193
196
  uname = update.message.from_user.username
194
197
  chat_type = update.message.chat.type
195
- if uname not in self.telegram['owners'] or chat_type != 'private':
198
+ if uname not in self.telegram['owners'] or chat_type not in ('private', 'group', 'supergroup'):
196
199
  await update.message.reply_text(_MSG_ADMIN_ONLY)
197
200
  return
201
+
198
202
  def _first_sentence(text: str) -> str:
199
203
  return text.split('. ')[0].rstrip('.!?') + '.'
200
204
 
201
205
  lines = ["Registered tools:"]
202
- lines.append(f"- search_messages - {_first_sentence(_SEARCH_TOOL['description'])}")
203
- for schema in self.webhook_schemas:
206
+ for schema in self._build_tool_list(chat_type, uname):
204
207
  lines.append(f"- {schema['name']} - {_first_sentence(schema['description'])}")
205
208
  await update.message.reply_text('\n'.join(lines))
206
209
 
@@ -555,7 +558,12 @@ class TelegramBot:
555
558
  with speaker name and timestamp if the replied-to message is not in context, or an
556
559
  empty string if already present.
557
560
 
558
- Uses a two-tier check to avoid redundant prepends:
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:
559
567
  1. Memory - reply_to_message.message_id in conv._loaded_message_ids (O(1)).
560
568
  In-session bot replies are added here immediately after each send.
561
569
  2. DB fallback - query for tg_message_id in the messages table.
@@ -563,6 +571,8 @@ class TelegramBot:
563
571
  stored for assistant messages after each send.
564
572
 
565
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.
566
576
  Correctly re-surfaces bot messages that are no longer in context (e.g. post-/forget).
567
577
 
568
578
  Args:
@@ -574,61 +584,98 @@ class TelegramBot:
574
584
  or empty string if the replied-to message is already in context.
575
585
  """
576
586
  reply = msg.reply_to_message
577
- if not reply or not reply.text or not reply.from_user:
587
+ if not reply or not reply.text:
578
588
  return ''
579
589
 
580
590
  r_tg_id = reply.message_id
581
-
582
- # Tier 1: memory check
583
591
  conv = self.conversations.get(msg.chat.id)
584
- if conv and r_tg_id in conv._loaded_message_ids:
585
- return ''
586
-
587
- # Tier 2: DB fallback
588
- if await message_id_exists(msg.chat.id, r_tg_id):
589
- return ''
592
+ is_our_reply = (
593
+ reply.from_user is not None and
594
+ reply.from_user.id == self.telegram['bot_id']
595
+ )
590
596
 
591
- # Not in context - build the annotated prefix and mark the ID as seen so
592
- # subsequent replies to the same foreign message skip the prepend.
593
- if conv:
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:
594
610
  conv._loaded_message_ids.add(r_tg_id)
595
- sender = reply.from_user
596
- name_parts = [p for p in [sender.first_name, sender.last_name] if p]
597
- name = ' '.join(name_parts) if name_parts else (sender.username or 'unknown')
611
+ if reply.from_user:
612
+ sender = reply.from_user
613
+ name_parts = [p for p in [sender.first_name, sender.last_name] if p]
614
+ name = ' '.join(name_parts) if name_parts else (sender.username or 'unknown')
615
+ elif reply.sender_chat:
616
+ name = reply.sender_chat.title or reply.sender_chat.username or 'unknown'
617
+ else:
618
+ name = 'unknown'
598
619
  dt = format_dt(reply.date)
599
620
  return f"[Replying to {name}, {dt}]: {reply.text}\n"
600
621
 
601
- def _foreign_bot_mention(self, msg: Message) -> str | None:
622
+ async def _store_bot_message(self, msg: Message) -> None:
602
623
  """
603
- Return the @username of the first explicitly @mentioned account that is not us, or None.
624
+ Persist a foreign bot's message to the DB for ambient group context.
604
625
 
605
- Inspects MessageEntity.MENTION entities in msg.entities. Used to detect when a
606
- reply-to-bot message explicitly @mentions another account (bot or user), so we can
607
- defer politely instead of responding as if the message was meant for us. Note that
608
- Telegram does not distinguish bots from regular users in MENTION entities; this
609
- method detects any foreign @mention. Deflections skip the read receipt -- the 👀
610
- reaction is only sent when we are going to generate a full response.
626
+ 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).
611
629
 
612
- Uses msg.parse_entity() for correct UTF-16 offset handling (Telegram offsets are
613
- UTF-16 code units, which can differ from Python str indices for non-BMP characters
614
- such as emoji appearing before the @mention).
630
+ Args:
631
+ msg: The Telegram Message from a foreign bot (msg.from_user.is_bot must be True).
632
+ """
633
+ if not msg.text:
634
+ return
635
+ bot_user = msg.from_user
636
+ await upsert_user(bot_user.id, bot_user.username, bot_user.first_name, bot_user.last_name, is_bot=True)
637
+ await upsert_chat(msg.chat.id, msg.chat.type, msg.chat.title or msg.chat.username or '')
638
+ existing = await message_id_exists(msg.chat.id, msg.message_id)
639
+ if not existing:
640
+ await insert_message(
641
+ chat_id=msg.chat.id,
642
+ user_id=bot_user.id,
643
+ role='user',
644
+ content=msg.text,
645
+ is_private=False,
646
+ tg_message_id=msg.message_id,
647
+ )
648
+ await prune_bot_messages(msg.chat.id)
649
+
650
+ def _exclusive_foreign_mention(self, msg: Message) -> str | None:
651
+ """
652
+ Return the first foreign @mention if all @mention entities are exclusively foreign.
653
+
654
+ Used in the reply-to-bot path: when the user threads Kowi's message but addresses
655
+ a different account via @mention, return that account's username so the caller can
656
+ detect a redirect. Returns None when we are also @mentioned (co-mention),
657
+ when there are no @mention entities, or when msg.entities is absent.
615
658
 
616
659
  Args:
617
660
  msg: The incoming Telegram Message object.
618
661
 
619
662
  Returns:
620
- The @username string (with leading @) of the first foreign mention, or None if
621
- the message contains no @mentions of other accounts.
663
+ The first foreign @username string (with leading @) if all @mentions are
664
+ foreign, or None if we are co-mentioned or no @mention entities exist.
622
665
  """
623
666
  if not msg.entities:
624
667
  return None
625
668
  our_username = self.telegram['username'].lower()
669
+ first_foreign = None
626
670
  for entity in msg.entities:
627
671
  if entity.type == MessageEntity.MENTION:
628
672
  mentioned = msg.parse_entity(entity)
629
- if mentioned.lstrip('@').lower() != our_username:
630
- return mentioned
631
- return None
673
+ if mentioned.lstrip('@').lower() == our_username:
674
+ return None
675
+ if first_foreign is None:
676
+ first_foreign = mentioned
677
+ return first_foreign
678
+
632
679
 
633
680
  async def _send_read_receipt(self, msg: Message, context: ContextTypes.DEFAULT_TYPE):
634
681
  """
@@ -662,6 +709,11 @@ class TelegramBot:
662
709
  - User mentions the bot by initials (set via config)
663
710
  - User directly replies to one of the bot's messages (Telegram reply-to feature)
664
711
 
712
+ Path A - foreign bot message (standalone update): Stores the message for ambient context and returns without LLM response.
713
+ 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.
715
+ Reply-to-bot trigger yields silently when the message is exclusively addressed to a foreign @account.
716
+
665
717
  In private chats, the bot responds to all messages (Telegram does not deliver bot-to-bot DMs).
666
718
 
667
719
  When the triggering message is itself a reply to a non-bot message not already in context,
@@ -680,10 +732,23 @@ class TelegramBot:
680
732
  return
681
733
  (msg, chat, user) = validated
682
734
 
683
- # Ignore messages from other bots in group chats - no response or storage needed.
735
+ # Path A: foreign bot message delivered directly as a standalone update (rare).
736
+ # Store for ambient context and return - no LLM response triggered by bot messages.
684
737
  if user.is_bot and user.id != self.telegram['bot_id'] and chat.type in ('group', 'supergroup'):
738
+ await self._store_bot_message(msg)
685
739
  return
686
740
 
741
+ # Path B: capture a foreign bot's message when a human replies to it.
742
+ # Fires regardless of whether the reply also triggers us.
743
+ if (
744
+ chat.type in ('group', 'supergroup') and
745
+ msg.reply_to_message is not None and
746
+ msg.reply_to_message.from_user is not None and
747
+ msg.reply_to_message.from_user.is_bot and
748
+ msg.reply_to_message.from_user.id != self.telegram['bot_id']
749
+ ):
750
+ await self._store_bot_message(msg.reply_to_message)
751
+
687
752
  # If it's a group text, only reply if the bot is named
688
753
  # The real magic of how the bot behaves is in tele_handle_response()
689
754
  response = _MSG_PROCESS_ERROR
@@ -697,7 +762,7 @@ class TelegramBot:
697
762
  )
698
763
  if exact_word_match(self.telegram['username'], msg.text):
699
764
  # Explicit @username mention: strongest signal - respond even if another
700
- # account is also @mentioned (both bots may be intentionally addressed).
765
+ # bot is also @mentioned (both may be intentionally addressed).
701
766
  pattern = r'@?\b' + re.escape(self.telegram['username']) + r'\b'
702
767
  new_text = re.sub(pattern, '', msg.text).strip()
703
768
  reply_context = await self._build_reply_context(msg)
@@ -705,14 +770,17 @@ class TelegramBot:
705
770
  response, assistant_db_id = await self.tele_handle_response(new_text, msg, reply_context)
706
771
  elif (
707
772
  exact_word_match(self.telegram['nickname'], msg.text) or
708
- exact_word_match(self.telegram['initials'], msg.text) or
709
- is_reply_to_bot
773
+ exact_word_match(self.telegram['initials'], msg.text)
710
774
  ):
711
- # Weaker trigger (nickname, initials, or reply): check for a foreign
712
- # @mention first. If present, the message is likely for another account.
713
- foreign = self._foreign_bot_mention(msg)
714
- if foreign:
715
- await msg.reply_text(f"Looks like that message is for {foreign}!")
775
+ # Nickname/initials: always engage - no reliable way to distinguish
776
+ # our name as addressee vs topic from text position alone.
777
+ reply_context = await self._build_reply_context(msg)
778
+ await self._send_read_receipt(msg, context)
779
+ response, assistant_db_id = await self.tele_handle_response(msg.text, msg, reply_context)
780
+ elif is_reply_to_bot:
781
+ # Reply-to-bot: weaker signal - yield silently if the message is
782
+ # exclusively addressed to a foreign account via @mention.
783
+ if self._exclusive_foreign_mention(msg):
716
784
  return
717
785
  reply_context = await self._build_reply_context(msg)
718
786
  await self._send_read_receipt(msg, context)
@@ -892,7 +960,7 @@ class TelegramBot:
892
960
  Builds an ephemeral message slice that appends a placeholder assistant acknowledgement
893
961
  and a user message containing the tool results to conv.messages, then calls the
894
962
  provider for a final natural-language answer. The ephemeral messages are not stored
895
- in conv.messages -- only the final answer is persisted by the caller.
963
+ in conv.messages - only the final answer is persisted by the caller.
896
964
 
897
965
  Args:
898
966
  tool_call: The ToolCall returned by the first LLM call.
@@ -1088,6 +1156,21 @@ class TelegramBot:
1088
1156
  self.llm['prune_threshold'] = floor(0.95 * self.llm['token_limit'])
1089
1157
  self.llm['prune_back_to'] = max(0, self.llm['prune_threshold'] - 500)
1090
1158
 
1159
+ # Patch identity placeholders now that both self.llm and self.telegram are set.
1160
+ # _tele_info() has already populated username, nickname, and initials.
1161
+ identity_line = (
1162
+ f"\n- Your Telegram username is @{self.telegram['username']}, "
1163
+ f"nickname '{self.telegram['nickname']}', "
1164
+ f"initials '{self.telegram['initials']}'. "
1165
+ "Users in group chats may address you by any of these. "
1166
+ "Do not sign or close your messages with your name, initials, or username."
1167
+ )
1168
+ self.llm['prompt'] = re.sub(
1169
+ r'\n-?\s*Your Telegram username is.*',
1170
+ identity_line,
1171
+ self.llm['prompt'],
1172
+ )
1173
+
1091
1174
  # Bot is now ready and active by default
1092
1175
  self._online = True
1093
1176
 
@@ -47,6 +47,7 @@ CREATE TABLE IF NOT EXISTS users (
47
47
  username TEXT,
48
48
  first_name TEXT,
49
49
  last_name TEXT,
50
+ is_bot INTEGER NOT NULL DEFAULT 0,
50
51
  private_mode INTEGER NOT NULL DEFAULT 0,
51
52
  previous_names TEXT,
52
53
  updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
@@ -117,7 +118,7 @@ async def init_db() -> None:
117
118
  Migrations applied to existing databases:
118
119
  messages - adds reply_to_id, tg_message_id, archived_at; drops username, is_foreign_bot.
119
120
  chats - adds chat_type, previous_titles.
120
- users - adds private_mode, previous_names; merges and drops legacy private_mode table.
121
+ users - adds private_mode, previous_names, is_bot; merges and drops legacy private_mode table.
121
122
  summary_archive - created (tier 1 and 2 conversation summaries).
122
123
  """
123
124
  async with aiosqlite.connect(get_db_path()) as db:
@@ -158,6 +159,8 @@ async def init_db() -> None:
158
159
  await db.execute("ALTER TABLE users ADD COLUMN private_mode INTEGER NOT NULL DEFAULT 0")
159
160
  if 'previous_names' not in users_cols:
160
161
  await db.execute("ALTER TABLE users ADD COLUMN previous_names TEXT")
162
+ if 'is_bot' not in users_cols:
163
+ await db.execute("ALTER TABLE users ADD COLUMN is_bot INTEGER NOT NULL DEFAULT 0")
161
164
  tables = {row[0] async for row in await db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
162
165
  if 'private_mode' in tables:
163
166
  await db.execute(
@@ -207,6 +210,40 @@ async def insert_message(
207
210
  return cursor.lastrowid
208
211
 
209
212
 
213
+ async def prune_bot_messages(chat_id: int, cap: int = 50) -> None:
214
+ """
215
+ Delete the oldest bot-authored rows for a chat when the per-chat cap is exceeded.
216
+
217
+ Called after every bot message insert to keep ambient peer bot context from growing
218
+ unboundedly. Deletes rows where the sender is marked is_bot=1 in the users table,
219
+ oldest first, until at most `cap` such rows remain for the chat.
220
+
221
+ Args:
222
+ chat_id: Telegram chat ID to prune.
223
+ cap: Maximum number of bot message rows to retain per chat.
224
+ """
225
+ async with aiosqlite.connect(get_db_path()) as db:
226
+ async with db.execute(
227
+ "SELECT COUNT(*) FROM messages m "
228
+ "JOIN users u ON m.user_id = u.user_id "
229
+ "WHERE m.chat_id = ? AND u.is_bot = 1",
230
+ (chat_id,),
231
+ ) as cur:
232
+ (count,) = await cur.fetchone()
233
+ if count > cap:
234
+ excess = count - cap
235
+ await db.execute(
236
+ "DELETE FROM messages WHERE id IN ("
237
+ " SELECT m.id FROM messages m "
238
+ " JOIN users u ON m.user_id = u.user_id "
239
+ " WHERE m.chat_id = ? AND u.is_bot = 1 "
240
+ " ORDER BY m.created_at ASC, m.id ASC LIMIT ?"
241
+ ")",
242
+ (chat_id, excess),
243
+ )
244
+ await db.commit()
245
+
246
+
210
247
  async def update_message_tg_id(db_id: int, tg_message_id: int) -> None:
211
248
  """
212
249
  Store the Telegram message ID on an assistant row after send.
@@ -603,6 +640,7 @@ async def upsert_user(
603
640
  username: Optional[str],
604
641
  first_name: Optional[str],
605
642
  last_name: Optional[str],
643
+ is_bot: bool = False,
606
644
  ) -> None:
607
645
  """
608
646
  Insert or update a user record. Called on every interaction to capture name changes.
@@ -615,6 +653,7 @@ async def upsert_user(
615
653
  username: @handle without @, may be None.
616
654
  first_name: Telegram first name, may be None.
617
655
  last_name: Telegram last name, may be None.
656
+ is_bot: True for Telegram bot accounts; stored so context loaders can label them.
618
657
  """
619
658
  async with aiosqlite.connect(get_db_path()) as db:
620
659
  async with db.execute(
@@ -645,12 +684,12 @@ async def upsert_user(
645
684
  updated_prev = prev
646
685
 
647
686
  await db.execute(
648
- "INSERT INTO users (user_id, username, first_name, last_name, previous_names) VALUES (?, ?, ?, ?, ?) "
687
+ "INSERT INTO users (user_id, username, first_name, last_name, is_bot, previous_names) VALUES (?, ?, ?, ?, ?, ?) "
649
688
  "ON CONFLICT(user_id) DO UPDATE SET "
650
689
  "username = excluded.username, first_name = excluded.first_name, "
651
- "last_name = excluded.last_name, previous_names = excluded.previous_names, "
652
- "updated_at = ?",
653
- (user_id, username, first_name, last_name, updated_prev, now_iso()),
690
+ "last_name = excluded.last_name, is_bot = excluded.is_bot, "
691
+ "previous_names = excluded.previous_names, updated_at = ?",
692
+ (user_id, username, first_name, last_name, int(is_bot), updated_prev, now_iso()),
654
693
  )
655
694
  await db.commit()
656
695
 
@@ -127,6 +127,7 @@ INIT_BOT_CONFIG_COMMENTS = {
127
127
  # Teaches an LLM how cross-chat memory works without requiring persona authors to include it.
128
128
  _SYSTEM_APPENDIX = (
129
129
  "## 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"
130
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"
131
132
  "- Always invoke the search tool before concluding a message does not exist.\n"
132
133
  "- Messages marked private are never shared between chats.\n"
@@ -11,7 +11,7 @@ import logging
11
11
  import os
12
12
  import re
13
13
  import socket
14
- from urllib.parse import quote, urlparse, urlunparse
14
+ from urllib.parse import parse_qs, quote, urlparse, urlunparse
15
15
 
16
16
  import httpx
17
17
 
@@ -591,7 +591,11 @@ async def execute_webhook(tool_def: dict, arguments: dict) -> str:
591
591
  try:
592
592
  async with httpx.AsyncClient(timeout=15.0) as client:
593
593
  if method == 'GET':
594
- resp = await client.get(url, params=args if args else None, headers=headers)
594
+ # Merge any static query params already in the URL (e.g. ?appid=KEY) with runtime args -
595
+ # httpx replaces the query string when 'params=' is passed separately, dropping static ones.
596
+ url_params = {k: v[0] for k, v in parse_qs(parsed.query).items()}
597
+ merged = {**url_params, **args}
598
+ resp = await client.get(base_url, params=merged if merged else None, headers=headers)
595
599
  else:
596
600
  resp = await client.request(method, url, json=args if args else None, headers=headers)
597
601
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: TeLLMgramBot
3
- Version: 3.13.7
3
+ Version: 3.14.0
4
4
  Summary: LLM-powered Telegram bot (OpenAI + Anthropic)
5
5
  Home-page: https://github.com/Digital-Heresy/TeLLMgramBot
6
6
  Author: Digital Heresy
@@ -135,7 +135,7 @@ The bot responds in groups when you:
135
135
  - Mention the bot by nickname or initials (configured via `config.yaml`)
136
136
  - Reply directly to one of the bot's messages
137
137
 
138
- If a message explicitly @mentions another account, the bot defers with "Looks like that's for @OtherBot!" instead.
138
+ When multiple bots are @mentioned in the same message, the bot coexists: if you mention the bot's nickname or initials, or reply to its message, the bot always engages (you may be intentionally addressing both bots). If the only trigger is a reply to the bot's message AND the message exclusively addresses a different bot via @mention (no mention of this bot), the bot yields silently - this supports threaded context without redundant responses.
139
139
 
140
140
  ### Private Chat Behavior
141
141
  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.
@@ -5,7 +5,7 @@ with open("README.md", "r") as fh:
5
5
 
6
6
  setup(
7
7
  name='TeLLMgramBot',
8
- version='3.13.7',
8
+ version='3.14.0',
9
9
  packages=find_packages(),
10
10
  license='MIT',
11
11
  author='Digital Heresy',
File without changes
File without changes