mm-telegram 0.3.1__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.
- mm_telegram-0.3.1/.env.example +2 -0
- mm_telegram-0.3.1/.gitignore +15 -0
- mm_telegram-0.3.1/.pre-commit-config.yaml +10 -0
- mm_telegram-0.3.1/PKG-INFO +6 -0
- mm_telegram-0.3.1/README.md +85 -0
- mm_telegram-0.3.1/justfile +40 -0
- mm_telegram-0.3.1/pyproject.toml +73 -0
- mm_telegram-0.3.1/src/mm_telegram/__init__.py +4 -0
- mm_telegram-0.3.1/src/mm_telegram/bot.py +95 -0
- mm_telegram-0.3.1/src/mm_telegram/message.py +63 -0
- mm_telegram-0.3.1/src/mm_telegram/py.typed +0 -0
- mm_telegram-0.3.1/tests/__init__.py +0 -0
- mm_telegram-0.3.1/tests/conftest.py +23 -0
- mm_telegram-0.3.1/tests/test_bot.py +262 -0
- mm_telegram-0.3.1/tests/test_message.py +18 -0
- mm_telegram-0.3.1/uv.lock +1022 -0
@@ -0,0 +1,85 @@
|
|
1
|
+
# mm-telegram
|
2
|
+
|
3
|
+
A Python library for building Telegram bots with type safety and modern async/await patterns.
|
4
|
+
|
5
|
+
## Features
|
6
|
+
|
7
|
+
- **Type-safe**: Full type annotations with mypy strict mode support
|
8
|
+
- **Async/await**: Built on python-telegram-bot with modern async patterns
|
9
|
+
- **Message splitting**: Automatic handling of long messages (>4096 chars)
|
10
|
+
- **Admin control**: Built-in admin authorization system
|
11
|
+
- **Simple API**: Minimal boilerplate for common bot operations
|
12
|
+
|
13
|
+
## Quick Start
|
14
|
+
|
15
|
+
### Basic Bot
|
16
|
+
|
17
|
+
```python
|
18
|
+
from mm_telegram import TelegramBot
|
19
|
+
from telegram.ext import CommandHandler
|
20
|
+
|
21
|
+
async def hello(update, context):
|
22
|
+
await update.message.reply_text("Hello!")
|
23
|
+
|
24
|
+
bot = TelegramBot(
|
25
|
+
handlers=[CommandHandler("hello", hello)],
|
26
|
+
bot_data={}
|
27
|
+
)
|
28
|
+
|
29
|
+
await bot.start(token="YOUR_BOT_TOKEN", admins=[YOUR_USER_ID])
|
30
|
+
```
|
31
|
+
|
32
|
+
### Send Messages
|
33
|
+
|
34
|
+
```python
|
35
|
+
from mm_telegram import send_message
|
36
|
+
|
37
|
+
result = await send_message(
|
38
|
+
bot_token="YOUR_BOT_TOKEN",
|
39
|
+
chat_id=123456789,
|
40
|
+
message="Your message here"
|
41
|
+
)
|
42
|
+
|
43
|
+
if result.is_ok():
|
44
|
+
message_ids = result.value
|
45
|
+
print(f"Sent messages: {message_ids}")
|
46
|
+
```
|
47
|
+
|
48
|
+
## API Reference
|
49
|
+
|
50
|
+
### TelegramBot
|
51
|
+
|
52
|
+
Main bot wrapper that manages application lifecycle.
|
53
|
+
|
54
|
+
```python
|
55
|
+
bot = TelegramBot(handlers, bot_data)
|
56
|
+
await bot.start(token, admins)
|
57
|
+
await bot.shutdown()
|
58
|
+
```
|
59
|
+
|
60
|
+
- `handlers`: List of telegram handlers (CommandHandler, MessageHandler, etc.)
|
61
|
+
- `bot_data`: Initial bot data dictionary
|
62
|
+
- `token`: Bot token from @BotFather
|
63
|
+
- `admins`: List of admin user IDs
|
64
|
+
|
65
|
+
### send_message
|
66
|
+
|
67
|
+
Send messages with automatic splitting for long text.
|
68
|
+
|
69
|
+
```python
|
70
|
+
result = await send_message(
|
71
|
+
bot_token="token",
|
72
|
+
chat_id=123,
|
73
|
+
message="text",
|
74
|
+
timeout=5,
|
75
|
+
inter_message_delay_seconds=3
|
76
|
+
)
|
77
|
+
```
|
78
|
+
|
79
|
+
Returns `Result[list[int]]` with message IDs on success.
|
80
|
+
|
81
|
+
### Built-in Handlers
|
82
|
+
|
83
|
+
- `/ping` - Responds with "pong"
|
84
|
+
- Unknown commands - "Sorry, I didn't understand that command."
|
85
|
+
- Admin check - Blocks non-admin users
|
@@ -0,0 +1,40 @@
|
|
1
|
+
version := `uv run python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])'`
|
2
|
+
|
3
|
+
|
4
|
+
clean:
|
5
|
+
rm -rf .pytest_cache .mypy_cache .ruff_cache .coverage dist build src/*.egg-info
|
6
|
+
|
7
|
+
build: clean
|
8
|
+
uv build
|
9
|
+
|
10
|
+
format:
|
11
|
+
uv run ruff check --select I --fix src tests
|
12
|
+
uv run ruff format src tests
|
13
|
+
|
14
|
+
test:
|
15
|
+
uv run pytest -n auto tests
|
16
|
+
|
17
|
+
lint: format
|
18
|
+
uv run ruff check src tests
|
19
|
+
uv run mypy src
|
20
|
+
|
21
|
+
audit:
|
22
|
+
uv export --no-dev --all-extras --format requirements-txt --no-emit-project > requirements.txt
|
23
|
+
uv run pip-audit -r requirements.txt --disable-pip
|
24
|
+
rm requirements.txt
|
25
|
+
uv run bandit --silent --recursive --configfile "pyproject.toml" src
|
26
|
+
|
27
|
+
publish: build lint audit test
|
28
|
+
git diff-index --quiet HEAD
|
29
|
+
uvx twine upload dist/**
|
30
|
+
git tag -a 'v{{version}}' -m 'v{{version}}'
|
31
|
+
git push origin v{{version}}
|
32
|
+
|
33
|
+
sync:
|
34
|
+
uv sync --all-extras
|
35
|
+
|
36
|
+
pre-commit:
|
37
|
+
uv run pre-commit run --all-files
|
38
|
+
|
39
|
+
pre-commit-autoupdate:
|
40
|
+
uv run pre-commit autoupdate
|
@@ -0,0 +1,73 @@
|
|
1
|
+
[project]
|
2
|
+
name = "mm-telegram"
|
3
|
+
version = "0.3.1"
|
4
|
+
description = ""
|
5
|
+
requires-python = ">=3.13"
|
6
|
+
dependencies = [
|
7
|
+
"mm-http~=0.1.0",
|
8
|
+
"python-telegram-bot~=22.1",
|
9
|
+
]
|
10
|
+
|
11
|
+
[build-system]
|
12
|
+
requires = ["hatchling"]
|
13
|
+
build-backend = "hatchling.build"
|
14
|
+
|
15
|
+
[tool.uv]
|
16
|
+
dev-dependencies = [
|
17
|
+
"pytest~=8.4.0",
|
18
|
+
"pytest-asyncio~=1.0.0",
|
19
|
+
"pytest-xdist~=3.7.0",
|
20
|
+
"ruff~=0.11.13",
|
21
|
+
"pip-audit~=2.9.0",
|
22
|
+
"bandit~=1.8.3",
|
23
|
+
"mypy~=1.16.0",
|
24
|
+
"pre-commit~=4.2.0",
|
25
|
+
"python-dotenv>=1.1.0",
|
26
|
+
]
|
27
|
+
[tool.mypy]
|
28
|
+
python_version = "3.13"
|
29
|
+
warn_no_return = false
|
30
|
+
strict = true
|
31
|
+
exclude = ["^tests/", "^tmp/"]
|
32
|
+
|
33
|
+
[tool.ruff]
|
34
|
+
line-length = 130
|
35
|
+
target-version = "py313"
|
36
|
+
[tool.ruff.lint]
|
37
|
+
select = ["ALL"]
|
38
|
+
ignore = [
|
39
|
+
"TC", # flake8-type-checking, TYPE_CHECKING is dangerous, for example it doesn't work with pydantic
|
40
|
+
"A005", # flake8-builtins: stdlib-module-shadowing
|
41
|
+
"ERA001", # eradicate: commented-out-code
|
42
|
+
"PT", # flake8-pytest-style
|
43
|
+
"D", # pydocstyle
|
44
|
+
"FIX", # flake8-fixme
|
45
|
+
"PLR0911", # refactor: too-many-return-statements
|
46
|
+
"PLR0913", # pylint: too-many-arguments
|
47
|
+
"PLR2004", # pylint: magic-value-comparison
|
48
|
+
"PLC0414", # pylint: useless-import-alias
|
49
|
+
"FBT", # flake8-boolean-trap
|
50
|
+
"EM", # flake8-errmsg
|
51
|
+
"TRY003", # tryceratops: raise-vanilla-args
|
52
|
+
"C901", # mccabe: complex-structure,
|
53
|
+
"BLE001", # flake8-blind-except
|
54
|
+
"S311", # bandit: suspicious-non-cryptographic-random-usage
|
55
|
+
"TD002", # flake8-todos: missing-todo-author
|
56
|
+
"TD003", # flake8-todos: missing-todo-link
|
57
|
+
"RET503", # flake8-return: implicit-return
|
58
|
+
"COM812", # it's used in ruff formatter
|
59
|
+
"ASYNC109",
|
60
|
+
]
|
61
|
+
[tool.ruff.lint.per-file-ignores]
|
62
|
+
"tests/*.py" = ["ANN", "S"]
|
63
|
+
[tool.ruff.format]
|
64
|
+
quote-style = "double"
|
65
|
+
indent-style = "space"
|
66
|
+
|
67
|
+
[tool.bandit]
|
68
|
+
exclude_dirs = ["tests"]
|
69
|
+
skips = ["B311"]
|
70
|
+
|
71
|
+
[tool.pytest.ini_options]
|
72
|
+
asyncio_mode = "auto"
|
73
|
+
asyncio_default_fixture_loop_scope = "function"
|
@@ -0,0 +1,95 @@
|
|
1
|
+
import logging
|
2
|
+
from typing import Any
|
3
|
+
|
4
|
+
from telegram import Update
|
5
|
+
from telegram.ext import (
|
6
|
+
Application,
|
7
|
+
ApplicationBuilder,
|
8
|
+
ApplicationHandlerStop,
|
9
|
+
BaseHandler,
|
10
|
+
CallbackContext,
|
11
|
+
CommandHandler,
|
12
|
+
ContextTypes,
|
13
|
+
ExtBot,
|
14
|
+
MessageHandler,
|
15
|
+
filters,
|
16
|
+
)
|
17
|
+
|
18
|
+
logger = logging.getLogger(__name__)
|
19
|
+
|
20
|
+
|
21
|
+
type TelegramHandler = BaseHandler[Any, CallbackContext[ExtBot[None], dict[Any, Any], dict[Any, Any], dict[Any, Any]], Any]
|
22
|
+
|
23
|
+
|
24
|
+
async def ping(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
25
|
+
"""Responds with 'pong' to /ping command."""
|
26
|
+
if update.effective_chat is not None:
|
27
|
+
await context.bot.send_message(chat_id=update.effective_chat.id, text="pong")
|
28
|
+
|
29
|
+
|
30
|
+
async def unknown(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
31
|
+
"""Handles unknown commands with a default response."""
|
32
|
+
if update.effective_chat is not None:
|
33
|
+
await context.bot.send_message(chat_id=update.effective_chat.id, text="Sorry, I didn't understand that command.")
|
34
|
+
|
35
|
+
|
36
|
+
async def is_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
37
|
+
"""Checks if user is admin and blocks access if not.
|
38
|
+
|
39
|
+
Raises ApplicationHandlerStop if user is not in the admins list.
|
40
|
+
"""
|
41
|
+
admins: list[int] = context.bot_data.get("admins", [])
|
42
|
+
|
43
|
+
if update.effective_user is None or update.message is None:
|
44
|
+
raise ApplicationHandlerStop
|
45
|
+
|
46
|
+
if update.effective_user.id not in admins:
|
47
|
+
logger.warning("is not admin", extra={"telegram_user_id": update.effective_user.id})
|
48
|
+
await update.message.reply_text("Who are you?")
|
49
|
+
raise ApplicationHandlerStop
|
50
|
+
|
51
|
+
|
52
|
+
class TelegramBot:
|
53
|
+
"""Telegram bot wrapper that manages application lifecycle and handlers."""
|
54
|
+
|
55
|
+
app: Application[Any, Any, Any, Any, Any, Any] | None
|
56
|
+
|
57
|
+
def __init__(self, handlers: list[TelegramHandler], bot_data: dict[str, object]) -> None:
|
58
|
+
"""Initialize bot with custom handlers and initial bot data."""
|
59
|
+
self.handlers = handlers
|
60
|
+
self.bot_data = bot_data
|
61
|
+
self.app = None
|
62
|
+
|
63
|
+
async def start(self, token: str, admins: list[int]) -> None:
|
64
|
+
"""Start the bot with given token and admin list.
|
65
|
+
|
66
|
+
Raises ValueError if no admins are provided.
|
67
|
+
"""
|
68
|
+
if not admins:
|
69
|
+
raise ValueError("No admins provided")
|
70
|
+
logger.debug("Starting telegram bot...")
|
71
|
+
app = ApplicationBuilder().token(token).build()
|
72
|
+
for key, value in self.bot_data.items():
|
73
|
+
app.bot_data[key] = value
|
74
|
+
app.bot_data["admins"] = admins
|
75
|
+
|
76
|
+
for handler in self.handlers:
|
77
|
+
app.add_handler(handler)
|
78
|
+
|
79
|
+
app.add_handler(CommandHandler("ping", ping))
|
80
|
+
app.add_handler(MessageHandler(filters.COMMAND, unknown))
|
81
|
+
|
82
|
+
await app.initialize()
|
83
|
+
await app.start()
|
84
|
+
if app.updater is not None:
|
85
|
+
await app.updater.start_polling()
|
86
|
+
logger.debug("Telegram bot started.")
|
87
|
+
|
88
|
+
self.app = app
|
89
|
+
|
90
|
+
async def shutdown(self) -> None:
|
91
|
+
"""Stop the bot and clean up resources."""
|
92
|
+
if self.app is not None:
|
93
|
+
await self.app.shutdown()
|
94
|
+
self.app = None
|
95
|
+
logger.debug("Telegram bot stopped.")
|
@@ -0,0 +1,63 @@
|
|
1
|
+
import asyncio
|
2
|
+
|
3
|
+
from mm_http import http_request
|
4
|
+
from mm_result import Result
|
5
|
+
|
6
|
+
|
7
|
+
async def send_message(
|
8
|
+
bot_token: str,
|
9
|
+
chat_id: int,
|
10
|
+
message: str,
|
11
|
+
timeout: float = 5,
|
12
|
+
inter_message_delay_seconds: int = 3,
|
13
|
+
) -> Result[list[int]]:
|
14
|
+
"""
|
15
|
+
Sends a message to a Telegram chat.
|
16
|
+
|
17
|
+
If the message exceeds the Telegram character limit (4096),
|
18
|
+
it will be split into multiple messages and sent sequentially
|
19
|
+
with a delay between each part.
|
20
|
+
|
21
|
+
Args:
|
22
|
+
bot_token: The Telegram bot token.
|
23
|
+
chat_id: The target chat ID.
|
24
|
+
message: The message text to send.
|
25
|
+
timeout: The HTTP request timeout in seconds. Defaults to 5.
|
26
|
+
inter_message_delay_seconds: The delay in seconds between sending
|
27
|
+
parts of a long message. Defaults to 3.
|
28
|
+
|
29
|
+
Returns:
|
30
|
+
A Result object containing a list of message IDs for the sent messages
|
31
|
+
on success, or an error details on failure. The 'extra' field in the
|
32
|
+
Result contains the raw responses from the Telegram API.
|
33
|
+
"""
|
34
|
+
messages = _split_string(message, 4096)
|
35
|
+
responses = []
|
36
|
+
result_message_ids = []
|
37
|
+
while True:
|
38
|
+
text_part = messages.pop(0)
|
39
|
+
params = {"chat_id": chat_id, "text": text_part}
|
40
|
+
res = await http_request(
|
41
|
+
f"https://api.telegram.org/bot{bot_token}/sendMessage", method="post", json=params, timeout=timeout
|
42
|
+
)
|
43
|
+
responses.append(res.to_dict())
|
44
|
+
if res.is_err():
|
45
|
+
return Result.err(res.error or "error sending message", extra={"responses": responses})
|
46
|
+
|
47
|
+
message_id = res.parse_json_body("result.message_id", none_on_error=True)
|
48
|
+
if message_id:
|
49
|
+
result_message_ids.append(message_id)
|
50
|
+
else:
|
51
|
+
# Log the unexpected response for debugging?
|
52
|
+
return Result.err("unknown_response_structure", extra={"responses": responses})
|
53
|
+
|
54
|
+
if len(messages):
|
55
|
+
await asyncio.sleep(inter_message_delay_seconds)
|
56
|
+
else:
|
57
|
+
break
|
58
|
+
return Result.ok(result_message_ids, extra={"responses": responses})
|
59
|
+
|
60
|
+
|
61
|
+
def _split_string(text: str, chars_per_string: int) -> list[str]:
|
62
|
+
"""Splits a string into a list of strings, each with a maximum length."""
|
63
|
+
return [text[i : i + chars_per_string] for i in range(0, len(text), chars_per_string)]
|
File without changes
|
File without changes
|
@@ -0,0 +1,23 @@
|
|
1
|
+
import os
|
2
|
+
|
3
|
+
import pytest
|
4
|
+
from dotenv import load_dotenv
|
5
|
+
|
6
|
+
load_dotenv()
|
7
|
+
|
8
|
+
TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN", "")
|
9
|
+
TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "")
|
10
|
+
|
11
|
+
|
12
|
+
@pytest.fixture
|
13
|
+
def telegram_token() -> str:
|
14
|
+
if not TELEGRAM_TOKEN:
|
15
|
+
raise ValueError("TELEGRAM_TOKEN is not set")
|
16
|
+
return TELEGRAM_TOKEN
|
17
|
+
|
18
|
+
|
19
|
+
@pytest.fixture
|
20
|
+
def telegram_chat_id() -> int:
|
21
|
+
if not TELEGRAM_CHAT_ID:
|
22
|
+
raise ValueError("TELEGRAM_CHAT_ID is not set")
|
23
|
+
return int(TELEGRAM_CHAT_ID)
|
@@ -0,0 +1,262 @@
|
|
1
|
+
from typing import cast
|
2
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
3
|
+
|
4
|
+
import pytest
|
5
|
+
from telegram import Update
|
6
|
+
from telegram.ext import (
|
7
|
+
Application,
|
8
|
+
ApplicationHandlerStop,
|
9
|
+
BaseHandler,
|
10
|
+
CallbackContext,
|
11
|
+
CommandHandler,
|
12
|
+
ContextTypes,
|
13
|
+
MessageHandler,
|
14
|
+
filters,
|
15
|
+
)
|
16
|
+
|
17
|
+
from mm_telegram.bot import TelegramBot, TelegramHandler, is_admin, ping, unknown
|
18
|
+
|
19
|
+
|
20
|
+
@pytest.fixture
|
21
|
+
def mock_update() -> Update:
|
22
|
+
"""Fixture to create a mock Update object."""
|
23
|
+
update = MagicMock(spec=Update)
|
24
|
+
update.effective_chat = MagicMock()
|
25
|
+
update.effective_chat.id = 12345
|
26
|
+
update.effective_user = MagicMock()
|
27
|
+
update.effective_user.id = 67890
|
28
|
+
update.message = MagicMock()
|
29
|
+
update.message.reply_text = AsyncMock()
|
30
|
+
return update
|
31
|
+
|
32
|
+
|
33
|
+
@pytest.fixture
|
34
|
+
def mock_context() -> ContextTypes.DEFAULT_TYPE:
|
35
|
+
"""Fixture to create a mock CallbackContext object."""
|
36
|
+
context = MagicMock(spec=CallbackContext)
|
37
|
+
context.bot = AsyncMock()
|
38
|
+
context.bot_data = {}
|
39
|
+
return context
|
40
|
+
|
41
|
+
|
42
|
+
@pytest.fixture
|
43
|
+
def mock_application() -> MagicMock:
|
44
|
+
"""Fixture to create a mock Application object."""
|
45
|
+
app = MagicMock(spec=Application)
|
46
|
+
app.update_queue = AsyncMock()
|
47
|
+
app.bot_data = {}
|
48
|
+
app.initialize = AsyncMock()
|
49
|
+
app.start = AsyncMock()
|
50
|
+
app.shutdown = AsyncMock()
|
51
|
+
app.updater = MagicMock()
|
52
|
+
app.updater.start_polling = AsyncMock()
|
53
|
+
app.add_handler = MagicMock()
|
54
|
+
app.process_update = AsyncMock()
|
55
|
+
return app
|
56
|
+
|
57
|
+
|
58
|
+
def test_telegram_bot_init() -> None:
|
59
|
+
"""Test TelegramBot initialization."""
|
60
|
+
mock_handler = MagicMock(spec=BaseHandler[Update, ContextTypes.DEFAULT_TYPE, object])
|
61
|
+
handlers: list[TelegramHandler] = [mock_handler]
|
62
|
+
bot_data: dict[str, object] = {"key": "value"}
|
63
|
+
bot = TelegramBot(handlers=handlers, bot_data=bot_data)
|
64
|
+
|
65
|
+
assert bot.handlers == handlers
|
66
|
+
assert bot.bot_data == bot_data
|
67
|
+
assert bot.app is None
|
68
|
+
|
69
|
+
|
70
|
+
@pytest.mark.asyncio
|
71
|
+
@patch("mm_telegram.bot.ApplicationBuilder")
|
72
|
+
async def test_telegram_bot_start_success(mock_application_builder_cls: MagicMock, mock_application: MagicMock) -> None:
|
73
|
+
"""Test TelegramBot.start successfully initializes and starts the application."""
|
74
|
+
mock_builder_instance = MagicMock()
|
75
|
+
mock_builder_instance.token.return_value = mock_builder_instance
|
76
|
+
mock_builder_instance.build.return_value = mock_application
|
77
|
+
mock_application_builder_cls.return_value = mock_builder_instance
|
78
|
+
|
79
|
+
# Make lambda async
|
80
|
+
async def dummy_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
81
|
+
pass
|
82
|
+
|
83
|
+
custom_handler = CommandHandler("custom", dummy_handler)
|
84
|
+
handlers: list[TelegramHandler] = [custom_handler]
|
85
|
+
initial_bot_data: dict[str, object] = {"initial_key": "initial_value"}
|
86
|
+
bot = TelegramBot(handlers=handlers, bot_data=initial_bot_data)
|
87
|
+
|
88
|
+
token = "test_token"
|
89
|
+
admins = [123, 456]
|
90
|
+
|
91
|
+
await bot.start(token=token, admins=admins)
|
92
|
+
|
93
|
+
mock_application_builder_cls.assert_called_once_with()
|
94
|
+
mock_builder_instance.token.assert_called_once_with(token)
|
95
|
+
mock_builder_instance.build.assert_called_once_with()
|
96
|
+
|
97
|
+
assert mock_application.bot_data["initial_key"] == "initial_value"
|
98
|
+
assert mock_application.bot_data["admins"] == admins
|
99
|
+
|
100
|
+
# Check handlers: 1 custom + 2 default (ping, unknown)
|
101
|
+
assert mock_application.add_handler.call_count == 3
|
102
|
+
# Check specific handlers were added
|
103
|
+
added_handlers = [call.args[0] for call in mock_application.add_handler.call_args_list]
|
104
|
+
assert custom_handler in added_handlers
|
105
|
+
assert any(isinstance(h, CommandHandler) and h.callback == ping for h in added_handlers)
|
106
|
+
assert any(isinstance(h, MessageHandler) and h.callback == unknown and h.filters == filters.COMMAND for h in added_handlers)
|
107
|
+
|
108
|
+
mock_application.initialize.assert_awaited_once()
|
109
|
+
mock_application.start.assert_awaited_once()
|
110
|
+
assert bot.app == mock_application
|
111
|
+
if mock_application.updater:
|
112
|
+
mock_application.updater.start_polling.assert_awaited_once()
|
113
|
+
|
114
|
+
|
115
|
+
@pytest.mark.asyncio
|
116
|
+
async def test_telegram_bot_start_no_admins_raises_value_error() -> None:
|
117
|
+
"""Test TelegramBot.start raises ValueError if no admins are provided."""
|
118
|
+
bot = TelegramBot(handlers=[], bot_data={})
|
119
|
+
with pytest.raises(ValueError, match="No admins provided"):
|
120
|
+
await bot.start(token="test_token", admins=[])
|
121
|
+
|
122
|
+
|
123
|
+
@pytest.mark.asyncio
|
124
|
+
async def test_telegram_bot_shutdown(
|
125
|
+
mock_application: MagicMock, # Use the fixture
|
126
|
+
) -> None:
|
127
|
+
"""Test TelegramBot.shutdown calls app.shutdown()."""
|
128
|
+
bot = TelegramBot(handlers=[], bot_data={})
|
129
|
+
bot.app = mock_application # Assign the mocked application
|
130
|
+
|
131
|
+
await bot.shutdown()
|
132
|
+
|
133
|
+
mock_application.shutdown.assert_awaited_once()
|
134
|
+
assert bot.app is None
|
135
|
+
|
136
|
+
|
137
|
+
@pytest.mark.asyncio
|
138
|
+
async def test_telegram_bot_shutdown_no_app() -> None:
|
139
|
+
"""Test TelegramBot.shutdown does nothing if app is None."""
|
140
|
+
bot = TelegramBot(handlers=[], bot_data={})
|
141
|
+
assert bot.app is None
|
142
|
+
# Should not raise any error and complete successfully
|
143
|
+
await bot.shutdown()
|
144
|
+
assert bot.app is None # Still None
|
145
|
+
|
146
|
+
|
147
|
+
@pytest.mark.asyncio
|
148
|
+
async def test_ping_handler(mock_update: Update, mock_context: ContextTypes.DEFAULT_TYPE) -> None:
|
149
|
+
"""Test the ping handler sends 'pong'."""
|
150
|
+
assert mock_update.effective_chat is not None
|
151
|
+
await ping(mock_update, mock_context)
|
152
|
+
cast(AsyncMock, mock_context.bot.send_message).assert_awaited_once_with(chat_id=mock_update.effective_chat.id, text="pong")
|
153
|
+
|
154
|
+
|
155
|
+
@pytest.mark.asyncio
|
156
|
+
async def test_ping_handler_no_effective_chat(mock_update: Update, mock_context: ContextTypes.DEFAULT_TYPE) -> None:
|
157
|
+
"""Test the ping handler does nothing if effective_chat is None."""
|
158
|
+
cast(MagicMock, mock_update).configure_mock(effective_chat=None)
|
159
|
+
|
160
|
+
await ping(mock_update, mock_context)
|
161
|
+
cast(AsyncMock, mock_context.bot.send_message).assert_not_awaited()
|
162
|
+
|
163
|
+
|
164
|
+
@pytest.mark.asyncio
|
165
|
+
async def test_unknown_handler(mock_update: Update, mock_context: ContextTypes.DEFAULT_TYPE) -> None:
|
166
|
+
"""Test the unknown handler sends a specific message."""
|
167
|
+
assert mock_update.effective_chat is not None # From fixture
|
168
|
+
await unknown(mock_update, mock_context)
|
169
|
+
cast(AsyncMock, mock_context.bot.send_message).assert_awaited_once_with(
|
170
|
+
chat_id=mock_update.effective_chat.id, text="Sorry, I didn't understand that command."
|
171
|
+
)
|
172
|
+
|
173
|
+
|
174
|
+
@pytest.mark.asyncio
|
175
|
+
async def test_unknown_handler_no_effective_chat(mock_update: Update, mock_context: ContextTypes.DEFAULT_TYPE) -> None:
|
176
|
+
"""Test the unknown handler does nothing if effective_chat is None."""
|
177
|
+
cast(MagicMock, mock_update).configure_mock(effective_chat=None)
|
178
|
+
await unknown(mock_update, mock_context)
|
179
|
+
cast(AsyncMock, mock_context.bot.send_message).assert_not_awaited()
|
180
|
+
|
181
|
+
|
182
|
+
@pytest.mark.asyncio
|
183
|
+
async def test_is_admin_user_is_admin(mock_update: Update, mock_context: ContextTypes.DEFAULT_TYPE) -> None:
|
184
|
+
"""Test is_admin allows an admin user."""
|
185
|
+
admin_user_id = 67890
|
186
|
+
mock_context.bot_data["admins"] = [admin_user_id]
|
187
|
+
assert mock_update.effective_user is not None
|
188
|
+
mock_update.effective_user.id = admin_user_id
|
189
|
+
|
190
|
+
# Should not raise ApplicationHandlerStop
|
191
|
+
await is_admin(mock_update, mock_context)
|
192
|
+
# Ensure no reply was sent (meaning access granted)
|
193
|
+
assert mock_update.message is not None
|
194
|
+
cast(AsyncMock, mock_update.message.reply_text).assert_not_awaited()
|
195
|
+
|
196
|
+
|
197
|
+
@pytest.mark.asyncio
|
198
|
+
async def test_is_admin_user_not_admin(mock_update: Update, mock_context: ContextTypes.DEFAULT_TYPE) -> None:
|
199
|
+
"""Test is_admin blocks a non-admin user and replies."""
|
200
|
+
non_admin_user_id = 11111
|
201
|
+
mock_context.bot_data["admins"] = [12345] # Some other admin
|
202
|
+
assert mock_update.effective_user is not None
|
203
|
+
mock_update.effective_user.id = non_admin_user_id
|
204
|
+
assert mock_update.message is not None
|
205
|
+
|
206
|
+
with pytest.raises(ApplicationHandlerStop):
|
207
|
+
await is_admin(mock_update, mock_context)
|
208
|
+
|
209
|
+
assert mock_update.message is not None
|
210
|
+
cast(AsyncMock, mock_update.message.reply_text).assert_awaited_once_with("Who are you?")
|
211
|
+
|
212
|
+
|
213
|
+
@pytest.mark.asyncio
|
214
|
+
async def test_is_admin_no_admins_in_bot_data(mock_update: Update, mock_context: ContextTypes.DEFAULT_TYPE) -> None:
|
215
|
+
"""Test is_admin blocks if 'admins' list is not in bot_data."""
|
216
|
+
# Ensure 'admins' key is missing or empty
|
217
|
+
if "admins" in mock_context.bot_data:
|
218
|
+
del mock_context.bot_data["admins"]
|
219
|
+
# Or mock_context.bot_data["admins"] = [] -> this also works due to `get("admins", [])`
|
220
|
+
|
221
|
+
assert mock_update.effective_user is not None
|
222
|
+
mock_update.effective_user.id = 12345 # Any user ID
|
223
|
+
assert mock_update.message is not None
|
224
|
+
|
225
|
+
with pytest.raises(ApplicationHandlerStop):
|
226
|
+
await is_admin(mock_update, mock_context)
|
227
|
+
|
228
|
+
assert mock_update.message is not None
|
229
|
+
cast(AsyncMock, mock_update.message.reply_text).assert_awaited_once_with("Who are you?")
|
230
|
+
|
231
|
+
|
232
|
+
@pytest.mark.asyncio
|
233
|
+
async def test_is_admin_no_effective_user(mock_update: Update, mock_context: ContextTypes.DEFAULT_TYPE) -> None:
|
234
|
+
"""Test is_admin blocks if effective_user is None."""
|
235
|
+
cast(MagicMock, mock_update).configure_mock(effective_user=None)
|
236
|
+
mock_context.bot_data["admins"] = [12345]
|
237
|
+
|
238
|
+
with pytest.raises(ApplicationHandlerStop):
|
239
|
+
await is_admin(mock_update, mock_context)
|
240
|
+
# No reply_text should be called because it exits before that
|
241
|
+
assert mock_update.message is not None
|
242
|
+
cast(AsyncMock, mock_update.message.reply_text).assert_not_awaited()
|
243
|
+
|
244
|
+
|
245
|
+
@pytest.mark.asyncio
|
246
|
+
async def test_is_admin_no_message(mock_update: Update, mock_context: ContextTypes.DEFAULT_TYPE) -> None:
|
247
|
+
"""Test is_admin blocks if message is None (after effective_user check)."""
|
248
|
+
# effective_user must exist for this path
|
249
|
+
assert mock_update.effective_user is not None
|
250
|
+
mock_update.effective_user.id = 11111 # Non-admin, to try to reach reply_text path
|
251
|
+
mock_context.bot_data["admins"] = [67890] # Different admin
|
252
|
+
|
253
|
+
cast(MagicMock, mock_update).configure_mock(message=None)
|
254
|
+
|
255
|
+
with pytest.raises(ApplicationHandlerStop):
|
256
|
+
await is_admin(mock_update, mock_context)
|
257
|
+
# reply_text is on update.message, so if message is None, this can't be called.
|
258
|
+
# The check `if update.message is None: raise ApplicationHandlerStop` prevents it.
|
259
|
+
# If reply_text were on context.bot.send_message, we'd check it. But it's on update.message.
|
260
|
+
|
261
|
+
|
262
|
+
# End of tests
|