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.
@@ -0,0 +1,2 @@
1
+ TELEGRAM_TOKEN=***********
2
+ TELEGRAM_CHAT_ID=****************
@@ -0,0 +1,15 @@
1
+ .idea
2
+ .venv
3
+ .env
4
+ .coverage
5
+ /htmlcov
6
+ __pycache__
7
+ *.egg-info
8
+ pip-wheel-metadata
9
+ .pytest_cache
10
+ .mypy_cache
11
+ .ruff_cache
12
+ /dist
13
+ /build
14
+ /tmp
15
+ .DS_Store
@@ -0,0 +1,10 @@
1
+ repos:
2
+ - repo: https://github.com/pre-commit/pre-commit-hooks
3
+ rev: v5.0.0
4
+ hooks:
5
+ - id: trailing-whitespace
6
+ - id: end-of-file-fixer
7
+ - id: check-yaml
8
+ - id: check-toml
9
+ - id: check-json
10
+ - id: check-added-large-files
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: mm-telegram
3
+ Version: 0.3.1
4
+ Requires-Python: >=3.13
5
+ Requires-Dist: mm-http~=0.1.0
6
+ Requires-Dist: python-telegram-bot~=22.1
@@ -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,4 @@
1
+ from .bot import TelegramBot, TelegramHandler
2
+ from .message import send_message
3
+
4
+ __all__ = ["TelegramBot", "TelegramHandler", "send_message"]
@@ -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