TeLLMgramBot 3.0.4__tar.gz → 3.2.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.
- {tellmgrambot-3.0.4 → tellmgrambot-3.2.0}/PKG-INFO +5 -5
- {tellmgrambot-3.0.4 → tellmgrambot-3.2.0}/README.md +4 -4
- {tellmgrambot-3.0.4 → tellmgrambot-3.2.0}/TeLLMgramBot/TeLLMgramBot.py +65 -19
- tellmgrambot-3.2.0/TeLLMgramBot/conversation.py +325 -0
- {tellmgrambot-3.0.4 → tellmgrambot-3.2.0}/TeLLMgramBot/database.py +167 -8
- {tellmgrambot-3.0.4 → tellmgrambot-3.2.0}/TeLLMgramBot.egg-info/PKG-INFO +5 -5
- {tellmgrambot-3.0.4 → tellmgrambot-3.2.0}/setup.py +1 -1
- tellmgrambot-3.0.4/TeLLMgramBot/conversation.py +0 -196
- {tellmgrambot-3.0.4 → tellmgrambot-3.2.0}/LICENSE +0 -0
- {tellmgrambot-3.0.4 → tellmgrambot-3.2.0}/TeLLMgramBot/__init__.py +0 -0
- {tellmgrambot-3.0.4 → tellmgrambot-3.2.0}/TeLLMgramBot/initialize.py +0 -0
- {tellmgrambot-3.0.4 → tellmgrambot-3.2.0}/TeLLMgramBot/message_handlers.py +0 -0
- {tellmgrambot-3.0.4 → tellmgrambot-3.2.0}/TeLLMgramBot/models.py +0 -0
- {tellmgrambot-3.0.4 → tellmgrambot-3.2.0}/TeLLMgramBot/providers/__init__.py +0 -0
- {tellmgrambot-3.0.4 → tellmgrambot-3.2.0}/TeLLMgramBot/providers/anthropic_provider.py +0 -0
- {tellmgrambot-3.0.4 → tellmgrambot-3.2.0}/TeLLMgramBot/providers/base.py +0 -0
- {tellmgrambot-3.0.4 → tellmgrambot-3.2.0}/TeLLMgramBot/providers/factory.py +0 -0
- {tellmgrambot-3.0.4 → tellmgrambot-3.2.0}/TeLLMgramBot/providers/openai_provider.py +0 -0
- {tellmgrambot-3.0.4 → tellmgrambot-3.2.0}/TeLLMgramBot/utils.py +0 -0
- {tellmgrambot-3.0.4 → tellmgrambot-3.2.0}/TeLLMgramBot/web_utils.py +0 -0
- {tellmgrambot-3.0.4 → tellmgrambot-3.2.0}/TeLLMgramBot.egg-info/SOURCES.txt +0 -0
- {tellmgrambot-3.0.4 → tellmgrambot-3.2.0}/TeLLMgramBot.egg-info/dependency_links.txt +0 -0
- {tellmgrambot-3.0.4 → tellmgrambot-3.2.0}/TeLLMgramBot.egg-info/requires.txt +0 -0
- {tellmgrambot-3.0.4 → tellmgrambot-3.2.0}/TeLLMgramBot.egg-info/top_level.txt +0 -0
- {tellmgrambot-3.0.4 → tellmgrambot-3.2.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: TeLLMgramBot
|
|
3
|
-
Version: 3.0
|
|
3
|
+
Version: 3.2.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
|
|
@@ -43,10 +43,10 @@ The basic goal of this project is to create a bridge between a Telegram Bot and
|
|
|
43
43
|
* This uses a separate model (configurable via `url_model`) to support more URL content with its higher token limit.
|
|
44
44
|
* Tokens are used to measure the length of all conversation messages between the Telegram bot assistant and the user. This is useful to:
|
|
45
45
|
* Ensure the length does not go over the model limit. If it does, prune oldest messages to fit within the limit.
|
|
46
|
-
* Remember past conversations when restarting: loads the user's full history across all chats (private and groups) plus all other participants' messages in the current chat, up to 50% of the token budget. This eliminates amnesia when users switch between contexts.
|
|
46
|
+
* Remember past conversations when restarting: loads the user's full history across all chats (private and groups) plus all other participants' messages in the current chat, up to 50% of the token budget. In private chats, shared group context (messages from groups where both user and bot are active) fills the remaining budget, enabling the bot to reference group conversations from a private context. This eliminates amnesia when users switch between contexts.
|
|
47
47
|
* Users can manage privacy via two commands:
|
|
48
|
-
* `/forget` — In private chats, clears the full conversation (including bot replies). In group chats, removes
|
|
49
|
-
* `/private` — Toggles per-user private mode (private chats only). When ON, messages are excluded from group conversation contexts, enabling selective privacy even in shared groups.
|
|
48
|
+
* `/forget` — In private chats, clears the full conversation (including bot replies) and resets all of your active sessions (any sessions where your context was merged). In group chats, removes your messages across all chat types; other participants' messages and sessions remain.
|
|
49
|
+
* `/private` — Toggles per-user private mode (private chats only). When ON, messages are excluded from group conversation contexts, enabling selective privacy even in shared groups. If you have private mode ON and message a group, the bot will remind you once per session.
|
|
50
50
|
|
|
51
51
|
## Why Telegram?
|
|
52
52
|
Using Telegram as the interface not only solves "exposing" the interface, but gives you boatloads of interactivity over a standard Command Line interface, or trying to create a website with input boxes and submit buttons to try to handle everything:
|
|
@@ -87,7 +87,7 @@ When initializing TeLLMgramBot, the following directories get created:
|
|
|
87
87
|
* User will also get notified to contact the owner.
|
|
88
88
|
* `data`
|
|
89
89
|
* Contains `conversations.db` — a SQLite database storing all conversations between the bot and users across all chats.
|
|
90
|
-
* When a user messages in any chat, their full history is available for context: private messages appear in group contexts, group messages appear in private contexts. This creates seamless cross-context awareness.
|
|
90
|
+
* When a user messages in any chat, their full history is available for context: private messages appear in group contexts, group messages appear in private contexts. This creates seamless cross-context awareness. The bot dynamically refreshes each user's context during a session to pick up messages sent between chats. In private chats, the bot also loads shared group context (messages from groups where both user and bot are active) to provide group awareness.
|
|
91
91
|
* Users can manage their context via `/forget` (private chat: clears full conversation; group chat: removes only your messages) or `/private` (toggles per-user privacy for group contexts).
|
|
92
92
|
|
|
93
93
|
### Environment Variables
|
|
@@ -12,10 +12,10 @@ The basic goal of this project is to create a bridge between a Telegram Bot and
|
|
|
12
12
|
* This uses a separate model (configurable via `url_model`) to support more URL content with its higher token limit.
|
|
13
13
|
* Tokens are used to measure the length of all conversation messages between the Telegram bot assistant and the user. This is useful to:
|
|
14
14
|
* Ensure the length does not go over the model limit. If it does, prune oldest messages to fit within the limit.
|
|
15
|
-
* Remember past conversations when restarting: loads the user's full history across all chats (private and groups) plus all other participants' messages in the current chat, up to 50% of the token budget. This eliminates amnesia when users switch between contexts.
|
|
15
|
+
* Remember past conversations when restarting: loads the user's full history across all chats (private and groups) plus all other participants' messages in the current chat, up to 50% of the token budget. In private chats, shared group context (messages from groups where both user and bot are active) fills the remaining budget, enabling the bot to reference group conversations from a private context. This eliminates amnesia when users switch between contexts.
|
|
16
16
|
* Users can manage privacy via two commands:
|
|
17
|
-
* `/forget` — In private chats, clears the full conversation (including bot replies). In group chats, removes
|
|
18
|
-
* `/private` — Toggles per-user private mode (private chats only). When ON, messages are excluded from group conversation contexts, enabling selective privacy even in shared groups.
|
|
17
|
+
* `/forget` — In private chats, clears the full conversation (including bot replies) and resets all of your active sessions (any sessions where your context was merged). In group chats, removes your messages across all chat types; other participants' messages and sessions remain.
|
|
18
|
+
* `/private` — Toggles per-user private mode (private chats only). When ON, messages are excluded from group conversation contexts, enabling selective privacy even in shared groups. If you have private mode ON and message a group, the bot will remind you once per session.
|
|
19
19
|
|
|
20
20
|
## Why Telegram?
|
|
21
21
|
Using Telegram as the interface not only solves "exposing" the interface, but gives you boatloads of interactivity over a standard Command Line interface, or trying to create a website with input boxes and submit buttons to try to handle everything:
|
|
@@ -56,7 +56,7 @@ When initializing TeLLMgramBot, the following directories get created:
|
|
|
56
56
|
* User will also get notified to contact the owner.
|
|
57
57
|
* `data`
|
|
58
58
|
* Contains `conversations.db` — a SQLite database storing all conversations between the bot and users across all chats.
|
|
59
|
-
* When a user messages in any chat, their full history is available for context: private messages appear in group contexts, group messages appear in private contexts. This creates seamless cross-context awareness.
|
|
59
|
+
* When a user messages in any chat, their full history is available for context: private messages appear in group contexts, group messages appear in private contexts. This creates seamless cross-context awareness. The bot dynamically refreshes each user's context during a session to pick up messages sent between chats. In private chats, the bot also loads shared group context (messages from groups where both user and bot are active) to provide group awareness.
|
|
60
60
|
* Users can manage their context via `/forget` (private chat: clears full conversation; group chat: removes only your messages) or `/private` (toggles per-user privacy for group contexts).
|
|
61
61
|
|
|
62
62
|
### Environment Variables
|
|
@@ -92,21 +92,38 @@ class TelegramBot:
|
|
|
92
92
|
"""
|
|
93
93
|
Remove conversation history from the database (behavior depends on chat type).
|
|
94
94
|
|
|
95
|
-
In private chats: deletes all messages in the chat (including bot replies)
|
|
96
|
-
In group chats: deletes
|
|
97
|
-
|
|
95
|
+
In private chats: deletes all messages in the private chat (including bot replies).
|
|
96
|
+
In group chats: deletes the user's rows across all chats (private included).
|
|
97
|
+
|
|
98
|
+
In both cases, evicts only the in-memory Conversation objects that contain this
|
|
99
|
+
user's data — scoped to the current chat, the user's private chat, and any other
|
|
100
|
+
chats where their context was previously merged. Other users' active sessions are
|
|
101
|
+
not affected. All private-mode notices for this user are also cleared.
|
|
98
102
|
"""
|
|
99
103
|
user_id = update.message.from_user.id
|
|
100
104
|
chat_id = update.message.chat.id
|
|
101
105
|
chat_type = update.message.chat.type
|
|
102
|
-
|
|
103
|
-
#
|
|
106
|
+
|
|
107
|
+
# Find all chat_ids where this user's context has been merged in memory,
|
|
108
|
+
# identified by user_id being present in the Conversation's _context_cursor.
|
|
109
|
+
user_chat_ids = {
|
|
110
|
+
gid for gid, conv in self.conversations.items() if user_id in conv._context_cursor
|
|
111
|
+
}
|
|
112
|
+
# Always include the private chat (chat_id == user_id in Telegram) and current chat.
|
|
113
|
+
user_chat_ids.update({chat_id, user_id})
|
|
114
|
+
|
|
104
115
|
if chat_type == 'private':
|
|
105
116
|
await delete_messages_for_chat(chat_id)
|
|
106
117
|
else:
|
|
107
118
|
await delete_messages_for_user(user_id)
|
|
108
|
-
|
|
109
|
-
|
|
119
|
+
|
|
120
|
+
# Evict only the Conversations that contain this user's data, not all sessions.
|
|
121
|
+
for evict_id in user_chat_ids:
|
|
122
|
+
self.conversations.pop(evict_id, None)
|
|
123
|
+
self.token_warning.pop(evict_id, None)
|
|
124
|
+
# Clear all private-mode notices for this user across all chats.
|
|
125
|
+
self._private_mode_warned = {k for k in self._private_mode_warned if k[1] != user_id}
|
|
126
|
+
|
|
110
127
|
await update.message.reply_text("My memories of our conversations are wiped!")
|
|
111
128
|
|
|
112
129
|
async def tele_private_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|
@@ -193,7 +210,7 @@ class TelegramBot:
|
|
|
193
210
|
chat_type = msg.chat.type
|
|
194
211
|
print(f"User {user_id} ({username}) in {chat_type} Chat {chat_id}")
|
|
195
212
|
|
|
196
|
-
# For a new session, create a Conversation keyed by chat_id
|
|
213
|
+
# For a new session, create a Conversation keyed by chat_id
|
|
197
214
|
if chat_id not in self.conversations:
|
|
198
215
|
self.conversations[chat_id] = Conversation(
|
|
199
216
|
chat_id,
|
|
@@ -201,14 +218,37 @@ class TelegramBot:
|
|
|
201
218
|
self.chatgpt['prompt'],
|
|
202
219
|
self.chatgpt['chat_model']
|
|
203
220
|
)
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
221
|
+
|
|
222
|
+
conv = self.conversations[chat_id]
|
|
223
|
+
token_budget = floor(self.chatgpt['prune_threshold'] / 2)
|
|
224
|
+
|
|
225
|
+
if user_id not in conv._context_cursor:
|
|
226
|
+
# First appearance of this user in this session — load their cross-chat history.
|
|
227
|
+
# Pass bot_id so private chats can also load shared group context.
|
|
228
|
+
# If no history exists yet, the cursor stays unset so the next message retries.
|
|
229
|
+
await conv.get_past_interaction(token_budget, user_id, self.telegram['bot_id'])
|
|
230
|
+
else:
|
|
231
|
+
# Already loaded — check for new cross-chat messages since last load.
|
|
232
|
+
await conv.refresh_user_context(user_id, token_budget)
|
|
207
233
|
|
|
208
234
|
# Add the user's message to our conversation, respecting private mode
|
|
209
235
|
# Private mode only applies in private chats — group messages are never flagged private
|
|
210
|
-
|
|
211
|
-
|
|
236
|
+
user_private_mode = await get_private_mode(user_id)
|
|
237
|
+
is_private = (chat_type == 'private') and user_private_mode
|
|
238
|
+
await conv.add_user_message(text, user_id, username, is_private)
|
|
239
|
+
|
|
240
|
+
# Warn once per group session if the user has private mode ON.
|
|
241
|
+
# Their private-chat messages are excluded from this group's context, which can be
|
|
242
|
+
# surprising. The notice is appended to the first reply in the session for this user.
|
|
243
|
+
private_mode_notice = ""
|
|
244
|
+
if chat_type in ('group', 'supergroup') and user_private_mode:
|
|
245
|
+
warn_key = (chat_id, user_id)
|
|
246
|
+
if warn_key not in self._private_mode_warned:
|
|
247
|
+
self._private_mode_warned.add(warn_key)
|
|
248
|
+
private_mode_notice = (
|
|
249
|
+
"\n\n(Note: your private mode is ON — messages from our private chat "
|
|
250
|
+
"are not included in this group's context. To disable, send /private off in our DMs.)"
|
|
251
|
+
)
|
|
212
252
|
|
|
213
253
|
# Check if the user is asking about a [URL]
|
|
214
254
|
url_match = re.search(r'\[http(s)?://\S+]', text)
|
|
@@ -226,8 +266,11 @@ class TelegramBot:
|
|
|
226
266
|
# This is the transition point between quick Telegram replies and the LLM
|
|
227
267
|
reply = await self.llm_completion(chat_id)
|
|
228
268
|
|
|
269
|
+
if private_mode_notice:
|
|
270
|
+
reply += private_mode_notice
|
|
271
|
+
|
|
229
272
|
# Check token count before storing to determine if warning should be appended
|
|
230
|
-
token_count = await
|
|
273
|
+
token_count = await conv.get_message_token_count()
|
|
231
274
|
if token_count > self.chatgpt['prune_back_to'] and chat_id not in self.token_warning:
|
|
232
275
|
reply += ("\n\n"
|
|
233
276
|
"By the way, our conversation will soon reach my token limit, so I may"
|
|
@@ -236,15 +279,17 @@ class TelegramBot:
|
|
|
236
279
|
)
|
|
237
280
|
self.token_warning[chat_id] = True
|
|
238
281
|
|
|
239
|
-
# Add the full reply (including any warning) to the conversation
|
|
240
|
-
|
|
241
|
-
|
|
282
|
+
# Add the full reply (including any warning) to the conversation.
|
|
283
|
+
# Propagate is_private so bot replies in private-mode exchanges are also excluded
|
|
284
|
+
# from group context loading — otherwise the assistant's half of the conversation leaks.
|
|
285
|
+
await conv.add_assistant_message(
|
|
286
|
+
reply, self.telegram['bot_id'], self.telegram['username'], is_private
|
|
242
287
|
)
|
|
243
288
|
|
|
244
289
|
# Recompute token count after full exchange is stored, then prune if needed
|
|
245
|
-
token_count = await
|
|
290
|
+
token_count = await conv.get_message_token_count()
|
|
246
291
|
if token_count > self.chatgpt['prune_threshold']:
|
|
247
|
-
await
|
|
292
|
+
await conv.prune_conversation(self.chatgpt['prune_back_to'])
|
|
248
293
|
|
|
249
294
|
return reply
|
|
250
295
|
|
|
@@ -396,6 +441,7 @@ class TelegramBot:
|
|
|
396
441
|
self.error_log = generate_error_path()
|
|
397
442
|
self.token_warning = {} # Determines whether user has reached token limit by AI model
|
|
398
443
|
self.conversations = {} # Provides Conversation class per user based on bot response
|
|
444
|
+
self._private_mode_warned = set() # (chat_id, user_id) pairs that received the private mode notice
|
|
399
445
|
self.telegram = {
|
|
400
446
|
'bot_id' : 0, # overwritten by _tele_info(); 0 is a safe sentinel
|
|
401
447
|
'owner' : bot_owner,
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Manages conversation history, token counting, and SQLite-backed persistence for any LLM provider.
|
|
3
|
+
|
|
4
|
+
The Conversation class is keyed by chat_id (Telegram chat ID) and maintains an in-memory message
|
|
5
|
+
history, delegating persistence to the database module. Supports async token counting via provider-
|
|
6
|
+
specific APIs and automatic pruning when token limits are approached.
|
|
7
|
+
"""
|
|
8
|
+
from .database import (
|
|
9
|
+
insert_message,
|
|
10
|
+
load_full_user_context,
|
|
11
|
+
get_max_cross_chat_message_id,
|
|
12
|
+
load_new_cross_chat_context,
|
|
13
|
+
get_shared_group_chat_ids,
|
|
14
|
+
load_shared_group_context,
|
|
15
|
+
delete_messages_for_user,
|
|
16
|
+
delete_messages_for_chat,
|
|
17
|
+
)
|
|
18
|
+
from .models import TokenLimits
|
|
19
|
+
from .utils import generate_error_path, log_error
|
|
20
|
+
|
|
21
|
+
# Minimum remaining token headroom required before loading shared group context
|
|
22
|
+
# in a private chat. Prevents loading group history when the private context
|
|
23
|
+
# already fills nearly the entire budget.
|
|
24
|
+
MIN_GROUP_CONTEXT_TOKENS = 500
|
|
25
|
+
|
|
26
|
+
class Conversation:
|
|
27
|
+
"""
|
|
28
|
+
Manages a chat conversation including message history, token counting, and SQLite persistence.
|
|
29
|
+
|
|
30
|
+
Keyed by chat_id (Telegram chat ID): negative for groups, positive (== user_id) for private chats.
|
|
31
|
+
Works with any LLM provider (OpenAI, Anthropic). When the token limit is approached, oldest
|
|
32
|
+
messages are pruned from memory; the database retains the full history.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, chat_id: int, chat_type: str, system_content: str, system_model: str = "gpt-4o-mini"):
|
|
36
|
+
"""
|
|
37
|
+
Initialize a conversation keyed by chat_id.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
chat_id: Telegram chat ID (negative for groups, positive/==user_id for private chats).
|
|
41
|
+
chat_type: Chat type ('private', 'group', 'supergroup', 'channel').
|
|
42
|
+
system_content: System prompt (bot persona).
|
|
43
|
+
system_model: LLM model to use for token counting (default: "gpt-4o-mini").
|
|
44
|
+
"""
|
|
45
|
+
self.error_log = generate_error_path()
|
|
46
|
+
|
|
47
|
+
self.chat_id = chat_id
|
|
48
|
+
self.chat_type = chat_type
|
|
49
|
+
self.chat_print = f"{self.chat_type} Chat {self.chat_id}"
|
|
50
|
+
|
|
51
|
+
self.system_content = system_content
|
|
52
|
+
self.system_model = TokenLimits(system_model)
|
|
53
|
+
self.messages = [{"role": "system", "content": system_content}]
|
|
54
|
+
|
|
55
|
+
# Maps user_id → max message id at the time their cross-chat context was loaded.
|
|
56
|
+
# Used for both per-user idempotency (is the user loaded?) and mid-session refresh
|
|
57
|
+
# detection (have new cross-chat messages appeared since the last load?).
|
|
58
|
+
self._context_cursor: dict[int, int] = {}
|
|
59
|
+
|
|
60
|
+
# Maps user_id → len(self.messages) after their history was loaded.
|
|
61
|
+
# Marks the boundary between historical context and live session messages so that
|
|
62
|
+
# refresh_user_context() inserts delta rows at the right position (after history,
|
|
63
|
+
# before live messages) rather than at index 1 (before all existing history).
|
|
64
|
+
self._history_end: dict[int, int] = {}
|
|
65
|
+
|
|
66
|
+
def set_system_content(self, new_content: str):
|
|
67
|
+
"""
|
|
68
|
+
Replace the system content (bot's personality prompt) in the conversation.
|
|
69
|
+
|
|
70
|
+
Updates both self.system_content and the first message in self.messages.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
new_content: The new system prompt text.
|
|
74
|
+
"""
|
|
75
|
+
self.system_content = new_content
|
|
76
|
+
self.messages[0] = {"role": "system", "content": new_content}
|
|
77
|
+
|
|
78
|
+
async def add_user_message(self, content: str, user_id: int, username: str | None, is_private: bool = False):
|
|
79
|
+
"""
|
|
80
|
+
Add a user message to conversation memory and persist it to the database.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
content: The message text.
|
|
84
|
+
user_id: Telegram user ID of the sender.
|
|
85
|
+
username: Telegram username (display only, may be None).
|
|
86
|
+
is_private: If True, excludes this message from group context loading (used for /forget or private commands).
|
|
87
|
+
"""
|
|
88
|
+
self.messages.append({"role": "user", "content": content})
|
|
89
|
+
await insert_message(self.chat_id, user_id, username, "user", content, is_private)
|
|
90
|
+
|
|
91
|
+
async def add_assistant_message(self, content: str, bot_user_id: int, bot_username: str | None, is_private: bool = False):
|
|
92
|
+
"""
|
|
93
|
+
Add an assistant message to conversation memory and persist it to the database.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
content: The message text (LLM response).
|
|
97
|
+
bot_user_id: Telegram user ID of the bot account.
|
|
98
|
+
bot_username: Telegram username of the bot (display only, may be None).
|
|
99
|
+
is_private: If True, excludes this reply from group context loading. Should match
|
|
100
|
+
the is_private flag of the user message it responds to, so that both
|
|
101
|
+
sides of a private-mode exchange are excluded from shared contexts.
|
|
102
|
+
"""
|
|
103
|
+
self.messages.append({"role": "assistant", "content": content})
|
|
104
|
+
await insert_message(self.chat_id, bot_user_id, bot_username, "assistant", content, is_private)
|
|
105
|
+
|
|
106
|
+
async def get_message_token_count(self) -> int:
|
|
107
|
+
"""
|
|
108
|
+
Get the current token count of all messages in this conversation.
|
|
109
|
+
|
|
110
|
+
Delegates to the configured model's provider to count tokens accurately.
|
|
111
|
+
This is an async operation because it may contact the provider API (e.g., for Anthropic).
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
The total token count for all messages (system, user, assistant).
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
ProviderAuthError: If API credentials are missing or invalid.
|
|
118
|
+
ProviderConnectionError: If the provider API cannot be reached.
|
|
119
|
+
"""
|
|
120
|
+
return await self.system_model.num_tokens_from_messages(self.messages)
|
|
121
|
+
|
|
122
|
+
async def prune_conversation(self, token_limit: int):
|
|
123
|
+
"""
|
|
124
|
+
Remove oldest user-assistant messages until token count is below threshold.
|
|
125
|
+
|
|
126
|
+
Recursively removes messages starting from index 1 (preserving the system message at index 0)
|
|
127
|
+
until the conversation fits within the token limit. The database is not modified — pruning
|
|
128
|
+
only affects the in-memory context window for the current session.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
token_limit: The maximum token count threshold. Messages are removed until
|
|
132
|
+
the conversation is strictly below this limit.
|
|
133
|
+
"""
|
|
134
|
+
try:
|
|
135
|
+
self.messages.pop(1)
|
|
136
|
+
if await self.get_message_token_count() > token_limit:
|
|
137
|
+
await self.prune_conversation(token_limit)
|
|
138
|
+
except Exception as e:
|
|
139
|
+
log_error(e, f"{type(e).__name__} pruning under {token_limit} tokens for {self.chat_print}", self.error_log)
|
|
140
|
+
|
|
141
|
+
async def get_past_interaction(self, token_limit: int, user_id: int, bot_id: int | None = None) -> bool:
|
|
142
|
+
"""
|
|
143
|
+
Load past conversation messages from the database up to a token limit.
|
|
144
|
+
|
|
145
|
+
Implements bidirectional cross-pollination: loads all messages from the current chat
|
|
146
|
+
(all participants) plus all messages from the user's private chat (both user and
|
|
147
|
+
assistant turns), merged chronologically. This eliminates amnesia when a user switches
|
|
148
|
+
between private and group contexts.
|
|
149
|
+
|
|
150
|
+
For group and supergroup chats, messages marked as private (is_private=1) are excluded
|
|
151
|
+
so private-mode messages don't surface in shared contexts.
|
|
152
|
+
|
|
153
|
+
For private chats, if bot_id is provided and remaining token budget allows, also loads
|
|
154
|
+
full message history from any groups shared between the user and the bot. This lets the
|
|
155
|
+
bot answer questions about group conversations from a private chat context.
|
|
156
|
+
|
|
157
|
+
Per-user idempotent: once a user's cursor is set, subsequent calls return True immediately.
|
|
158
|
+
Calling for a new user_id merges that user's cross-chat context into the existing
|
|
159
|
+
conversation. Context is considered "found" only if messages were actually inserted
|
|
160
|
+
(rows_inserted > 0 or group_rows_inserted > 0).
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
token_limit: The maximum token count to load into the in-memory context.
|
|
164
|
+
user_id: Telegram user ID of the requesting user; used to fetch bidirectional context.
|
|
165
|
+
bot_id: Telegram user ID of the bot account. When provided in a private chat,
|
|
166
|
+
enables shared group context loading into any remaining token budget.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
True if past interactions were found (either on this call or previously),
|
|
170
|
+
False if no past interactions exist for this user.
|
|
171
|
+
|
|
172
|
+
Side Effects:
|
|
173
|
+
- Modifies self.messages in-place by inserting historical messages after the system message
|
|
174
|
+
(attempting to fit messages starting from most recent, stopping when limit is reached).
|
|
175
|
+
- Sets self._context_cursor[user_id] to the max message id loaded once context is found.
|
|
176
|
+
- Sets self._history_end[user_id] to len(self.messages) after loading, marking the
|
|
177
|
+
boundary between historical context and live session messages for future refreshes.
|
|
178
|
+
- Prints to stdout showing token count and whether the token limit was reached.
|
|
179
|
+
"""
|
|
180
|
+
if user_id in self._context_cursor:
|
|
181
|
+
print(f"Context already loaded for User {user_id} in {self.chat_print}")
|
|
182
|
+
return True
|
|
183
|
+
|
|
184
|
+
exclude_private = self.chat_type in ('group', 'supergroup')
|
|
185
|
+
rows = await load_full_user_context(user_id, self.chat_id, exclude_private=exclude_private)
|
|
186
|
+
|
|
187
|
+
token_limit_reached = False
|
|
188
|
+
token_count = 0
|
|
189
|
+
rows_inserted = 0
|
|
190
|
+
|
|
191
|
+
for row in reversed(rows):
|
|
192
|
+
self.messages.insert(1, row)
|
|
193
|
+
token_count = await self.get_message_token_count()
|
|
194
|
+
if token_count > token_limit:
|
|
195
|
+
self.messages.pop(1)
|
|
196
|
+
token_count = await self.get_message_token_count()
|
|
197
|
+
token_limit_reached = True
|
|
198
|
+
break
|
|
199
|
+
rows_inserted += 1
|
|
200
|
+
|
|
201
|
+
# Load shared group context for private chats when token budget allows.
|
|
202
|
+
# Private context fills the primary budget; group context fills the remainder.
|
|
203
|
+
group_rows_inserted = 0
|
|
204
|
+
group_limit_reached = False
|
|
205
|
+
if self.chat_type == 'private' and bot_id is not None:
|
|
206
|
+
if token_count == 0:
|
|
207
|
+
token_count = await self.get_message_token_count()
|
|
208
|
+
remaining = token_limit - token_count
|
|
209
|
+
if remaining > MIN_GROUP_CONTEXT_TOKENS:
|
|
210
|
+
shared_groups = await get_shared_group_chat_ids(user_id, bot_id)
|
|
211
|
+
if shared_groups:
|
|
212
|
+
group_rows = await load_shared_group_context(shared_groups, exclude_private=True)
|
|
213
|
+
insert_at = 1 + rows_inserted
|
|
214
|
+
for row in reversed(group_rows):
|
|
215
|
+
self.messages.insert(insert_at, row)
|
|
216
|
+
token_count = await self.get_message_token_count()
|
|
217
|
+
if token_count > token_limit:
|
|
218
|
+
self.messages.pop(insert_at)
|
|
219
|
+
token_count = await self.get_message_token_count()
|
|
220
|
+
group_limit_reached = True
|
|
221
|
+
break
|
|
222
|
+
group_rows_inserted += 1
|
|
223
|
+
|
|
224
|
+
has_context = rows_inserted > 0 or group_rows_inserted > 0
|
|
225
|
+
|
|
226
|
+
if has_context:
|
|
227
|
+
self._context_cursor[user_id] = await get_max_cross_chat_message_id(
|
|
228
|
+
user_id, self.chat_id, exclude_private=exclude_private
|
|
229
|
+
)
|
|
230
|
+
self._history_end[user_id] = len(self.messages)
|
|
231
|
+
label = "Full" if not token_limit_reached and not group_limit_reached else "Partial"
|
|
232
|
+
group_note = f", plus {group_rows_inserted} group message(s)" if group_rows_inserted > 0 else ""
|
|
233
|
+
print(
|
|
234
|
+
f"{label} context loaded for User {user_id} in {self.chat_print} — "
|
|
235
|
+
f"storing {token_count} token(s){group_note}"
|
|
236
|
+
)
|
|
237
|
+
else:
|
|
238
|
+
print(f"No prior context found for User {user_id} in {self.chat_print}")
|
|
239
|
+
|
|
240
|
+
return has_context
|
|
241
|
+
|
|
242
|
+
async def refresh_user_context(self, user_id: int, token_limit: int) -> bool:
|
|
243
|
+
"""
|
|
244
|
+
Merge any new cross-chat messages that arrived since this user's context was last loaded.
|
|
245
|
+
|
|
246
|
+
Compares the current max message id in the cross-chat context against the stored
|
|
247
|
+
cursor for this user. If new rows exist, loads only the delta and merges it into
|
|
248
|
+
the in-memory conversation, respecting the token budget.
|
|
249
|
+
|
|
250
|
+
This handles the mid-session staleness case: a user whose context was loaded
|
|
251
|
+
at session start (with no private history) will pick up new private messages they
|
|
252
|
+
send before returning to the group, without requiring a full reload.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
user_id: Telegram user ID of the user to refresh.
|
|
256
|
+
token_limit: Maximum token budget; new messages are discarded if they would
|
|
257
|
+
push the conversation over this limit.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
True if at least one new message was merged, False otherwise.
|
|
261
|
+
|
|
262
|
+
Side Effects:
|
|
263
|
+
- Modifies self.messages in-place, inserting new rows at self._history_end[user_id]
|
|
264
|
+
(after historical context, before any live session messages).
|
|
265
|
+
- Advances self._context_cursor[user_id] and self._history_end[user_id] on success.
|
|
266
|
+
"""
|
|
267
|
+
if user_id not in self._context_cursor:
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
exclude_private = self.chat_type in ('group', 'supergroup')
|
|
271
|
+
current_max = await get_max_cross_chat_message_id(user_id, self.chat_id, exclude_private)
|
|
272
|
+
|
|
273
|
+
if current_max <= self._context_cursor[user_id]:
|
|
274
|
+
return False
|
|
275
|
+
|
|
276
|
+
new_rows = await load_new_cross_chat_context(
|
|
277
|
+
user_id, self.chat_id, self._context_cursor[user_id], exclude_private
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
if not new_rows:
|
|
281
|
+
return False
|
|
282
|
+
|
|
283
|
+
# Insert at the tracked history boundary so delta rows land after existing historical
|
|
284
|
+
# context and before any live session messages, preserving chronological ordering.
|
|
285
|
+
insert_pos = self._history_end.get(user_id, 1)
|
|
286
|
+
rows_inserted = 0
|
|
287
|
+
for row in reversed(new_rows):
|
|
288
|
+
self.messages.insert(insert_pos, row)
|
|
289
|
+
token_count = await self.get_message_token_count()
|
|
290
|
+
if token_count > token_limit:
|
|
291
|
+
self.messages.pop(insert_pos)
|
|
292
|
+
break
|
|
293
|
+
rows_inserted += 1
|
|
294
|
+
|
|
295
|
+
if rows_inserted > 0:
|
|
296
|
+
self._context_cursor[user_id] = current_max
|
|
297
|
+
self._history_end[user_id] = insert_pos + rows_inserted
|
|
298
|
+
print(f"Refreshed {rows_inserted} new message(s) for User {user_id} in {self.chat_print}")
|
|
299
|
+
|
|
300
|
+
return rows_inserted > 0
|
|
301
|
+
|
|
302
|
+
async def clear_interaction(self, user_id: int):
|
|
303
|
+
"""
|
|
304
|
+
Clear this user's messages from memory and from the database.
|
|
305
|
+
|
|
306
|
+
In private chats, all messages in the chat are deleted (including bot replies),
|
|
307
|
+
fully clearing the conversation. In group chats, only this user's rows are removed;
|
|
308
|
+
other members' messages remain. Resets both _context_cursor and _history_end.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
user_id: Telegram user ID of the user requesting the forget.
|
|
312
|
+
|
|
313
|
+
Side Effects:
|
|
314
|
+
- Clears self.messages to contain only the system message (index 0).
|
|
315
|
+
- Resets self._context_cursor to empty dict.
|
|
316
|
+
- Resets self._history_end to empty dict.
|
|
317
|
+
- Deletes all messages for this chat (private) or this user (group) from database.
|
|
318
|
+
"""
|
|
319
|
+
self.messages = [self.messages[0]]
|
|
320
|
+
self._context_cursor = {}
|
|
321
|
+
self._history_end = {}
|
|
322
|
+
if self.chat_type == 'private':
|
|
323
|
+
await delete_messages_for_chat(self.chat_id)
|
|
324
|
+
else:
|
|
325
|
+
await delete_messages_for_user(user_id)
|
|
@@ -14,6 +14,10 @@ from .utils import execution_dir
|
|
|
14
14
|
# Allows tests to override the DB path without touching env vars
|
|
15
15
|
_DB_PATH: Optional[str] = None
|
|
16
16
|
|
|
17
|
+
# Maximum bound parameters per SQLite statement (SQLite default limit is 999).
|
|
18
|
+
# Defined at module level so tests can patch it to force batching with small inputs.
|
|
19
|
+
_SQLITE_MAX_PARAMS = 999
|
|
20
|
+
|
|
17
21
|
_DDL = """
|
|
18
22
|
PRAGMA journal_mode=WAL;
|
|
19
23
|
|
|
@@ -149,13 +153,27 @@ async def load_full_user_context(
|
|
|
149
153
|
Load bidirectional conversation context for a user across all chats.
|
|
150
154
|
|
|
151
155
|
Implements bidirectional cross-pollination: returns all messages from the current
|
|
152
|
-
chat (all participants) plus
|
|
153
|
-
|
|
154
|
-
switches between private and group contexts.
|
|
156
|
+
chat (all participants) plus all messages from the user's private chat (both user
|
|
157
|
+
and assistant turns), merged in chronological order. This eliminates amnesia when
|
|
158
|
+
a user switches between private and group contexts.
|
|
159
|
+
|
|
160
|
+
In Telegram, a user's private chat_id always equals their user_id. Three arms cover
|
|
161
|
+
all cross-context cases:
|
|
162
|
+
|
|
163
|
+
Arm 1 — chat_id = current_chat_id:
|
|
164
|
+
All messages (user and bot) in the current chat. Always included.
|
|
165
|
+
|
|
166
|
+
Arm 2 — chat_id = user_id AND chat_id != current_chat_id:
|
|
167
|
+
All messages (both sides) from the user's private chat, included when the
|
|
168
|
+
current chat is a group. Pulling both sides restores proper role alternation
|
|
169
|
+
that was broken when only the user's own rows were fetched.
|
|
170
|
+
|
|
171
|
+
Arm 3 — user_id = user_id AND chat_id != current_chat_id AND chat_id != user_id:
|
|
172
|
+
The requesting user's own messages from any other group chats (not the private
|
|
173
|
+
chat, not the current chat). Covers group-to-private and group-to-group context.
|
|
174
|
+
Only the user's own rows are fetched from other groups (not bot replies).
|
|
155
175
|
|
|
156
|
-
|
|
157
|
-
user's own rows from every other chat, merged chronologically. The exclude_private filter
|
|
158
|
-
is applied globally when the current chat is a group.
|
|
176
|
+
The exclude_private filter is applied globally when the current chat is a group.
|
|
159
177
|
|
|
160
178
|
Args:
|
|
161
179
|
user_id: Telegram user ID of the requesting user.
|
|
@@ -169,9 +187,76 @@ async def load_full_user_context(
|
|
|
169
187
|
"""
|
|
170
188
|
query = (
|
|
171
189
|
"SELECT role, content FROM messages "
|
|
172
|
-
"WHERE (chat_id = ?
|
|
190
|
+
"WHERE (chat_id = ? "
|
|
191
|
+
"OR (chat_id = ? AND chat_id != ?) "
|
|
192
|
+
"OR (user_id = ? AND chat_id != ? AND chat_id != ?))"
|
|
193
|
+
)
|
|
194
|
+
params: tuple = (current_chat_id, user_id, current_chat_id, user_id, current_chat_id, user_id)
|
|
195
|
+
if exclude_private:
|
|
196
|
+
query += " AND is_private = 0"
|
|
197
|
+
query += " ORDER BY created_at ASC, id ASC"
|
|
198
|
+
async with aiosqlite.connect(get_db_path()) as db:
|
|
199
|
+
async with db.execute(query, params) as cursor:
|
|
200
|
+
rows = await cursor.fetchall()
|
|
201
|
+
return [{"role": row[0], "content": row[1]} for row in rows]
|
|
202
|
+
|
|
203
|
+
async def get_max_cross_chat_message_id(
|
|
204
|
+
user_id: int,
|
|
205
|
+
current_chat_id: int,
|
|
206
|
+
exclude_private: bool = False,
|
|
207
|
+
) -> int:
|
|
208
|
+
"""
|
|
209
|
+
Return the maximum message id from the cross-chat context for this user.
|
|
210
|
+
|
|
211
|
+
Uses the same three-arm query as load_full_user_context, returning only the
|
|
212
|
+
MAX(id) scalar. Used to establish a cursor for mid-session refresh detection.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Maximum message id as an integer, or 0 if no rows exist.
|
|
216
|
+
"""
|
|
217
|
+
query = (
|
|
218
|
+
"SELECT MAX(id) FROM messages "
|
|
219
|
+
"WHERE (chat_id = ? "
|
|
220
|
+
"OR (chat_id = ? AND chat_id != ?) "
|
|
221
|
+
"OR (user_id = ? AND chat_id != ? AND chat_id != ?))"
|
|
222
|
+
)
|
|
223
|
+
params: tuple = (current_chat_id, user_id, current_chat_id, user_id, current_chat_id, user_id)
|
|
224
|
+
if exclude_private:
|
|
225
|
+
query += " AND is_private = 0"
|
|
226
|
+
async with aiosqlite.connect(get_db_path()) as db:
|
|
227
|
+
async with db.execute(query, params) as cursor:
|
|
228
|
+
row = await cursor.fetchone()
|
|
229
|
+
return row[0] if row and row[0] is not None else 0
|
|
230
|
+
|
|
231
|
+
async def load_new_cross_chat_context(
|
|
232
|
+
user_id: int,
|
|
233
|
+
current_chat_id: int,
|
|
234
|
+
since_id: int,
|
|
235
|
+
exclude_private: bool = False,
|
|
236
|
+
) -> list[dict]:
|
|
237
|
+
"""
|
|
238
|
+
Load cross-chat context rows with id strictly greater than since_id.
|
|
239
|
+
|
|
240
|
+
Uses the same three-arm query as load_full_user_context but restricted to rows
|
|
241
|
+
newer than the given cursor. Returns only the delta since the last context load.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
user_id: Telegram user ID of the requesting user.
|
|
245
|
+
current_chat_id: The chat_id of the current conversation.
|
|
246
|
+
since_id: Exclusive lower bound — only rows with id > since_id are returned.
|
|
247
|
+
exclude_private: If True, rows with is_private=1 are excluded.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
List of {"role": ..., "content": ...} dicts in ascending chronological order.
|
|
251
|
+
"""
|
|
252
|
+
query = (
|
|
253
|
+
"SELECT role, content FROM messages "
|
|
254
|
+
"WHERE (chat_id = ? "
|
|
255
|
+
"OR (chat_id = ? AND chat_id != ?) "
|
|
256
|
+
"OR (user_id = ? AND chat_id != ? AND chat_id != ?)) "
|
|
257
|
+
"AND id > ?"
|
|
173
258
|
)
|
|
174
|
-
params: tuple = (current_chat_id, user_id, current_chat_id)
|
|
259
|
+
params: tuple = (current_chat_id, user_id, current_chat_id, user_id, current_chat_id, user_id, since_id)
|
|
175
260
|
if exclude_private:
|
|
176
261
|
query += " AND is_private = 0"
|
|
177
262
|
query += " ORDER BY created_at ASC, id ASC"
|
|
@@ -180,6 +265,80 @@ async def load_full_user_context(
|
|
|
180
265
|
rows = await cursor.fetchall()
|
|
181
266
|
return [{"role": row[0], "content": row[1]} for row in rows]
|
|
182
267
|
|
|
268
|
+
async def get_shared_group_chat_ids(user_id: int, bot_id: int) -> list[int]:
|
|
269
|
+
"""
|
|
270
|
+
Return chat_ids of groups where both the user and the bot have sent messages.
|
|
271
|
+
|
|
272
|
+
Eligibility conditions:
|
|
273
|
+
1. The requesting user has sent at least one message in the group.
|
|
274
|
+
2. The bot has also sent at least one message in the group (bot is active there).
|
|
275
|
+
|
|
276
|
+
Condition 2 prevents surfacing history from groups the bot was removed from
|
|
277
|
+
or never properly joined.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
user_id: Telegram user ID of the requesting user.
|
|
281
|
+
bot_id: Telegram user ID of the bot account.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
List of negative chat_ids (Telegram group IDs).
|
|
285
|
+
"""
|
|
286
|
+
query = """
|
|
287
|
+
SELECT DISTINCT chat_id
|
|
288
|
+
FROM messages
|
|
289
|
+
WHERE user_id = ? AND chat_id < 0
|
|
290
|
+
AND EXISTS (
|
|
291
|
+
SELECT 1 FROM messages m2
|
|
292
|
+
WHERE m2.chat_id = messages.chat_id AND m2.user_id = ?
|
|
293
|
+
)
|
|
294
|
+
"""
|
|
295
|
+
async with aiosqlite.connect(get_db_path()) as db:
|
|
296
|
+
async with db.execute(query, (user_id, bot_id)) as cursor:
|
|
297
|
+
rows = await cursor.fetchall()
|
|
298
|
+
return [row[0] for row in rows]
|
|
299
|
+
|
|
300
|
+
async def load_shared_group_context(
|
|
301
|
+
group_chat_ids: list[int],
|
|
302
|
+
exclude_private: bool = True,
|
|
303
|
+
) -> list[dict]:
|
|
304
|
+
"""
|
|
305
|
+
Load all messages from multiple group chats, merged in chronological order.
|
|
306
|
+
|
|
307
|
+
Used to fill remaining token budget in a private chat with context from
|
|
308
|
+
shared groups (full group context in private chat).
|
|
309
|
+
|
|
310
|
+
Automatically batches the query if group_chat_ids exceeds _SQLITE_MAX_PARAMS
|
|
311
|
+
(999, SQLite's bound parameter limit) to handle users who share many groups
|
|
312
|
+
with the bot. Results are merged and sorted chronologically across batches.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
group_chat_ids: List of group chat_ids (negative integers) to include.
|
|
316
|
+
exclude_private: If True, rows with is_private=1 are excluded (default True).
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
List of {"role": ..., "content": ...} dicts in ascending chronological order.
|
|
320
|
+
Empty list if group_chat_ids is empty.
|
|
321
|
+
"""
|
|
322
|
+
if not group_chat_ids:
|
|
323
|
+
return []
|
|
324
|
+
# SQLite has a max of 999 bound parameters per statement. Batch group_chat_ids to
|
|
325
|
+
# avoid exceeding this limit for users who share many groups with the bot.
|
|
326
|
+
all_rows: list[tuple] = []
|
|
327
|
+
async with aiosqlite.connect(get_db_path()) as db:
|
|
328
|
+
for i in range(0, len(group_chat_ids), _SQLITE_MAX_PARAMS):
|
|
329
|
+
chunk = group_chat_ids[i:i + _SQLITE_MAX_PARAMS]
|
|
330
|
+
placeholders = ','.join('?' * len(chunk))
|
|
331
|
+
query = (
|
|
332
|
+
f"SELECT role, content, created_at, id FROM messages "
|
|
333
|
+
f"WHERE chat_id IN ({placeholders})"
|
|
334
|
+
)
|
|
335
|
+
if exclude_private:
|
|
336
|
+
query += " AND is_private = 0"
|
|
337
|
+
async with db.execute(query, tuple(chunk)) as cursor:
|
|
338
|
+
all_rows.extend(await cursor.fetchall())
|
|
339
|
+
all_rows.sort(key=lambda r: (r[2], r[3]))
|
|
340
|
+
return [{"role": row[0], "content": row[1]} for row in all_rows]
|
|
341
|
+
|
|
183
342
|
async def delete_messages_for_user(user_id: int) -> None:
|
|
184
343
|
"""
|
|
185
344
|
Delete all message rows for a given user_id across all chats.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: TeLLMgramBot
|
|
3
|
-
Version: 3.0
|
|
3
|
+
Version: 3.2.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
|
|
@@ -43,10 +43,10 @@ The basic goal of this project is to create a bridge between a Telegram Bot and
|
|
|
43
43
|
* This uses a separate model (configurable via `url_model`) to support more URL content with its higher token limit.
|
|
44
44
|
* Tokens are used to measure the length of all conversation messages between the Telegram bot assistant and the user. This is useful to:
|
|
45
45
|
* Ensure the length does not go over the model limit. If it does, prune oldest messages to fit within the limit.
|
|
46
|
-
* Remember past conversations when restarting: loads the user's full history across all chats (private and groups) plus all other participants' messages in the current chat, up to 50% of the token budget. This eliminates amnesia when users switch between contexts.
|
|
46
|
+
* Remember past conversations when restarting: loads the user's full history across all chats (private and groups) plus all other participants' messages in the current chat, up to 50% of the token budget. In private chats, shared group context (messages from groups where both user and bot are active) fills the remaining budget, enabling the bot to reference group conversations from a private context. This eliminates amnesia when users switch between contexts.
|
|
47
47
|
* Users can manage privacy via two commands:
|
|
48
|
-
* `/forget` — In private chats, clears the full conversation (including bot replies). In group chats, removes
|
|
49
|
-
* `/private` — Toggles per-user private mode (private chats only). When ON, messages are excluded from group conversation contexts, enabling selective privacy even in shared groups.
|
|
48
|
+
* `/forget` — In private chats, clears the full conversation (including bot replies) and resets all of your active sessions (any sessions where your context was merged). In group chats, removes your messages across all chat types; other participants' messages and sessions remain.
|
|
49
|
+
* `/private` — Toggles per-user private mode (private chats only). When ON, messages are excluded from group conversation contexts, enabling selective privacy even in shared groups. If you have private mode ON and message a group, the bot will remind you once per session.
|
|
50
50
|
|
|
51
51
|
## Why Telegram?
|
|
52
52
|
Using Telegram as the interface not only solves "exposing" the interface, but gives you boatloads of interactivity over a standard Command Line interface, or trying to create a website with input boxes and submit buttons to try to handle everything:
|
|
@@ -87,7 +87,7 @@ When initializing TeLLMgramBot, the following directories get created:
|
|
|
87
87
|
* User will also get notified to contact the owner.
|
|
88
88
|
* `data`
|
|
89
89
|
* Contains `conversations.db` — a SQLite database storing all conversations between the bot and users across all chats.
|
|
90
|
-
* When a user messages in any chat, their full history is available for context: private messages appear in group contexts, group messages appear in private contexts. This creates seamless cross-context awareness.
|
|
90
|
+
* When a user messages in any chat, their full history is available for context: private messages appear in group contexts, group messages appear in private contexts. This creates seamless cross-context awareness. The bot dynamically refreshes each user's context during a session to pick up messages sent between chats. In private chats, the bot also loads shared group context (messages from groups where both user and bot are active) to provide group awareness.
|
|
91
91
|
* Users can manage their context via `/forget` (private chat: clears full conversation; group chat: removes only your messages) or `/private` (toggles per-user privacy for group contexts).
|
|
92
92
|
|
|
93
93
|
### Environment Variables
|
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Manages conversation history, token counting, and SQLite-backed persistence for any LLM provider.
|
|
3
|
-
|
|
4
|
-
The Conversation class is keyed by chat_id (Telegram chat ID) and maintains an in-memory message
|
|
5
|
-
history, delegating persistence to the database module. Supports async token counting via provider-
|
|
6
|
-
specific APIs and automatic pruning when token limits are approached.
|
|
7
|
-
"""
|
|
8
|
-
from .database import insert_message, load_full_user_context, delete_messages_for_user, delete_messages_for_chat
|
|
9
|
-
from .models import TokenLimits
|
|
10
|
-
from .utils import generate_error_path, log_error
|
|
11
|
-
|
|
12
|
-
class Conversation:
|
|
13
|
-
"""
|
|
14
|
-
Manages a chat conversation including message history, token counting, and SQLite persistence.
|
|
15
|
-
|
|
16
|
-
Keyed by chat_id (Telegram chat ID): negative for groups, positive (== user_id) for private chats.
|
|
17
|
-
Works with any LLM provider (OpenAI, Anthropic). When the token limit is approached, oldest
|
|
18
|
-
messages are pruned from memory; the database retains the full history.
|
|
19
|
-
"""
|
|
20
|
-
|
|
21
|
-
def __init__(self, chat_id: int, chat_type: str, system_content: str, system_model: str = "gpt-4o-mini"):
|
|
22
|
-
"""
|
|
23
|
-
Initialize a conversation keyed by chat_id.
|
|
24
|
-
|
|
25
|
-
Args:
|
|
26
|
-
chat_id: Telegram chat ID (negative for groups, positive/==user_id for private chats).
|
|
27
|
-
chat_type: Chat type ('private', 'group', 'supergroup', 'channel').
|
|
28
|
-
system_content: System prompt (bot persona).
|
|
29
|
-
system_model: LLM model to use for token counting (default: "gpt-4o-mini").
|
|
30
|
-
"""
|
|
31
|
-
self.error_log = generate_error_path()
|
|
32
|
-
|
|
33
|
-
self.chat_id = chat_id
|
|
34
|
-
self.chat_type = chat_type
|
|
35
|
-
self.chat_print = f"{self.chat_type} Chat {self.chat_id}"
|
|
36
|
-
|
|
37
|
-
self.system_content = system_content
|
|
38
|
-
self.system_model = TokenLimits(system_model)
|
|
39
|
-
self.messages = [{"role": "system", "content": system_content}]
|
|
40
|
-
|
|
41
|
-
self._has_past_interaction = False
|
|
42
|
-
|
|
43
|
-
def set_system_content(self, new_content: str):
|
|
44
|
-
"""
|
|
45
|
-
Replace the system content (bot's personality prompt) in the conversation.
|
|
46
|
-
|
|
47
|
-
Updates both self.system_content and the first message in self.messages.
|
|
48
|
-
|
|
49
|
-
Args:
|
|
50
|
-
new_content: The new system prompt text.
|
|
51
|
-
"""
|
|
52
|
-
self.system_content = new_content
|
|
53
|
-
self.messages[0] = {"role": "system", "content": new_content}
|
|
54
|
-
|
|
55
|
-
async def add_user_message(self, content: str, user_id: int, username: str | None, is_private: bool = False):
|
|
56
|
-
"""
|
|
57
|
-
Add a user message to conversation memory and persist it to the database.
|
|
58
|
-
|
|
59
|
-
Args:
|
|
60
|
-
content: The message text.
|
|
61
|
-
user_id: Telegram user ID of the sender.
|
|
62
|
-
username: Telegram username (display only, may be None).
|
|
63
|
-
is_private: If True, excludes this message from group context loading (used for /forget or private commands).
|
|
64
|
-
"""
|
|
65
|
-
self.messages.append({"role": "user", "content": content})
|
|
66
|
-
await insert_message(self.chat_id, user_id, username, "user", content, is_private)
|
|
67
|
-
|
|
68
|
-
async def add_assistant_message(self, content: str, bot_user_id: int, bot_username: str | None):
|
|
69
|
-
"""
|
|
70
|
-
Add an assistant message to conversation memory and persist it to the database.
|
|
71
|
-
|
|
72
|
-
Args:
|
|
73
|
-
content: The message text (LLM response).
|
|
74
|
-
bot_user_id: Telegram user ID of the bot account.
|
|
75
|
-
bot_username: Telegram username of the bot (display only, may be None).
|
|
76
|
-
"""
|
|
77
|
-
self.messages.append({"role": "assistant", "content": content})
|
|
78
|
-
await insert_message(self.chat_id, bot_user_id, bot_username, "assistant", content, False)
|
|
79
|
-
|
|
80
|
-
async def get_message_token_count(self) -> int:
|
|
81
|
-
"""
|
|
82
|
-
Get the current token count of all messages in this conversation.
|
|
83
|
-
|
|
84
|
-
Delegates to the configured model's provider to count tokens accurately.
|
|
85
|
-
This is an async operation because it may contact the provider API (e.g., for Anthropic).
|
|
86
|
-
|
|
87
|
-
Returns:
|
|
88
|
-
The total token count for all messages (system, user, assistant).
|
|
89
|
-
|
|
90
|
-
Raises:
|
|
91
|
-
ProviderAuthError: If API credentials are missing or invalid.
|
|
92
|
-
ProviderConnectionError: If the provider API cannot be reached.
|
|
93
|
-
"""
|
|
94
|
-
return await self.system_model.num_tokens_from_messages(self.messages)
|
|
95
|
-
|
|
96
|
-
async def prune_conversation(self, token_limit: int):
|
|
97
|
-
"""
|
|
98
|
-
Remove oldest user-assistant messages until token count is below threshold.
|
|
99
|
-
|
|
100
|
-
Recursively removes messages starting from index 1 (preserving the system message at index 0)
|
|
101
|
-
until the conversation fits within the token limit. The database is not modified — pruning
|
|
102
|
-
only affects the in-memory context window for the current session.
|
|
103
|
-
|
|
104
|
-
Args:
|
|
105
|
-
token_limit: The maximum token count threshold. Messages are removed until
|
|
106
|
-
the conversation is strictly below this limit.
|
|
107
|
-
"""
|
|
108
|
-
try:
|
|
109
|
-
self.messages.pop(1)
|
|
110
|
-
if await self.get_message_token_count() > token_limit:
|
|
111
|
-
await self.prune_conversation(token_limit)
|
|
112
|
-
except Exception as e:
|
|
113
|
-
log_error(e, f"{type(e).__name__} pruning under {token_limit} tokens for {self.chat_print}", self.error_log)
|
|
114
|
-
|
|
115
|
-
async def get_past_interaction(self, token_limit: int, user_id: int) -> bool:
|
|
116
|
-
"""
|
|
117
|
-
Load past conversation messages from the database up to a token limit.
|
|
118
|
-
|
|
119
|
-
Implements bidirectional cross-pollination: loads all messages from the current chat
|
|
120
|
-
(all participants) plus only the requesting user's own messages from other chats,
|
|
121
|
-
merged chronologically. This eliminates amnesia when a user switches between private
|
|
122
|
-
and group contexts.
|
|
123
|
-
|
|
124
|
-
For group and supergroup chats, messages marked as private (is_private=1) are excluded
|
|
125
|
-
so private-mode messages don't surface in shared contexts.
|
|
126
|
-
|
|
127
|
-
Idempotent: calling again returns True without reloading if messages were already loaded.
|
|
128
|
-
|
|
129
|
-
Args:
|
|
130
|
-
token_limit: The maximum token count to load into the in-memory context.
|
|
131
|
-
user_id: Telegram user ID of the requesting user; used to fetch bidirectional context.
|
|
132
|
-
|
|
133
|
-
Returns:
|
|
134
|
-
True if past interactions were found (either on this call or previously),
|
|
135
|
-
False if no past interactions exist.
|
|
136
|
-
|
|
137
|
-
Side Effects:
|
|
138
|
-
- Modifies self.messages in-place by inserting historical messages after the system message
|
|
139
|
-
(attempting to fit messages starting from most recent, stopping when limit is reached).
|
|
140
|
-
- Sets self._has_past_interaction to True if any messages are loaded.
|
|
141
|
-
- Prints to stdout showing token count and whether the token limit was reached.
|
|
142
|
-
"""
|
|
143
|
-
if self._has_past_interaction:
|
|
144
|
-
print(f"Context already loaded for User {user_id} in {self.chat_print}")
|
|
145
|
-
return True
|
|
146
|
-
|
|
147
|
-
exclude_private = self.chat_type in ('group', 'supergroup')
|
|
148
|
-
rows = await load_full_user_context(user_id, self.chat_id, exclude_private=exclude_private)
|
|
149
|
-
|
|
150
|
-
token_limit_reached = False
|
|
151
|
-
token_count = 0
|
|
152
|
-
|
|
153
|
-
for row in reversed(rows):
|
|
154
|
-
self.messages.insert(1, row)
|
|
155
|
-
token_count = await self.get_message_token_count()
|
|
156
|
-
if token_count > token_limit:
|
|
157
|
-
self.messages.pop(1)
|
|
158
|
-
token_count = await self.get_message_token_count()
|
|
159
|
-
token_limit_reached = True
|
|
160
|
-
break
|
|
161
|
-
|
|
162
|
-
self._has_past_interaction = len(self.messages) > 1
|
|
163
|
-
|
|
164
|
-
if self._has_past_interaction:
|
|
165
|
-
if token_limit_reached:
|
|
166
|
-
print(
|
|
167
|
-
f"Partial context loaded for User {user_id} in {self.chat_print} — "
|
|
168
|
-
f"full history exceeded token limit ({token_limit}), storing {token_count} token(s)"
|
|
169
|
-
)
|
|
170
|
-
else:
|
|
171
|
-
print(
|
|
172
|
-
f"Full context loaded for User {user_id} in {self.chat_print} — "
|
|
173
|
-
f"storing {token_count} token(s)"
|
|
174
|
-
)
|
|
175
|
-
else:
|
|
176
|
-
print(f"No prior context found for User {user_id} in {self.chat_print}")
|
|
177
|
-
|
|
178
|
-
return self._has_past_interaction
|
|
179
|
-
|
|
180
|
-
async def clear_interaction(self, user_id: int):
|
|
181
|
-
"""
|
|
182
|
-
Clear this user's messages from memory and from the database.
|
|
183
|
-
|
|
184
|
-
In private chats, all messages in the chat are deleted (including bot replies),
|
|
185
|
-
fully clearing the conversation. In group chats, only this user's rows are removed;
|
|
186
|
-
other members' messages remain.
|
|
187
|
-
|
|
188
|
-
Args:
|
|
189
|
-
user_id: Telegram user ID of the user requesting the forget.
|
|
190
|
-
"""
|
|
191
|
-
self.messages = [self.messages[0]]
|
|
192
|
-
self._has_past_interaction = False
|
|
193
|
-
if self.chat_type == 'private':
|
|
194
|
-
await delete_messages_for_chat(self.chat_id)
|
|
195
|
-
else:
|
|
196
|
-
await delete_messages_for_user(user_id)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|