TeLLMgramBot 3.0.2__tar.gz → 3.0.4__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.2 → tellmgrambot-3.0.4}/PKG-INFO +1 -1
- tellmgrambot-3.0.4/TeLLMgramBot/providers/__init__.py +1 -0
- {tellmgrambot-3.0.2 → tellmgrambot-3.0.4}/TeLLMgramBot/utils.py +33 -4
- tellmgrambot-3.0.4/TeLLMgramBot.egg-info/PKG-INFO +174 -0
- {tellmgrambot-3.0.2 → tellmgrambot-3.0.4}/TeLLMgramBot.egg-info/SOURCES.txt +6 -7
- tellmgrambot-3.0.4/TeLLMgramBot.egg-info/requires.txt +9 -0
- tellmgrambot-3.0.4/TeLLMgramBot.egg-info/top_level.txt +1 -0
- {tellmgrambot-3.0.2 → tellmgrambot-3.0.4}/setup.py +1 -1
- tellmgrambot-3.0.2/MANIFEST.in +0 -21
- tellmgrambot-3.0.2/Tests/test_api_key_status.py +0 -234
- tellmgrambot-3.0.2/Tests/test_conversation_db.py +0 -482
- tellmgrambot-3.0.2/Tests/test_providers.py +0 -419
- tellmgrambot-3.0.2/Tests/test_web_utils.py +0 -22
- tellmgrambot-3.0.2/requirements.txt +0 -13
- {tellmgrambot-3.0.2 → tellmgrambot-3.0.4}/LICENSE +0 -0
- {tellmgrambot-3.0.2 → tellmgrambot-3.0.4}/README.md +0 -0
- {tellmgrambot-3.0.2 → tellmgrambot-3.0.4}/TeLLMgramBot/TeLLMgramBot.py +0 -0
- {tellmgrambot-3.0.2/TeLLMgramBot/providers → tellmgrambot-3.0.4/TeLLMgramBot}/__init__.py +0 -0
- {tellmgrambot-3.0.2 → tellmgrambot-3.0.4}/TeLLMgramBot/conversation.py +0 -0
- {tellmgrambot-3.0.2 → tellmgrambot-3.0.4}/TeLLMgramBot/database.py +0 -0
- {tellmgrambot-3.0.2 → tellmgrambot-3.0.4}/TeLLMgramBot/initialize.py +0 -0
- {tellmgrambot-3.0.2 → tellmgrambot-3.0.4}/TeLLMgramBot/message_handlers.py +0 -0
- {tellmgrambot-3.0.2 → tellmgrambot-3.0.4}/TeLLMgramBot/models.py +0 -0
- {tellmgrambot-3.0.2 → tellmgrambot-3.0.4}/TeLLMgramBot/providers/anthropic_provider.py +0 -0
- {tellmgrambot-3.0.2 → tellmgrambot-3.0.4}/TeLLMgramBot/providers/base.py +0 -0
- {tellmgrambot-3.0.2 → tellmgrambot-3.0.4}/TeLLMgramBot/providers/factory.py +0 -0
- {tellmgrambot-3.0.2 → tellmgrambot-3.0.4}/TeLLMgramBot/providers/openai_provider.py +0 -0
- {tellmgrambot-3.0.2 → tellmgrambot-3.0.4}/TeLLMgramBot/web_utils.py +0 -0
- /tellmgrambot-3.0.2/TeLLMgramBot/__init__.py → /tellmgrambot-3.0.4/TeLLMgramBot.egg-info/dependency_links.txt +0 -0
- {tellmgrambot-3.0.2 → tellmgrambot-3.0.4}/setup.cfg +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -85,15 +85,44 @@ def generate_file_path(directory: str, file: str, name="", text="") -> str:
|
|
|
85
85
|
return path
|
|
86
86
|
|
|
87
87
|
def generate_error_path(file='tellmgrambot_error.log') -> str:
|
|
88
|
-
"""
|
|
88
|
+
"""
|
|
89
|
+
Generate the path to the error log file, respecting the TELLMGRAMBOT_ERRORLOGS_PATH environment variable.
|
|
90
|
+
|
|
91
|
+
If TELLMGRAMBOT_ERRORLOGS_PATH is not set, defaults to the system temporary directory and prints
|
|
92
|
+
a notice. Returns the full file path (directory + filename).
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
file (str): Error log filename. Defaults to 'tellmgrambot_error.log'.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
str: Full path to the error log file.
|
|
99
|
+
"""
|
|
89
100
|
env_var = 'TELLMGRAMBOT_ERRORLOGS_PATH'
|
|
90
101
|
if not os.environ.get(env_var):
|
|
91
102
|
os.environ[env_var] = gettempdir()
|
|
92
|
-
print(f"{env_var}
|
|
103
|
+
print(f"{env_var} not set, using system temp: {os.environ[env_var]}")
|
|
93
104
|
return generate_file_path(os.environ[env_var], file, f"empty {env_var}")
|
|
94
105
|
|
|
95
|
-
def log_error(error: Exception or str, error_type: str, error_filename=
|
|
96
|
-
"""
|
|
106
|
+
def log_error(error: Exception or str, error_type: str, error_filename=None):
|
|
107
|
+
"""
|
|
108
|
+
Log an error message to both console and file with timestamp and error type.
|
|
109
|
+
|
|
110
|
+
The error filename is lazily evaluated: if not provided, the path is resolved by calling
|
|
111
|
+
`generate_error_path()`, which respects the TELLMGRAMBOT_ERRORLOGS_PATH environment variable.
|
|
112
|
+
This deferred resolution allows the environment variable to be set at any point before the
|
|
113
|
+
error is logged, rather than at import/definition time.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
error (Exception or str): The error object or message to log.
|
|
117
|
+
error_type (str): A label for the error type (e.g., 'ValueError', 'ConnectionError').
|
|
118
|
+
error_filename (str, optional): Full path to the error log file. If None, `generate_error_path()`
|
|
119
|
+
is called to determine the path. Defaults to None.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
None: Logs to both console (via print) and the error file.
|
|
123
|
+
"""
|
|
124
|
+
if error_filename is None:
|
|
125
|
+
error_filename = generate_error_path()
|
|
97
126
|
text = f"{get_timestamp()} {error_type}: {error}"
|
|
98
127
|
print(text) # Also log into console
|
|
99
128
|
append_text_file_path(error_filename, text)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: TeLLMgramBot
|
|
3
|
+
Version: 3.0.4
|
|
4
|
+
Summary: LLM-powered Telegram bot (OpenAI + Anthropic)
|
|
5
|
+
Home-page: https://github.com/Digital-Heresy/TeLLMgramBot
|
|
6
|
+
Author: Digital Heresy
|
|
7
|
+
Author-email: ronin.atx@gmail.com
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: openai>2.0
|
|
13
|
+
Requires-Dist: anthropic>=0.40
|
|
14
|
+
Requires-Dist: PyYAML
|
|
15
|
+
Requires-Dist: httpx
|
|
16
|
+
Requires-Dist: beautifulsoup4
|
|
17
|
+
Requires-Dist: validators
|
|
18
|
+
Requires-Dist: tiktoken>=0.12
|
|
19
|
+
Requires-Dist: python-telegram-bot>20.0
|
|
20
|
+
Requires-Dist: aiosqlite>=0.19
|
|
21
|
+
Dynamic: author
|
|
22
|
+
Dynamic: author-email
|
|
23
|
+
Dynamic: description
|
|
24
|
+
Dynamic: description-content-type
|
|
25
|
+
Dynamic: home-page
|
|
26
|
+
Dynamic: license
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
Dynamic: requires-dist
|
|
29
|
+
Dynamic: requires-python
|
|
30
|
+
Dynamic: summary
|
|
31
|
+
|
|
32
|
+
# TeLLMgramBot
|
|
33
|
+
The basic goal of this project is to create a bridge between a Telegram Bot and a Large Language Model (LLM), supporting both OpenAI's GPT models and Anthropic's Claude models.
|
|
34
|
+
* To use this library, you must have a Telegram account **with a user name**, not just a phone number. If you don't have one, [create one online](https://telegram.org/).
|
|
35
|
+
* If added to a Telegram group, the bot must be [administrator](https://www.alphr.com/add-admin-telegram/) in order to respond to a user calling out its name, initials, or nickname.
|
|
36
|
+
<img src="assets/TeLLMgramBot_Logo.png" width=200 align=center />
|
|
37
|
+
|
|
38
|
+
## Telegram Bot + LLM Encapsulation
|
|
39
|
+
* The Telegram interface handles special commands, especially on some basic "chatty" prompts and responses that don't require LLM, like "Hello".
|
|
40
|
+
* The more dynamic conversation gets handed off to the LLM to manage prompts and responses, and Telegram acts as the interaction broker.
|
|
41
|
+
* Pass the URL in [square brackets] and mention how the bot should interpret it.
|
|
42
|
+
* Example: "What do you think of this article? [https://some_site/article]"
|
|
43
|
+
* This uses a separate model (configurable via `url_model`) to support more URL content with its higher token limit.
|
|
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
|
+
* 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.
|
|
47
|
+
* Users can manage privacy via two commands:
|
|
48
|
+
* `/forget` — In private chats, clears the full conversation (including bot replies). In group chats, removes only your messages; other participants' messages 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.
|
|
50
|
+
|
|
51
|
+
## Why Telegram?
|
|
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:
|
|
53
|
+
1. Telegram already lets you paste in verbose, multiline messages.
|
|
54
|
+
2. Telegram already lets you paste in pictures, videos, links, etc.
|
|
55
|
+
3. Telegram already lets you react with emojis, stickers, etc.
|
|
56
|
+
|
|
57
|
+
## Supported LLM Providers
|
|
58
|
+
TeLLMgramBot selects the LLM provider automatically based on the model name:
|
|
59
|
+
|
|
60
|
+
| Model prefix | Provider | Example models |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| `gpt-` | OpenAI | `gpt-4o`, `gpt-4o-mini`, `gpt-5-mini` |
|
|
63
|
+
| `claude-` | Anthropic | `claude-sonnet-4-6`, `claude-haiku-4-5` |
|
|
64
|
+
|
|
65
|
+
Simply set `chat_model` (and optionally `url_model`) in your `config.yaml` to any supported model and supply the corresponding API key — no other changes needed.
|
|
66
|
+
|
|
67
|
+
## Directories
|
|
68
|
+
When initializing TeLLMgramBot, the following directories get created:
|
|
69
|
+
* `configs` - Contains bot configuration files.
|
|
70
|
+
* `config.yaml` (can be a different name)
|
|
71
|
+
* This file sets main bot parameters like naming and the LLM models to use.
|
|
72
|
+
* `chat_model` — the model used for normal conversation (e.g. `gpt-5-mini` or `claude-sonnet-4-6`).
|
|
73
|
+
* `url_model` — the model used to read and summarize URL content, can differ from `chat_model`.
|
|
74
|
+
* An empty `token_limit` will use the maximum tokens supported by the `chat_model`.
|
|
75
|
+
* `models.yaml`
|
|
76
|
+
* Contains token size parameters for all supported models.
|
|
77
|
+
* On first run, GPT and Claude model families are pre-populated. Additional models can be added manually.
|
|
78
|
+
* `prompts` - Contains prompt files for how the bot interacts with any user.
|
|
79
|
+
* `test_personality.prmpt` (can be a different name)
|
|
80
|
+
* A sample prompt file defining the bot's personality: generic, helpful, and multi-provider-aware.
|
|
81
|
+
* The prompt emphasizes the bot's ability to fetch and analyze URLs passed in square brackets `[]`.
|
|
82
|
+
* The user can create more prompt files as needed for different personalities.
|
|
83
|
+
* `url_analysis.prmpt`
|
|
84
|
+
* Prompt template used to analyze URL content passed in brackets `[]`.
|
|
85
|
+
* `errorlogs`
|
|
86
|
+
* Contains a `tellmgrambot_error.log` file to investigate if there are problems during the interaction.
|
|
87
|
+
* User will also get notified to contact the owner.
|
|
88
|
+
* `data`
|
|
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.
|
|
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
|
+
|
|
93
|
+
### Environment Variables
|
|
94
|
+
TeLLMgramBot also creates or utilizes the following environment variables that can be pre-loaded, especially in containerized environments like Home Assistant with different persistent storage locations:
|
|
95
|
+
1. `TELLMGRAMBOT_CONFIGS_PATH` — Directory containing `config.yaml` and `models.yaml`
|
|
96
|
+
2. `TELLMGRAMBOT_PROMPTS_PATH` — Directory containing prompt files
|
|
97
|
+
3. `TELLMGRAMBOT_ERRORLOGS_PATH` — Directory for error logs
|
|
98
|
+
4. `TELLMGRAMBOT_DATA_PATH` — Directory containing `conversations.db` (e.g. `/data`). Defaults to `data/` in the execution directory.
|
|
99
|
+
|
|
100
|
+
If none are defined, all paths default to subdirectories of the execution directory (the directory containing the entry-point script).
|
|
101
|
+
|
|
102
|
+
## API Keys
|
|
103
|
+
TeLLMgramBot supports the following API keys. Each can be supplied via environment variable or `.key` file:
|
|
104
|
+
|
|
105
|
+
* **[OpenAI](https://platform.openai.com/overview)** — required when using a `gpt-*` model. Missing: chat and URL analysis disabled.
|
|
106
|
+
* **[Anthropic](https://console.anthropic.com/)** — required when using a `claude-*` model. Missing: chat and URL analysis disabled.
|
|
107
|
+
* **[Telegram](https://core.telegram.org/api)** — always required; available through BotFather. Missing: bot will not start.
|
|
108
|
+
* **[VirusTotal](https://www.virustotal.com/gui/home/)** — optional; performs safety checks on URLs. Missing: URL analysis disabled.
|
|
109
|
+
|
|
110
|
+
If a provider API key matching your configured model is missing, the bot will start but disable chat and URL analysis features. A startup summary shows which features are enabled.
|
|
111
|
+
|
|
112
|
+
### Environment Variables
|
|
113
|
+
TeLLMgramBot uses the following environment variables for API keys:
|
|
114
|
+
1. `TELLMGRAMBOT_OPENAI_API_KEY` *(OpenAI models)*
|
|
115
|
+
2. `TELLMGRAMBOT_ANTHROPIC_API_KEY` *(Anthropic models)*
|
|
116
|
+
3. `TELLMGRAMBOT_TELEGRAM_API_KEY`
|
|
117
|
+
4. `TELLMGRAMBOT_VIRUSTOTAL_API_KEY`
|
|
118
|
+
|
|
119
|
+
During spin-up time, a user can call out `os.environ[env_var]` to set those variables, like the following example:
|
|
120
|
+
```
|
|
121
|
+
my_keys = Some_Vault_Fetch_Function()
|
|
122
|
+
|
|
123
|
+
os.environ['TELLMGRAMBOT_OPENAI_API_KEY'] = my_keys['OpenAIKey']
|
|
124
|
+
os.environ['TELLMGRAMBOT_ANTHROPIC_API_KEY'] = my_keys['AnthropicKey']
|
|
125
|
+
os.environ['TELLMGRAMBOT_TELEGRAM_API_KEY'] = my_keys['BotFatherToken']
|
|
126
|
+
os.environ['TELLMGRAMBOT_VIRUSTOTAL_API_KEY'] = my_keys['VirusTotalToken']
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
This means the user can implement whatever key vault they want to fetch the keys at runtime, without needing files stored in the directory.
|
|
130
|
+
|
|
131
|
+
### API Key Files
|
|
132
|
+
By default, API key files are created in the execution directory (or the directory specified by `TELLMGRAMBOT_KEYS_PATH` for legacy deployments):
|
|
133
|
+
1. `openai.key` — OpenAI API key for GPT models
|
|
134
|
+
2. `anthropic.key` — Anthropic API key for Claude models
|
|
135
|
+
3. `telegram.key` — Telegram Bot API key
|
|
136
|
+
4. `virustotal.key` — VirusTotal API key for URL safety checks
|
|
137
|
+
|
|
138
|
+
Each file with the associated API key will update its respective environment variable if not defined. Missing provider keys (OpenAI or Anthropic) will disable chat and URL analysis but allow the bot to start. Missing VirusTotal keys will disable URL analysis.
|
|
139
|
+
|
|
140
|
+
## Bot Setup
|
|
141
|
+
This library includes an example script `test_local.py`, which uses files from the folders `configs` and `prompts` for TeLLMgramBot to process.
|
|
142
|
+
1. Ensure the previous sections are followed with the proper API keys and your Telegram bot set.
|
|
143
|
+
2. Install this library via PIP (`pip install TeLLMgramBot`) and then import into your project.
|
|
144
|
+
3. Instantiate the bot by passing in various configuration pieces needed below.
|
|
145
|
+
Note the Telegram bot's full name and username auto-populate before startup.
|
|
146
|
+
```
|
|
147
|
+
telegram_bot = TeLLMgramBot.TelegramBot(
|
|
148
|
+
bot_owner = <Bot owner's Telegram username>,
|
|
149
|
+
bot_nickname = <Bot nickname like 'Botty'>,
|
|
150
|
+
bot_initials = <Bot initials like 'FB'>,
|
|
151
|
+
chat_model = <Conversation model like 'gpt-4o-mini' or 'claude-sonnet-4-6'>,
|
|
152
|
+
url_model = <URL analysis model like 'gpt-4o' or 'claude-haiku-4-5'>,
|
|
153
|
+
token_limit = <Maximum token count set, by default chat_model max>,
|
|
154
|
+
persona_temp = <LLM factual to creative value [0-2], by default 1.0>,
|
|
155
|
+
persona_prompt = <System prompt summarizing bot personality>
|
|
156
|
+
)
|
|
157
|
+
```
|
|
158
|
+
4. Turn on TeLLMgramBot by calling:
|
|
159
|
+
```
|
|
160
|
+
telegram_bot.start_polling()
|
|
161
|
+
```
|
|
162
|
+
Once you see `TeLLMgramBot polling...`, the bot is online in Telegram.
|
|
163
|
+
5. Converse! Type `/help` for all available commands.
|
|
164
|
+
|
|
165
|
+
## Resources
|
|
166
|
+
* GitHub repository [python-telegram-bot](https://github.com/python-telegram-bot/python-telegram-bot) has guides to create a Telegram bot.
|
|
167
|
+
* For more information on OpenAI models and token limits:
|
|
168
|
+
* [OpenAI model overview and maximum tokens](https://platform.openai.com/docs/models)
|
|
169
|
+
* [OpenAI message conversion to tokens](https://github.com/openai/openai-python)
|
|
170
|
+
* [OpenAI custom fine-tuning](https://platform.openai.com/docs/guides/model-optimization)
|
|
171
|
+
* [OpenAI's tiktoken library](https://github.com/openai/tiktoken/tree/main)
|
|
172
|
+
* For more information on Anthropic Claude models:
|
|
173
|
+
* [Anthropic model overview and context windows](https://docs.anthropic.com/en/docs/about-claude/models)
|
|
174
|
+
* [Anthropic Python SDK](https://github.com/anthropic/anthropic-sdk-python)
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
LICENSE
|
|
2
|
-
MANIFEST.in
|
|
3
2
|
README.md
|
|
4
|
-
requirements.txt
|
|
5
3
|
setup.py
|
|
6
4
|
TeLLMgramBot/TeLLMgramBot.py
|
|
7
5
|
TeLLMgramBot/__init__.py
|
|
@@ -12,12 +10,13 @@ TeLLMgramBot/message_handlers.py
|
|
|
12
10
|
TeLLMgramBot/models.py
|
|
13
11
|
TeLLMgramBot/utils.py
|
|
14
12
|
TeLLMgramBot/web_utils.py
|
|
13
|
+
TeLLMgramBot.egg-info/PKG-INFO
|
|
14
|
+
TeLLMgramBot.egg-info/SOURCES.txt
|
|
15
|
+
TeLLMgramBot.egg-info/dependency_links.txt
|
|
16
|
+
TeLLMgramBot.egg-info/requires.txt
|
|
17
|
+
TeLLMgramBot.egg-info/top_level.txt
|
|
15
18
|
TeLLMgramBot/providers/__init__.py
|
|
16
19
|
TeLLMgramBot/providers/anthropic_provider.py
|
|
17
20
|
TeLLMgramBot/providers/base.py
|
|
18
21
|
TeLLMgramBot/providers/factory.py
|
|
19
|
-
TeLLMgramBot/providers/openai_provider.py
|
|
20
|
-
Tests/test_api_key_status.py
|
|
21
|
-
Tests/test_conversation_db.py
|
|
22
|
-
Tests/test_providers.py
|
|
23
|
-
Tests/test_web_utils.py
|
|
22
|
+
TeLLMgramBot/providers/openai_provider.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
TeLLMgramBot
|
tellmgrambot-3.0.2/MANIFEST.in
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
# Include top-level docs
|
|
2
|
-
include README.md
|
|
3
|
-
include LICENSE
|
|
4
|
-
include requirements.txt
|
|
5
|
-
|
|
6
|
-
# Exclude development/CI-only files and directories
|
|
7
|
-
exclude CLAUDE.md
|
|
8
|
-
exclude RELEASE_NOTES.md
|
|
9
|
-
exclude test_local.py
|
|
10
|
-
exclude deploy_local.py
|
|
11
|
-
recursive-exclude .claude *
|
|
12
|
-
recursive-exclude .beans *
|
|
13
|
-
recursive-include Tests *.py
|
|
14
|
-
recursive-exclude configs *
|
|
15
|
-
recursive-exclude prompts *
|
|
16
|
-
recursive-exclude errorLogs *
|
|
17
|
-
recursive-exclude keys *
|
|
18
|
-
recursive-exclude data *
|
|
19
|
-
recursive-exclude assets *
|
|
20
|
-
recursive-exclude build *
|
|
21
|
-
recursive-exclude *.egg-info *
|
|
@@ -1,234 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import unittest
|
|
3
|
-
from unittest.mock import patch, MagicMock
|
|
4
|
-
from TeLLMgramBot.initialize import ApiKeyStatus, init_keys, print_api_key_status, _model_provider
|
|
5
|
-
|
|
6
|
-
# ---------------------------------------------------------------------------
|
|
7
|
-
# Helpers
|
|
8
|
-
# ---------------------------------------------------------------------------
|
|
9
|
-
|
|
10
|
-
TELEGRAM_KEY = 'TELLMGRAMBOT_TELEGRAM_API_KEY'
|
|
11
|
-
VIRUSTOTAL_KEY = 'TELLMGRAMBOT_VIRUSTOTAL_API_KEY'
|
|
12
|
-
OPENAI_KEY = 'TELLMGRAMBOT_OPENAI_API_KEY'
|
|
13
|
-
ANTHROPIC_KEY = 'TELLMGRAMBOT_ANTHROPIC_API_KEY'
|
|
14
|
-
|
|
15
|
-
BASE_ENV = {
|
|
16
|
-
TELEGRAM_KEY: 'tg-key',
|
|
17
|
-
VIRUSTOTAL_KEY: 'vt-key',
|
|
18
|
-
OPENAI_KEY: 'openai-key',
|
|
19
|
-
ANTHROPIC_KEY: 'anthropic-key',
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
OPENAI_CONFIG = {'chat_model': 'gpt-5-mini', 'url_model': 'gpt-5'}
|
|
23
|
-
ANTHROPIC_CONFIG = {'chat_model': 'claude-sonnet-4-6', 'url_model': 'claude-haiku-4-5'}
|
|
24
|
-
MIXED_CONFIG = {'chat_model': 'gpt-5-mini', 'url_model': 'claude-haiku-4-5'}
|
|
25
|
-
|
|
26
|
-
def _make_requires_provider(config: dict):
|
|
27
|
-
"""Build a _requires_provider return value from a config dict."""
|
|
28
|
-
needs = {'openai': False, 'anthropic': False}
|
|
29
|
-
for field in ('chat_model', 'url_model'):
|
|
30
|
-
model = config.get(field, '')
|
|
31
|
-
if model.startswith('claude-'):
|
|
32
|
-
needs['anthropic'] = True
|
|
33
|
-
elif model:
|
|
34
|
-
needs['openai'] = True
|
|
35
|
-
return needs
|
|
36
|
-
|
|
37
|
-
# ---------------------------------------------------------------------------
|
|
38
|
-
# _model_provider helper
|
|
39
|
-
# ---------------------------------------------------------------------------
|
|
40
|
-
|
|
41
|
-
class TestModelProvider(unittest.TestCase):
|
|
42
|
-
"""Tests for _model_provider() — maps a model name string to its provider name."""
|
|
43
|
-
|
|
44
|
-
def test_openai_model(self):
|
|
45
|
-
"""A gpt-* model name must return 'openai'."""
|
|
46
|
-
self.assertEqual(_model_provider('gpt-5-mini'), 'openai')
|
|
47
|
-
|
|
48
|
-
def test_anthropic_model(self):
|
|
49
|
-
"""A claude-* model name must return 'anthropic'."""
|
|
50
|
-
self.assertEqual(_model_provider('claude-sonnet-4-6'), 'anthropic')
|
|
51
|
-
|
|
52
|
-
def test_empty_model(self):
|
|
53
|
-
"""An empty string must return None (no provider required)."""
|
|
54
|
-
self.assertIsNone(_model_provider(''))
|
|
55
|
-
|
|
56
|
-
def test_none_model(self):
|
|
57
|
-
"""None must return None (no provider required)."""
|
|
58
|
-
self.assertIsNone(_model_provider(None))
|
|
59
|
-
|
|
60
|
-
# ---------------------------------------------------------------------------
|
|
61
|
-
# ApiKeyStatus defaults
|
|
62
|
-
# ---------------------------------------------------------------------------
|
|
63
|
-
|
|
64
|
-
class TestApiKeyStatusDefaults(unittest.TestCase):
|
|
65
|
-
"""Tests for ApiKeyStatus dataclass default values."""
|
|
66
|
-
|
|
67
|
-
def test_all_features_enabled_by_default(self):
|
|
68
|
-
"""A freshly constructed ApiKeyStatus must have all features enabled and no disabled reasons."""
|
|
69
|
-
fm = ApiKeyStatus()
|
|
70
|
-
self.assertTrue(fm.chat_enabled)
|
|
71
|
-
self.assertTrue(fm.url_analysis_enabled)
|
|
72
|
-
self.assertEqual(fm.disabled_reasons, {})
|
|
73
|
-
|
|
74
|
-
# ---------------------------------------------------------------------------
|
|
75
|
-
# init_keys — all keys present
|
|
76
|
-
# ---------------------------------------------------------------------------
|
|
77
|
-
|
|
78
|
-
class TestInitKeysAllPresent(unittest.TestCase):
|
|
79
|
-
"""Tests for init_keys() when all required API keys are present."""
|
|
80
|
-
|
|
81
|
-
@patch('TeLLMgramBot.initialize.read_yaml', return_value=OPENAI_CONFIG)
|
|
82
|
-
@patch('TeLLMgramBot.initialize._requires_provider', return_value=_make_requires_provider(OPENAI_CONFIG))
|
|
83
|
-
@patch('TeLLMgramBot.initialize.read_text', return_value='fake-key')
|
|
84
|
-
@patch('TeLLMgramBot.initialize.generate_file_path')
|
|
85
|
-
@patch('os.path.exists', return_value=True)
|
|
86
|
-
def test_all_features_enabled_when_all_keys_present(self, mock_exists, mock_gen, mock_read, mock_req, mock_yaml):
|
|
87
|
-
"""All features must be enabled when all API keys are available."""
|
|
88
|
-
with patch.dict(os.environ, BASE_ENV, clear=False):
|
|
89
|
-
fm = init_keys()
|
|
90
|
-
self.assertTrue(fm.chat_enabled)
|
|
91
|
-
self.assertTrue(fm.url_analysis_enabled)
|
|
92
|
-
self.assertEqual(fm.disabled_reasons, {})
|
|
93
|
-
|
|
94
|
-
# ---------------------------------------------------------------------------
|
|
95
|
-
# init_keys — Telegram key missing → sys.exit
|
|
96
|
-
# ---------------------------------------------------------------------------
|
|
97
|
-
|
|
98
|
-
class TestInitKeysTelegramMissing(unittest.TestCase):
|
|
99
|
-
"""Tests for init_keys() hard exit when the Telegram key is missing."""
|
|
100
|
-
|
|
101
|
-
@patch('TeLLMgramBot.initialize.read_yaml', return_value=OPENAI_CONFIG)
|
|
102
|
-
@patch('TeLLMgramBot.initialize._requires_provider', return_value=_make_requires_provider(OPENAI_CONFIG))
|
|
103
|
-
@patch('TeLLMgramBot.initialize.generate_file_path')
|
|
104
|
-
@patch('os.path.exists', return_value=False)
|
|
105
|
-
def test_exits_when_telegram_key_missing(self, mock_exists, mock_gen, mock_req, mock_yaml):
|
|
106
|
-
"""init_keys() must call sys.exit() when the Telegram API key is absent — it is always required."""
|
|
107
|
-
env = {k: v for k, v in BASE_ENV.items() if k != TELEGRAM_KEY}
|
|
108
|
-
with patch.dict(os.environ, env, clear=False):
|
|
109
|
-
os.environ.pop(TELEGRAM_KEY, None)
|
|
110
|
-
with self.assertRaises(SystemExit):
|
|
111
|
-
init_keys()
|
|
112
|
-
|
|
113
|
-
# ---------------------------------------------------------------------------
|
|
114
|
-
# init_keys — provider key missing → feature disabled
|
|
115
|
-
# ---------------------------------------------------------------------------
|
|
116
|
-
|
|
117
|
-
class TestInitKeysProviderMissing(unittest.TestCase):
|
|
118
|
-
"""Tests for init_keys() graceful degradation when a provider key (OpenAI/Anthropic) is missing."""
|
|
119
|
-
|
|
120
|
-
@patch('TeLLMgramBot.initialize.read_yaml', return_value=OPENAI_CONFIG)
|
|
121
|
-
@patch('TeLLMgramBot.initialize._requires_provider', return_value=_make_requires_provider(OPENAI_CONFIG))
|
|
122
|
-
@patch('TeLLMgramBot.initialize.generate_file_path')
|
|
123
|
-
@patch('os.path.exists', return_value=False)
|
|
124
|
-
def test_chat_disabled_when_openai_key_missing(self, mock_exists, mock_gen, mock_req, mock_yaml):
|
|
125
|
-
"""Missing OpenAI key must disable both chat and URL analysis when an OpenAI model is configured."""
|
|
126
|
-
env = {k: v for k, v in BASE_ENV.items() if k != OPENAI_KEY}
|
|
127
|
-
with patch.dict(os.environ, env, clear=False):
|
|
128
|
-
os.environ.pop(OPENAI_KEY, None)
|
|
129
|
-
fm = init_keys()
|
|
130
|
-
self.assertFalse(fm.chat_enabled)
|
|
131
|
-
self.assertFalse(fm.url_analysis_enabled)
|
|
132
|
-
self.assertIn('chat', fm.disabled_reasons)
|
|
133
|
-
|
|
134
|
-
@patch('TeLLMgramBot.initialize.read_yaml', return_value=ANTHROPIC_CONFIG)
|
|
135
|
-
@patch('TeLLMgramBot.initialize._requires_provider', return_value=_make_requires_provider(ANTHROPIC_CONFIG))
|
|
136
|
-
@patch('TeLLMgramBot.initialize.generate_file_path')
|
|
137
|
-
@patch('os.path.exists', return_value=False)
|
|
138
|
-
def test_chat_disabled_when_anthropic_key_missing(self, mock_exists, mock_gen, mock_req, mock_yaml):
|
|
139
|
-
"""Missing Anthropic key must disable both chat and URL analysis when a Claude model is configured."""
|
|
140
|
-
env = {k: v for k, v in BASE_ENV.items() if k != ANTHROPIC_KEY}
|
|
141
|
-
with patch.dict(os.environ, env, clear=False):
|
|
142
|
-
os.environ.pop(ANTHROPIC_KEY, None)
|
|
143
|
-
fm = init_keys()
|
|
144
|
-
self.assertFalse(fm.chat_enabled)
|
|
145
|
-
self.assertFalse(fm.url_analysis_enabled)
|
|
146
|
-
self.assertIn('chat', fm.disabled_reasons)
|
|
147
|
-
|
|
148
|
-
@patch('TeLLMgramBot.initialize.read_yaml', return_value=OPENAI_CONFIG)
|
|
149
|
-
@patch('TeLLMgramBot.initialize._requires_provider', return_value=_make_requires_provider(OPENAI_CONFIG))
|
|
150
|
-
def test_does_not_exit_when_only_provider_key_missing(self, mock_req, mock_yaml):
|
|
151
|
-
"""init_keys() must not call sys.exit() when only a provider key is missing — graceful degradation."""
|
|
152
|
-
env = {k: v for k, v in BASE_ENV.items() if k != OPENAI_KEY}
|
|
153
|
-
with patch.dict(os.environ, env, clear=False):
|
|
154
|
-
os.environ.pop(OPENAI_KEY, None)
|
|
155
|
-
with patch('TeLLMgramBot.initialize.generate_file_path'):
|
|
156
|
-
with patch('os.path.exists', return_value=False):
|
|
157
|
-
try:
|
|
158
|
-
fm = init_keys()
|
|
159
|
-
except SystemExit:
|
|
160
|
-
self.fail("init_keys() should not exit when only a provider key is missing")
|
|
161
|
-
|
|
162
|
-
# ---------------------------------------------------------------------------
|
|
163
|
-
# init_keys — VirusTotal key missing → virustotal + url_analysis disabled
|
|
164
|
-
# ---------------------------------------------------------------------------
|
|
165
|
-
|
|
166
|
-
class TestInitKeysVirusTotalMissing(unittest.TestCase):
|
|
167
|
-
"""Tests for init_keys() graceful degradation when the VirusTotal key is missing."""
|
|
168
|
-
|
|
169
|
-
@patch('TeLLMgramBot.initialize.read_yaml', return_value=OPENAI_CONFIG)
|
|
170
|
-
@patch('TeLLMgramBot.initialize._requires_provider', return_value=_make_requires_provider(OPENAI_CONFIG))
|
|
171
|
-
@patch('TeLLMgramBot.initialize.generate_file_path')
|
|
172
|
-
@patch('os.path.exists', return_value=False)
|
|
173
|
-
def test_virustotal_and_url_analysis_disabled_when_vt_key_missing(self, mock_exists, mock_gen, mock_req, mock_yaml):
|
|
174
|
-
"""Missing VirusTotal key must disable url_analysis while leaving chat enabled."""
|
|
175
|
-
env = {k: v for k, v in BASE_ENV.items() if k != VIRUSTOTAL_KEY}
|
|
176
|
-
with patch.dict(os.environ, env, clear=False):
|
|
177
|
-
os.environ.pop(VIRUSTOTAL_KEY, None)
|
|
178
|
-
fm = init_keys()
|
|
179
|
-
self.assertTrue(fm.chat_enabled)
|
|
180
|
-
self.assertFalse(fm.url_analysis_enabled)
|
|
181
|
-
self.assertIn('url_analysis', fm.disabled_reasons)
|
|
182
|
-
|
|
183
|
-
# ---------------------------------------------------------------------------
|
|
184
|
-
# init_keys — both provider and VirusTotal keys missing
|
|
185
|
-
# ---------------------------------------------------------------------------
|
|
186
|
-
|
|
187
|
-
class TestInitKeysBothProviderAndVTMissing(unittest.TestCase):
|
|
188
|
-
"""Tests for init_keys() when both the provider key and VirusTotal key are missing."""
|
|
189
|
-
|
|
190
|
-
@patch('TeLLMgramBot.initialize.read_yaml', return_value=OPENAI_CONFIG)
|
|
191
|
-
@patch('TeLLMgramBot.initialize._requires_provider', return_value=_make_requires_provider(OPENAI_CONFIG))
|
|
192
|
-
@patch('TeLLMgramBot.initialize.generate_file_path')
|
|
193
|
-
@patch('os.path.exists', return_value=False)
|
|
194
|
-
def test_url_analysis_disabled_when_both_provider_and_vt_keys_missing(self, mock_exists, mock_gen, mock_req, mock_yaml):
|
|
195
|
-
"""When both the provider key and VirusTotal key are missing, url_analysis must be disabled with the provider key cited as the reason."""
|
|
196
|
-
env = {k: v for k, v in BASE_ENV.items() if k not in (OPENAI_KEY, VIRUSTOTAL_KEY)}
|
|
197
|
-
with patch.dict(os.environ, env, clear=False):
|
|
198
|
-
os.environ.pop(OPENAI_KEY, None)
|
|
199
|
-
os.environ.pop(VIRUSTOTAL_KEY, None)
|
|
200
|
-
fm = init_keys()
|
|
201
|
-
self.assertFalse(fm.chat_enabled)
|
|
202
|
-
self.assertFalse(fm.url_analysis_enabled)
|
|
203
|
-
self.assertIn('openai', fm.disabled_reasons.get('url_analysis', ''))
|
|
204
|
-
|
|
205
|
-
# ---------------------------------------------------------------------------
|
|
206
|
-
# print_api_key_status output
|
|
207
|
-
# ---------------------------------------------------------------------------
|
|
208
|
-
|
|
209
|
-
class TestPrintApiKeyStatus(unittest.TestCase):
|
|
210
|
-
"""Tests for print_api_key_status() startup output formatting."""
|
|
211
|
-
|
|
212
|
-
def test_all_enabled_output(self):
|
|
213
|
-
"""When all features are enabled, output must contain ENABLED and no DISABLED lines."""
|
|
214
|
-
fm = ApiKeyStatus()
|
|
215
|
-
with patch('builtins.print') as mock_print:
|
|
216
|
-
print_api_key_status(fm)
|
|
217
|
-
output = ' '.join(str(c) for c in [call.args[0] for call in mock_print.call_args_list])
|
|
218
|
-
self.assertIn('ENABLED', output)
|
|
219
|
-
self.assertNotIn('DISABLED', output)
|
|
220
|
-
|
|
221
|
-
def test_disabled_feature_shows_reason(self):
|
|
222
|
-
"""A disabled feature must appear as DISABLED with its reason string in the output."""
|
|
223
|
-
fm = ApiKeyStatus(
|
|
224
|
-
chat_enabled=False,
|
|
225
|
-
disabled_reasons={'chat': 'missing openai API key'}
|
|
226
|
-
)
|
|
227
|
-
with patch('builtins.print') as mock_print:
|
|
228
|
-
print_api_key_status(fm)
|
|
229
|
-
output = ' '.join(str(call.args[0]) for call in mock_print.call_args_list)
|
|
230
|
-
self.assertIn('DISABLED', output)
|
|
231
|
-
self.assertIn('missing openai API key', output)
|
|
232
|
-
|
|
233
|
-
if __name__ == '__main__':
|
|
234
|
-
unittest.main()
|