Skip to content

Commit 0910170

Browse files
authored
Merge pull request chigwell#283 from alexey-igrychev/feat/vote-poll
feat(messages): add voting in existing polls
2 parents 7f7b84b + 598c73f commit 0910170

3 files changed

Lines changed: 232 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@ The server currently includes 80+ MCP tools grouped into these areas:
6060
- **Messages:** send, schedule, edit, delete, forward, pin, unpin, mark read, reply, search, inspect context, create polls, manage reactions, inspect inline buttons, and press inline callbacks. `send_message`, `reply_to_message`, and `edit_message` support classic formatting (`parse_mode='md'`/`'html'`) and server-side rich formatting (`parse_mode='rich'`/`'rich_markdown'`/`'rich_html'` — full Markdown/HTML with tables, headings, formulas, and collapsible sections). Rich modes require Telegram Premium on the account; Premium is re-checked on every call, and without it nothing is sent — the tool returns a structured `telegram_premium_required` result so the agent can reformat with classic modes and retry. `send_message`, `reply_to_message`, and `edit_message` also accept `format_date` to render a date as a tappable chip.
6161

6262
`get_message_reactions` returns an empty list for a message with no reactions. To reuse a custom reaction, pass the returned `custom:<document_id>` value to `send_reaction`.
63+
64+
`vote_poll(chat_id, message_id, option_indices, account=None)` votes in an existing poll.
65+
Read the poll with `get_messages` first, then select zero-based answer indices: `[0]`
66+
for the first answer, or `[0, 2]` for the first and third in a multiple-choice poll.
67+
This may replace an existing vote; empty selections, duplicate or out-of-range
68+
indices, closed polls, and multiple answers in single-choice polls are rejected.
69+
Telegram enforces voting permissions and restrictions on changing quiz votes.
70+
6371
- **Contacts:** list, search, add, delete, block, unblock, import, export, inspect direct chats, find recent contact interactions, and remember contacts by the names you actually use (see below).
6472

6573
### Remembered contacts

‎telegram_mcp/tools/messages.py‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from telegram_mcp.runtime import *
44
from telegram_mcp import transcription
5+
from pydantic import StrictInt
56

67
# Domain used to build message permalinks. Overridable because the default is a
78
# single point of failure: on 2026-07-13 the .me registry put t.me on serverHold
@@ -2285,6 +2286,63 @@ async def create_poll(
22852286
)
22862287

22872288

2289+
@mcp.tool(annotations=ToolAnnotations(title="Vote Poll", openWorldHint=True, destructiveHint=True))
2290+
@with_account(readonly=False)
2291+
@validate_id("chat_id")
2292+
async def vote_poll(
2293+
chat_id: Union[int, str],
2294+
message_id: StrictInt,
2295+
option_indices: List[StrictInt],
2296+
account: Optional[str] = None,
2297+
) -> str:
2298+
"""Vote in an existing Telegram poll, replacing the current vote if allowed.
2299+
2300+
Args:
2301+
chat_id: The ID or username of the chat containing the poll.
2302+
message_id: The message ID of the poll.
2303+
option_indices: Non-empty list of zero-based answer indices, in the order
2304+
shown by get_messages. Multiple indices require a multiple-choice poll.
2305+
This submits a vote; it does not press an inline keyboard button.
2306+
account: Account name to use (required in multi-account mode).
2307+
2308+
Telegram enforces voting permissions and restrictions on changing quiz votes.
2309+
"""
2310+
try:
2311+
if type(message_id) is not int or message_id <= 0:
2312+
return "Error: message_id must be a positive integer."
2313+
if not option_indices or any(type(i) is not int for i in option_indices):
2314+
return "Error: option_indices must be a non-empty list of integers."
2315+
if len(set(option_indices)) != len(option_indices):
2316+
return "Error: option_indices must not contain duplicates."
2317+
2318+
cl = get_client(account)
2319+
entity = await resolve_entity(chat_id, cl)
2320+
if is_chat_allowlist_enabled() and not is_chat_allowed(chat_id, entity):
2321+
raise ChatAccessDeniedError(check_chat_access(chat_id, entity))
2322+
2323+
message = await cl.get_messages(entity, ids=message_id)
2324+
if message is None or not isinstance(message.media, types.MessageMediaPoll):
2325+
return "Error: Message not found or does not contain a poll."
2326+
poll = message.media.poll
2327+
if poll.closed:
2328+
return "Error: Poll is closed."
2329+
if len(option_indices) > 1 and not poll.multiple_choice:
2330+
return "Error: This poll only allows one answer."
2331+
if any(i < 0 or i >= len(poll.answers) for i in option_indices):
2332+
return "Error: Answer index out of range (indices are zero-based)."
2333+
2334+
await cl(
2335+
functions.messages.SendVoteRequest(
2336+
peer=entity,
2337+
msg_id=message_id,
2338+
options=[poll.answers[i].option for i in option_indices],
2339+
)
2340+
)
2341+
return f"Vote submitted for poll {message_id} in chat {chat_id}."
2342+
except Exception as e:
2343+
return log_and_format_error("vote_poll", e, chat_id=chat_id, message_id=message_id)
2344+
2345+
22882346
@mcp.tool(
22892347
annotations=ToolAnnotations(
22902348
title="Send Reaction", openWorldHint=True, destructiveHint=False, idempotentHint=True
@@ -2774,6 +2832,7 @@ async def export_unread_messages(
27742832
"get_history",
27752833
"get_pinned_messages",
27762834
"create_poll",
2835+
"vote_poll",
27772836
"send_reaction",
27782837
"remove_reaction",
27792838
"get_message_reactions",

‎tests/test_vote_poll.py‎

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
"""Exercise poll voting through the registered tool and real Telethon TL objects."""
2+
3+
from datetime import datetime, timezone
4+
from types import SimpleNamespace
5+
from unittest.mock import AsyncMock, Mock
6+
7+
import pytest
8+
from mcp.server.fastmcp.exceptions import ToolError
9+
from telethon import errors, types
10+
from telethon.tl.functions.messages import SendVoteRequest
11+
12+
from telegram_mcp import runtime
13+
from telegram_mcp.tools import messages
14+
15+
16+
@pytest.fixture
17+
def voting(monkeypatch):
18+
poll = types.Poll(
19+
id=77,
20+
hash=0,
21+
question=types.TextWithEntities("Pick a drink", []),
22+
answers=[
23+
types.PollAnswer(types.TextWithEntities(text, []), option)
24+
for text, option in [
25+
("Tea", b"tea-id"),
26+
("Water", b"\xff\x10"),
27+
("Juice", b"juice-id"),
28+
]
29+
],
30+
closed=False,
31+
multiple_choice=False,
32+
)
33+
message = types.Message(
34+
id=42,
35+
peer_id=types.PeerChat(123),
36+
date=datetime.now(timezone.utc),
37+
message="",
38+
media=types.MessageMediaPoll(poll, types.PollResults()),
39+
)
40+
client = AsyncMock()
41+
client.get_messages.return_value = message
42+
entity = types.InputPeerChat(123)
43+
get_client = Mock(return_value=client)
44+
monkeypatch.setattr(messages, "get_client", get_client)
45+
monkeypatch.setattr(messages, "resolve_entity", AsyncMock(return_value=entity))
46+
monkeypatch.setattr(runtime, "is_multi_mode", lambda: False)
47+
monkeypatch.setattr(runtime, "ALLOWED_CHAT_IDS", None)
48+
monkeypatch.delenv("TELEGRAM_ALLOWED_CHAT_IDS", raising=False)
49+
return SimpleNamespace(
50+
poll=poll, message=message, client=client, entity=entity, get_client=get_client
51+
)
52+
53+
54+
async def call_vote(**kwargs):
55+
args = {"chat_id": -123, "message_id": 42, "option_indices": [1], **kwargs}
56+
return await messages.vote_poll(**args)
57+
58+
59+
@pytest.mark.asyncio
60+
@pytest.mark.parametrize("indices", [[1], [2, 0]])
61+
async def test_vote_sends_real_option_bytes(voting, indices):
62+
voting.poll.multiple_choice = len(indices) > 1
63+
result = await call_vote(option_indices=indices, account="personal")
64+
assert result == "Vote submitted for poll 42 in chat -123."
65+
voting.get_client.assert_called_once_with("personal")
66+
voting.client.get_messages.assert_awaited_once_with(voting.entity, ids=42)
67+
request = voting.client.call_args.args[0]
68+
assert isinstance(request, SendVoteRequest)
69+
assert request.peer == voting.entity
70+
assert request.msg_id == 42
71+
assert request.options == ([b"\xff\x10"] if indices == [1] else [b"juice-id", b"tea-id"])
72+
assert bytes(request) # Request is serializable by Telethon, not just a mock.
73+
voting.client.assert_awaited_once()
74+
75+
76+
@pytest.mark.asyncio
77+
@pytest.mark.parametrize(
78+
"args, expected",
79+
[
80+
({"message_id": 0}, "positive integer"),
81+
({"message_id": -1}, "positive integer"),
82+
({"option_indices": []}, "non-empty list"),
83+
({"option_indices": [True]}, "list of integers"),
84+
({"option_indices": [1.0]}, "list of integers"),
85+
({"option_indices": [1, 1]}, "duplicates"),
86+
({"option_indices": [-1]}, "out of range"),
87+
({"option_indices": [3]}, "out of range"),
88+
({"option_indices": [0, 1]}, "only allows one answer"),
89+
],
90+
)
91+
async def test_invalid_votes_never_send(voting, args, expected):
92+
assert expected in await call_vote(**args)
93+
voting.client.assert_not_awaited()
94+
95+
96+
@pytest.mark.asyncio
97+
@pytest.mark.parametrize("kind", ["missing", "plain", "closed"])
98+
async def test_non_poll_and_closed_poll_never_send(voting, kind):
99+
if kind == "missing":
100+
voting.client.get_messages.return_value = None
101+
elif kind == "plain":
102+
voting.message.media = None
103+
else:
104+
voting.poll.closed = True
105+
result = await call_vote()
106+
assert ("Poll is closed" if kind == "closed" else "does not contain a poll") in result
107+
voting.client.assert_not_awaited()
108+
109+
110+
@pytest.mark.asyncio
111+
async def test_rpc_error_is_not_reported_as_success(voting):
112+
voting.client.side_effect = errors.MessagePollClosedError(request=None)
113+
result = await call_vote()
114+
assert "Vote submitted" not in result
115+
assert "Error" in result or "error" in result
116+
117+
118+
@pytest.mark.asyncio
119+
async def test_multi_account_requires_explicit_account(voting, monkeypatch):
120+
monkeypatch.setattr(runtime, "is_multi_mode", lambda: True)
121+
monkeypatch.setattr(runtime, "clients", {"personal": voting.client, "work": AsyncMock()})
122+
assert "'account' is required" in await call_vote()
123+
voting.client.get_messages.assert_not_awaited()
124+
voting.client.assert_not_awaited()
125+
126+
127+
@pytest.mark.asyncio
128+
async def test_chat_allowlist_blocks_vote(voting, monkeypatch):
129+
monkeypatch.setenv("TELEGRAM_ALLOWED_CHAT_IDS", "-456")
130+
assert "restricted by privacy policy" in await call_vote()
131+
voting.client.get_messages.assert_not_awaited()
132+
voting.client.assert_not_awaited()
133+
134+
135+
@pytest.mark.asyncio
136+
async def test_resolved_chat_is_checked_before_reading(voting, monkeypatch):
137+
monkeypatch.setattr(messages, "is_chat_allowlist_enabled", lambda: True)
138+
monkeypatch.setattr(messages, "is_chat_allowed", lambda *args: False)
139+
monkeypatch.setattr(
140+
messages, "check_chat_access", lambda *args: "restricted by privacy policy"
141+
)
142+
result = await call_vote()
143+
assert "Vote submitted" not in result
144+
voting.client.get_messages.assert_not_awaited()
145+
voting.client.assert_not_awaited()
146+
147+
148+
@pytest.mark.asyncio
149+
async def test_registered_tool_accepts_username_and_rejects_coercion(voting):
150+
tool = next(t for t in messages.mcp._tool_manager.list_tools() if t.name == "vote_poll")
151+
assert not tool.annotations.readOnlyHint
152+
assert tool.annotations.destructiveHint
153+
result = await tool.run({"chat_id": "@example_chat", "message_id": 42, "option_indices": [1]})
154+
assert "Vote submitted" in result
155+
for invalid in [True, 1.0, "1"]:
156+
voting.client.reset_mock()
157+
with pytest.raises(ToolError):
158+
await tool.run({"chat_id": -123, "message_id": 42, "option_indices": [invalid]})
159+
voting.client.assert_not_awaited()
160+
161+
162+
def test_vote_exported_for_compatibility_entrypoint():
163+
from telegram_mcp.tools import vote_poll
164+
165+
assert vote_poll is messages.vote_poll

0 commit comments

Comments
 (0)