mcp-server-telegram-bot 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ .env
2
+ .venv/
3
+ __pycache__/
4
+ *.egg-info/
5
+ dist/
6
+ .pytest_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mirmaxamat
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.5
2
+ Name: mcp-server-telegram-bot
3
+ Version: 0.2.0
4
+ Summary: MCP server that lets AI agents send Telegram messages and files via a bot
5
+ Project-URL: Homepage, https://github.com/mirmakhamat/mcp-server-telegram-bot
6
+ Project-URL: Issues, https://github.com/mirmakhamat/mcp-server-telegram-bot/issues
7
+ Author: Mirmaxamat
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: bot,claude,mcp,telegram
11
+ Requires-Python: >=3.10
12
+ Requires-Dist: fpdf2>=2.8.9
13
+ Requires-Dist: httpx>=0.27
14
+ Requires-Dist: mcp<2,>=1.2
15
+ Description-Content-Type: text/markdown
16
+
17
+ # mcp-server-telegram-bot
18
+
19
+ MCP server that lets AI agents send Telegram messages and files through your own bot.
20
+ The token is read from the environment and never reaches the agent.
21
+
22
+ ## Setup
23
+ 1. Create a bot with @BotFather and copy its token.
24
+ 2. Send `/start` to your bot from the chat that should receive messages.
25
+ 3. Install and register (replace the placeholders):
26
+
27
+ ```
28
+ claude mcp add telegram \
29
+ -e TELEGRAM_BOT_TOKEN=<token> \
30
+ -e ALLOWED_CHAT_IDS=<chat_id> \
31
+ -- uvx mcp-server-telegram-bot
32
+ ```
33
+
34
+ Use `--scope user` so it works in every folder. When running from a checkout use `uv run --project <dir>` (not `--directory`), otherwise the server cannot see the session directory.
35
+
36
+ Don't know your chat id? Register with a temporary `ALLOWED_CHAT_IDS=0`, ask the agent to run `get_updates`, then re-register with the real id.
37
+
38
+ ## Environment
39
+ | Variable | Meaning |
40
+ |---|---|
41
+ | `TELEGRAM_BOT_TOKEN` | Bot token (required) |
42
+ | `ALLOWED_CHAT_IDS` | Comma-separated chats, each `id` or `name=id` (e.g. `me=111,ali=222`); first is default (required) |
43
+ | `ALLOWED_DIRS` | Extra directories whose files are sent without asking (optional) |
44
+ | `ACCESS_MODE` | `ask` (default): files outside the allowed roots need your confirmation. `strict`: they are refused |
45
+ | `DENY_PATHS` | Extra files/dirs that are never sent (optional) |
46
+
47
+ ## Typical use
48
+ - "Send `report.pdf` to me when you finish" -> `send_document(path)` to the default chat.
49
+ - "Send the result to Ali" -> `send_message(text, chat_id="ali")`.
50
+ - A person can only receive messages after they open the bot and press Start. Telegram blocks the bot otherwise.
51
+
52
+ ## Tools
53
+ `send_message(text, chat_id?)`, `send_document(path, caption?, chat_id?)`, `send_photo(path, caption?, chat_id?)`, `send_generated(name, content, format, caption?, chat_id?)`, `get_updates(limit?)`
54
+ Access management: `list_access()`, `add_chat(chat_id, name?)`, `add_dir(path)`, `remove_chat(chat)`, `remove_dir(path)`
55
+
56
+ ## File access
57
+ 1. **Never sent**, whatever env or the agent says: `.ssh`, `.gnupg`, `.aws`, `.kube`, `.docker`, `~/.claude*`, browser profiles, keyrings, `/etc`, `/proc`, `/sys`; `.env*`, `.netrc`, `.pgpass`, `.git-credentials`, `.git/config`, `id_*` keys, `*.pem`, `*.key`, `*.p12`, `*.kdbx`, shell history. Paths are resolved with `realpath`, so symlinks and `..` do not help. Add your own with `DENY_PATHS`.
58
+ 2. **Sent directly:** files inside the directory the session was started in (not if that is `/` or your home), and inside `ALLOWED_DIRS`.
59
+ 3. **Everything else:** you are asked to confirm each time (`ACCESS_MODE=ask`), or it is refused (`strict`).
60
+
61
+ `send_generated` builds the file from text the agent provides (pdf, md, txt, csv, json) and reads nothing from disk, so it needs no folder access: "send me a PDF of everything you did".
62
+
63
+ Every send is logged (time, chat, path or file name, never contents) to `~/.local/state/telegram-mcp/sent.log`.
64
+
65
+ Limits: the deny list cannot know every secret. A password inside an ordinary file in the session directory will be sent if the agent is told to send it. Files go to Telegram's servers. Max 50 MB. Use one bot per user.
66
+
67
+ ## Changing access from the agent
68
+ - `add_chat` / `add_dir` ask the user to confirm (MCP elicitation). If the client cannot ask, the call is refused.
69
+ - `/` and the home directory cannot be added. Protected dirs stay blocked.
70
+ - Additions are saved in `~/.config/mcp-server-telegram-bot/state.json` (override with `TELEGRAM_MCP_STATE`).
71
+ - `remove_*` only removes runtime additions. Env entries are changed in the environment.
72
+
73
+ ## Development
74
+ ```
75
+ uv sync && uv run pytest
76
+ ```
@@ -0,0 +1,60 @@
1
+ # mcp-server-telegram-bot
2
+
3
+ MCP server that lets AI agents send Telegram messages and files through your own bot.
4
+ The token is read from the environment and never reaches the agent.
5
+
6
+ ## Setup
7
+ 1. Create a bot with @BotFather and copy its token.
8
+ 2. Send `/start` to your bot from the chat that should receive messages.
9
+ 3. Install and register (replace the placeholders):
10
+
11
+ ```
12
+ claude mcp add telegram \
13
+ -e TELEGRAM_BOT_TOKEN=<token> \
14
+ -e ALLOWED_CHAT_IDS=<chat_id> \
15
+ -- uvx mcp-server-telegram-bot
16
+ ```
17
+
18
+ Use `--scope user` so it works in every folder. When running from a checkout use `uv run --project <dir>` (not `--directory`), otherwise the server cannot see the session directory.
19
+
20
+ Don't know your chat id? Register with a temporary `ALLOWED_CHAT_IDS=0`, ask the agent to run `get_updates`, then re-register with the real id.
21
+
22
+ ## Environment
23
+ | Variable | Meaning |
24
+ |---|---|
25
+ | `TELEGRAM_BOT_TOKEN` | Bot token (required) |
26
+ | `ALLOWED_CHAT_IDS` | Comma-separated chats, each `id` or `name=id` (e.g. `me=111,ali=222`); first is default (required) |
27
+ | `ALLOWED_DIRS` | Extra directories whose files are sent without asking (optional) |
28
+ | `ACCESS_MODE` | `ask` (default): files outside the allowed roots need your confirmation. `strict`: they are refused |
29
+ | `DENY_PATHS` | Extra files/dirs that are never sent (optional) |
30
+
31
+ ## Typical use
32
+ - "Send `report.pdf` to me when you finish" -> `send_document(path)` to the default chat.
33
+ - "Send the result to Ali" -> `send_message(text, chat_id="ali")`.
34
+ - A person can only receive messages after they open the bot and press Start. Telegram blocks the bot otherwise.
35
+
36
+ ## Tools
37
+ `send_message(text, chat_id?)`, `send_document(path, caption?, chat_id?)`, `send_photo(path, caption?, chat_id?)`, `send_generated(name, content, format, caption?, chat_id?)`, `get_updates(limit?)`
38
+ Access management: `list_access()`, `add_chat(chat_id, name?)`, `add_dir(path)`, `remove_chat(chat)`, `remove_dir(path)`
39
+
40
+ ## File access
41
+ 1. **Never sent**, whatever env or the agent says: `.ssh`, `.gnupg`, `.aws`, `.kube`, `.docker`, `~/.claude*`, browser profiles, keyrings, `/etc`, `/proc`, `/sys`; `.env*`, `.netrc`, `.pgpass`, `.git-credentials`, `.git/config`, `id_*` keys, `*.pem`, `*.key`, `*.p12`, `*.kdbx`, shell history. Paths are resolved with `realpath`, so symlinks and `..` do not help. Add your own with `DENY_PATHS`.
42
+ 2. **Sent directly:** files inside the directory the session was started in (not if that is `/` or your home), and inside `ALLOWED_DIRS`.
43
+ 3. **Everything else:** you are asked to confirm each time (`ACCESS_MODE=ask`), or it is refused (`strict`).
44
+
45
+ `send_generated` builds the file from text the agent provides (pdf, md, txt, csv, json) and reads nothing from disk, so it needs no folder access: "send me a PDF of everything you did".
46
+
47
+ Every send is logged (time, chat, path or file name, never contents) to `~/.local/state/telegram-mcp/sent.log`.
48
+
49
+ Limits: the deny list cannot know every secret. A password inside an ordinary file in the session directory will be sent if the agent is told to send it. Files go to Telegram's servers. Max 50 MB. Use one bot per user.
50
+
51
+ ## Changing access from the agent
52
+ - `add_chat` / `add_dir` ask the user to confirm (MCP elicitation). If the client cannot ask, the call is refused.
53
+ - `/` and the home directory cannot be added. Protected dirs stay blocked.
54
+ - Additions are saved in `~/.config/mcp-server-telegram-bot/state.json` (override with `TELEGRAM_MCP_STATE`).
55
+ - `remove_*` only removes runtime additions. Env entries are changed in the environment.
56
+
57
+ ## Development
58
+ ```
59
+ uv sync && uv run pytest
60
+ ```
@@ -0,0 +1,36 @@
1
+ [project]
2
+ name = "mcp-server-telegram-bot"
3
+ version = "0.2.0"
4
+ description = "MCP server that lets AI agents send Telegram messages and files via a bot"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "Mirmaxamat" }]
9
+ keywords = ["mcp", "telegram", "bot", "claude"]
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "mcp>=1.2,<2",
13
+ "httpx>=0.27",
14
+ "fpdf2>=2.8.9",
15
+ ]
16
+
17
+ [project.scripts]
18
+ mcp-server-telegram-bot = "telegram_mcp.server:main"
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/mirmakhamat/mcp-server-telegram-bot"
22
+ Issues = "https://github.com/mirmakhamat/mcp-server-telegram-bot/issues"
23
+
24
+ [dependency-groups]
25
+ dev = ["pytest>=8"]
26
+
27
+ [build-system]
28
+ requires = ["hatchling"]
29
+ build-backend = "hatchling.build"
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["src/telegram_mcp"]
33
+
34
+ [tool.hatch.build.targets.sdist]
35
+ exclude = ["CLAUDE.md", ".env*", "uv.lock"]
36
+ include = ["src", "tests", "README.md", "pyproject.toml", ".env.example"]
@@ -0,0 +1,187 @@
1
+ Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
2
+ Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below)
3
+
4
+
5
+ Bitstream Vera Fonts Copyright
6
+ ------------------------------
7
+
8
+ Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is
9
+ a trademark of Bitstream, Inc.
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of the fonts accompanying this license ("Fonts") and associated
13
+ documentation files (the "Font Software"), to reproduce and distribute the
14
+ Font Software, including without limitation the rights to use, copy, merge,
15
+ publish, distribute, and/or sell copies of the Font Software, and to permit
16
+ persons to whom the Font Software is furnished to do so, subject to the
17
+ following conditions:
18
+
19
+ The above copyright and trademark notices and this permission notice shall
20
+ be included in all copies of one or more of the Font Software typefaces.
21
+
22
+ The Font Software may be modified, altered, or added to, and in particular
23
+ the designs of glyphs or characters in the Fonts may be modified and
24
+ additional glyphs or characters may be added to the Fonts, only if the fonts
25
+ are renamed to names not containing either the words "Bitstream" or the word
26
+ "Vera".
27
+
28
+ This License becomes null and void to the extent applicable to Fonts or Font
29
+ Software that has been modified and is distributed under the "Bitstream
30
+ Vera" names.
31
+
32
+ The Font Software may be sold as part of a larger software package but no
33
+ copy of one or more of the Font Software typefaces may be sold by itself.
34
+
35
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
36
+ OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
37
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
38
+ TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
39
+ FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
40
+ ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
41
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
42
+ THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
43
+ FONT SOFTWARE.
44
+
45
+ Except as contained in this notice, the names of Gnome, the Gnome
46
+ Foundation, and Bitstream Inc., shall not be used in advertising or
47
+ otherwise to promote the sale, use or other dealings in this Font Software
48
+ without prior written authorization from the Gnome Foundation or Bitstream
49
+ Inc., respectively. For further information, contact: fonts at gnome dot
50
+ org.
51
+
52
+ Arev Fonts Copyright
53
+ ------------------------------
54
+
55
+ Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved.
56
+
57
+ Permission is hereby granted, free of charge, to any person obtaining
58
+ a copy of the fonts accompanying this license ("Fonts") and
59
+ associated documentation files (the "Font Software"), to reproduce
60
+ and distribute the modifications to the Bitstream Vera Font Software,
61
+ including without limitation the rights to use, copy, merge, publish,
62
+ distribute, and/or sell copies of the Font Software, and to permit
63
+ persons to whom the Font Software is furnished to do so, subject to
64
+ the following conditions:
65
+
66
+ The above copyright and trademark notices and this permission notice
67
+ shall be included in all copies of one or more of the Font Software
68
+ typefaces.
69
+
70
+ The Font Software may be modified, altered, or added to, and in
71
+ particular the designs of glyphs or characters in the Fonts may be
72
+ modified and additional glyphs or characters may be added to the
73
+ Fonts, only if the fonts are renamed to names not containing either
74
+ the words "Tavmjong Bah" or the word "Arev".
75
+
76
+ This License becomes null and void to the extent applicable to Fonts
77
+ or Font Software that has been modified and is distributed under the
78
+ "Tavmjong Bah Arev" names.
79
+
80
+ The Font Software may be sold as part of a larger software package but
81
+ no copy of one or more of the Font Software typefaces may be sold by
82
+ itself.
83
+
84
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
85
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
86
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
87
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL
88
+ TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
89
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
90
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
91
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
92
+ OTHER DEALINGS IN THE FONT SOFTWARE.
93
+
94
+ Except as contained in this notice, the name of Tavmjong Bah shall not
95
+ be used in advertising or otherwise to promote the sale, use or other
96
+ dealings in this Font Software without prior written authorization
97
+ from Tavmjong Bah. For further information, contact: tavmjong @ free
98
+ . fr.
99
+
100
+ TeX Gyre DJV Math
101
+ -----------------
102
+ Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
103
+
104
+ Math extensions done by B. Jackowski, P. Strzelczyk and P. Pianowski
105
+ (on behalf of TeX users groups) are in public domain.
106
+
107
+ Letters imported from Euler Fraktur from AMSfonts are (c) American
108
+ Mathematical Society (see below).
109
+ Bitstream Vera Fonts Copyright
110
+ Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera
111
+ is a trademark of Bitstream, Inc.
112
+
113
+ Permission is hereby granted, free of charge, to any person obtaining a copy
114
+ of the fonts accompanying this license (“Fonts”) and associated
115
+ documentation
116
+ files (the “Font Software”), to reproduce and distribute the Font Software,
117
+ including without limitation the rights to use, copy, merge, publish,
118
+ distribute,
119
+ and/or sell copies of the Font Software, and to permit persons to whom
120
+ the Font Software is furnished to do so, subject to the following
121
+ conditions:
122
+
123
+ The above copyright and trademark notices and this permission notice
124
+ shall be
125
+ included in all copies of one or more of the Font Software typefaces.
126
+
127
+ The Font Software may be modified, altered, or added to, and in particular
128
+ the designs of glyphs or characters in the Fonts may be modified and
129
+ additional
130
+ glyphs or characters may be added to the Fonts, only if the fonts are
131
+ renamed
132
+ to names not containing either the words “Bitstream” or the word “Vera”.
133
+
134
+ This License becomes null and void to the extent applicable to Fonts or
135
+ Font Software
136
+ that has been modified and is distributed under the “Bitstream Vera”
137
+ names.
138
+
139
+ The Font Software may be sold as part of a larger software package but
140
+ no copy
141
+ of one or more of the Font Software typefaces may be sold by itself.
142
+
143
+ THE FONT SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS
144
+ OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
145
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
146
+ TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
147
+ FOUNDATION
148
+ BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL,
149
+ SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN
150
+ ACTION
151
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR
152
+ INABILITY TO USE
153
+ THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
154
+ Except as contained in this notice, the names of GNOME, the GNOME
155
+ Foundation,
156
+ and Bitstream Inc., shall not be used in advertising or otherwise to promote
157
+ the sale, use or other dealings in this Font Software without prior written
158
+ authorization from the GNOME Foundation or Bitstream Inc., respectively.
159
+ For further information, contact: fonts at gnome dot org.
160
+
161
+ AMSFonts (v. 2.2) copyright
162
+
163
+ The PostScript Type 1 implementation of the AMSFonts produced by and
164
+ previously distributed by Blue Sky Research and Y&Y, Inc. are now freely
165
+ available for general use. This has been accomplished through the
166
+ cooperation
167
+ of a consortium of scientific publishers with Blue Sky Research and Y&Y.
168
+ Members of this consortium include:
169
+
170
+ Elsevier Science IBM Corporation Society for Industrial and Applied
171
+ Mathematics (SIAM) Springer-Verlag American Mathematical Society (AMS)
172
+
173
+ In order to assure the authenticity of these fonts, copyright will be
174
+ held by
175
+ the American Mathematical Society. This is not meant to restrict in any way
176
+ the legitimate use of the fonts, such as (but not limited to) electronic
177
+ distribution of documents containing these fonts, inclusion of these fonts
178
+ into other public domain or commercial font collections or computer
179
+ applications, use of the outline data to create derivative fonts and/or
180
+ faces, etc. However, the AMS does require that the AMS copyright notice be
181
+ removed from any derivative versions of the fonts which have been altered in
182
+ any way. In addition, to ensure the fidelity of TeX documents using Computer
183
+ Modern fonts, Professor Donald Knuth, creator of the Computer Modern faces,
184
+ has requested that any alterations which yield different font metrics be
185
+ given a different name.
186
+
187
+ $Id$
@@ -0,0 +1,36 @@
1
+ import os
2
+
3
+ from fpdf import FPDF
4
+
5
+ FONTS = os.path.join(os.path.dirname(__file__), "fonts")
6
+ MAX_CHARS = 500_000
7
+
8
+
9
+ def render(content: str, title: str | None = None) -> bytes:
10
+ """Plain text to PDF. '# ' and '## ' lines are headings, '- ' lines are bullets."""
11
+ if len(content) > MAX_CHARS:
12
+ raise ValueError(f"content is longer than {MAX_CHARS} characters")
13
+ pdf = FPDF()
14
+ pdf.add_font("dv", "", os.path.join(FONTS, "DejaVuSans.ttf"))
15
+ pdf.add_font("dv", "B", os.path.join(FONTS, "DejaVuSans-Bold.ttf"))
16
+ pdf.add_page()
17
+ def line(text, size, style="", indent=0):
18
+ pdf.set_font("dv", style, size)
19
+ pdf.set_x(pdf.l_margin + indent)
20
+ pdf.multi_cell(0, size * 0.55, text, new_x="LMARGIN", new_y="NEXT")
21
+
22
+ if title:
23
+ line(title, 18, "B")
24
+ pdf.ln(3)
25
+ for raw in content.splitlines():
26
+ if raw.startswith("## "):
27
+ line(raw[3:], 13, "B")
28
+ elif raw.startswith("# "):
29
+ line(raw[2:], 16, "B")
30
+ elif raw.startswith(("- ", "* ")):
31
+ line("• " + raw[2:], 11, indent=4)
32
+ elif raw.strip():
33
+ line(raw, 11)
34
+ else:
35
+ pdf.ln(3)
36
+ return bytes(pdf.output())
@@ -0,0 +1,174 @@
1
+ import fnmatch
2
+ import json
3
+ import os
4
+ from dataclasses import dataclass, field, replace
5
+
6
+ MAX_BYTES = 50 * 1024 * 1024 # Bot API upload limit
7
+ # Never sendable, whatever env or the user says.
8
+ BLOCKED_DIRS = {".ssh", ".gnupg", ".aws", ".kube", ".docker", ".password-store"}
9
+ BLOCKED_HOME = (
10
+ ".config/gcloud", ".config/telegram-mcp", ".local/state/telegram-mcp", ".local/share/keyrings",
11
+ ".claude", ".mozilla", ".config/google-chrome", ".config/chromium", ".config/BraveSoftware",
12
+ )
13
+ BLOCKED_ABS = ("/etc", "/proc", "/sys", "/dev", "/boot", "/root")
14
+ BLOCKED_NAMES = (
15
+ ".env*", ".netrc", ".pgpass", ".git-credentials", "id_rsa*", "id_ed25519*", "id_ecdsa*", "id_dsa*",
16
+ "*.pem", "*.key", "*.p12", "*.pfx", "*.kdbx", ".bash_history", ".zsh_history", ".claude.json",
17
+ "credentials", "credentials.json",
18
+ )
19
+
20
+
21
+ class ConfigError(Exception):
22
+ pass
23
+
24
+
25
+ class DeniedError(Exception):
26
+ pass
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Config:
31
+ token: str
32
+ chat_ids: tuple[str, ...]
33
+ dirs: tuple[str, ...]
34
+ names: dict = field(default_factory=dict) # lowercase name -> chat id
35
+ session_dir: str = "" # auto-allowed; empty when the session runs in / or home
36
+ mode: str = "ask" # ask = confirm files outside allowed roots, strict = refuse them
37
+ deny: tuple[str, ...] = () # extra user-defined blocked paths
38
+
39
+
40
+ def parse_chat(entry: str) -> tuple[str | None, str]:
41
+ """'ali=123' -> ('ali', '123'); '123' -> (None, '123')."""
42
+ name, sep, cid = entry.rpartition("=")
43
+ return (name.strip() or None, cid.strip()) if sep else (None, entry.strip())
44
+
45
+
46
+ def _chats(entries) -> tuple[tuple[str, ...], dict]:
47
+ ids, names = [], {}
48
+ for e in entries:
49
+ name, cid = parse_chat(e)
50
+ if cid not in ids:
51
+ ids.append(cid)
52
+ if name:
53
+ names[name.lower()] = cid
54
+ return tuple(ids), names
55
+
56
+
57
+ def _split(value: str) -> tuple[str, ...]:
58
+ return tuple(p.strip() for p in value.split(",") if p.strip())
59
+
60
+
61
+ def load_config(env=os.environ, cwd: str | None = None) -> Config:
62
+ token = env.get("TELEGRAM_BOT_TOKEN", "").strip()
63
+ chat_ids, names = _chats(_split(env.get("ALLOWED_CHAT_IDS", "")))
64
+ if not token:
65
+ raise ConfigError("TELEGRAM_BOT_TOKEN is not set")
66
+ if not chat_ids:
67
+ raise ConfigError("ALLOWED_CHAT_IDS is not set")
68
+ dirs = tuple(os.path.realpath(os.path.expanduser(d)) for d in _split(env.get("ALLOWED_DIRS", "")))
69
+ mode = env.get("ACCESS_MODE", "ask").strip().lower()
70
+ if mode not in ("ask", "strict"):
71
+ raise ConfigError("ACCESS_MODE must be 'ask' or 'strict'")
72
+ session = os.path.realpath(cwd or os.getcwd())
73
+ if session in (os.sep, os.path.realpath(os.path.expanduser("~"))):
74
+ session = ""
75
+ deny = tuple(os.path.realpath(os.path.expanduser(d)) for d in _split(env.get("DENY_PATHS", "")))
76
+ return Config(token, chat_ids, dirs, names, session, mode, deny)
77
+
78
+
79
+ def resolve_chat(cfg: Config, chat_id: str | int | None) -> str:
80
+ if chat_id is None or str(chat_id) == "":
81
+ return cfg.chat_ids[0]
82
+ key = str(chat_id).strip()
83
+ key = cfg.names.get(key.lower(), key)
84
+ if key not in cfg.chat_ids:
85
+ raise DeniedError("chat is not in ALLOWED_CHAT_IDS")
86
+ return key
87
+
88
+
89
+ def _under(real: str, root: str) -> bool:
90
+ return os.path.commonpath([real, root]) == root
91
+
92
+
93
+ def is_blocked(cfg: Config, real: str) -> bool:
94
+ home = os.path.realpath(os.path.expanduser("~"))
95
+ parts = real.split(os.sep)
96
+ if BLOCKED_DIRS & set(parts):
97
+ return True
98
+ if any(_under(real, os.path.join(home, h)) for h in BLOCKED_HOME):
99
+ return True
100
+ if any(_under(real, a) for a in BLOCKED_ABS) or any(_under(real, d) for d in cfg.deny):
101
+ return True
102
+ if any(fnmatch.fnmatch(parts[-1], n) for n in BLOCKED_NAMES):
103
+ return True
104
+ return ".git" in parts[:-1] and parts[-1] == "config"
105
+
106
+
107
+ def check_path(cfg: Config, path: str) -> str:
108
+ """Return the real path, or raise if it is protected, missing or too large."""
109
+ real = os.path.realpath(os.path.expanduser(path))
110
+ if is_blocked(cfg, real):
111
+ raise DeniedError("path points to a protected file")
112
+ if not os.path.isfile(real):
113
+ raise DeniedError("not a regular file")
114
+ if os.path.getsize(real) > MAX_BYTES:
115
+ raise DeniedError("file is larger than 50 MB")
116
+ return real
117
+
118
+
119
+ def is_inside(cfg: Config, real: str) -> bool:
120
+ """True when the file may be sent without asking: ALLOWED_DIRS or the session directory."""
121
+ roots = cfg.dirs + ((cfg.session_dir,) if cfg.session_dir else ())
122
+ return any(_under(real, r) for r in roots)
123
+
124
+
125
+ # Runtime additions (added via tools) live in a state file; env values stay the base.
126
+ def state_path() -> str:
127
+ return os.path.expanduser(os.environ.get("TELEGRAM_MCP_STATE", "~/.config/telegram-mcp/state.json"))
128
+
129
+
130
+ def load_extra(path: str) -> dict:
131
+ try:
132
+ with open(path) as f:
133
+ d = json.load(f)
134
+ return {"chats": list(d.get("chats", [])), "dirs": list(d.get("dirs", []))}
135
+ except (OSError, ValueError):
136
+ return {"chats": [], "dirs": []}
137
+
138
+
139
+ def save_extra(path: str, extra: dict) -> None:
140
+ os.makedirs(os.path.dirname(path), exist_ok=True)
141
+ with open(path, "w") as f:
142
+ json.dump(extra, f)
143
+
144
+
145
+ def merge(base: Config, extra: dict) -> Config:
146
+ ids, names = _chats(extra["chats"])
147
+ chats = base.chat_ids + tuple(c for c in ids if c not in base.chat_ids)
148
+ dirs = base.dirs + tuple(d for d in extra["dirs"] if d not in base.dirs)
149
+ return replace(base, chat_ids=chats, dirs=dirs, names={**names, **base.names})
150
+
151
+
152
+ def validate_chat(chat_id: str) -> str:
153
+ c = str(chat_id).strip()
154
+ if not c.lstrip("-").isdigit():
155
+ raise DeniedError("chat_id must be a numeric id")
156
+ return c
157
+
158
+
159
+ def validate_name(name: str) -> str:
160
+ n = name.strip()
161
+ if not n.replace("_", "").replace("-", "").isalnum():
162
+ raise DeniedError("name may contain only letters, digits, _ and -")
163
+ return n
164
+
165
+
166
+ def validate_dir(cfg: Config, path: str) -> str:
167
+ real = os.path.realpath(os.path.expanduser(path))
168
+ if real == os.sep or real == os.path.realpath(os.path.expanduser("~")):
169
+ raise DeniedError("directory is too broad (root or home)")
170
+ if is_blocked(cfg, real):
171
+ raise DeniedError("directory is protected")
172
+ if not os.path.isdir(real):
173
+ raise DeniedError("not a directory")
174
+ return real
@@ -0,0 +1,190 @@
1
+ import os
2
+ import re
3
+ import sys
4
+ from datetime import datetime
5
+
6
+ from mcp.server.fastmcp import Context, FastMCP
7
+ from pydantic import BaseModel
8
+
9
+ from . import pdf, security, telegram
10
+
11
+ FORMATS = ("pdf", "md", "txt", "csv", "json")
12
+ mcp = FastMCP("telegram")
13
+ _base: security.Config | None = None
14
+
15
+
16
+ def base() -> security.Config:
17
+ global _base
18
+ if _base is None:
19
+ _base = security.load_config()
20
+ return _base
21
+
22
+
23
+ def cfg() -> security.Config:
24
+ return security.merge(base(), security.load_extra(security.state_path()))
25
+
26
+
27
+ @mcp.tool()
28
+ async def send_message(text: str, chat_id: str | None = None) -> str:
29
+ """Send a text message. chat_id is an allowed id or name (see list_access); omitted = default chat."""
30
+ c = cfg()
31
+ r = await telegram.call(c.token, "sendMessage", {"chat_id": security.resolve_chat(c, chat_id), "text": text})
32
+ return f"sent, message_id={r['message_id']}"
33
+
34
+
35
+ def _log(chat: str, what: str) -> None:
36
+ """Append-only record of what was sent where. Never stores file contents."""
37
+ path = os.path.expanduser(os.environ.get("TELEGRAM_MCP_LOG", "~/.local/state/telegram-mcp/sent.log"))
38
+ try:
39
+ os.makedirs(os.path.dirname(path), exist_ok=True)
40
+ with open(path, "a") as f:
41
+ f.write(f"{datetime.now().isoformat(timespec='seconds')}\tchat={chat}\t{what}\n")
42
+ except OSError:
43
+ pass # logging must never block a send
44
+
45
+
46
+ async def _send_file(ctx: Context, method: str, field: str, path: str, caption: str | None, chat_id: str | None) -> str:
47
+ c = cfg()
48
+ chat = security.resolve_chat(c, chat_id)
49
+ real = security.check_path(c, path)
50
+ if not security.is_inside(c, real):
51
+ if c.mode == "strict":
52
+ raise security.DeniedError("file is outside the session directory and ALLOWED_DIRS (ACCESS_MODE=strict)")
53
+ await _approve(ctx, f"send {real} to chat {chat}")
54
+ data = {"chat_id": chat, **({"caption": caption} if caption else {})}
55
+ with open(real, "rb") as f:
56
+ r = await telegram.call(c.token, method, data, {field: f})
57
+ _log(chat, f"file={real}")
58
+ return f"sent, message_id={r['message_id']}"
59
+
60
+
61
+ @mcp.tool()
62
+ async def send_document(ctx: Context, path: str, caption: str | None = None, chat_id: str | None = None) -> str:
63
+ """Send an existing file. Files in the session directory send directly; elsewhere the user is asked. Secrets (.ssh, .env, keys) are always refused."""
64
+ return await _send_file(ctx, "sendDocument", "document", path, caption, chat_id)
65
+
66
+
67
+ @mcp.tool()
68
+ async def send_photo(ctx: Context, path: str, caption: str | None = None, chat_id: str | None = None) -> str:
69
+ """Send an existing image as a photo. Same access rules as send_document."""
70
+ return await _send_file(ctx, "sendPhoto", "photo", path, caption, chat_id)
71
+
72
+
73
+ @mcp.tool()
74
+ async def send_generated(
75
+ name: str, content: str, format: str = "pdf", caption: str | None = None, chat_id: str | None = None
76
+ ) -> str:
77
+ """Create a file from text and send it; reads nothing from disk. format: pdf, md, txt, csv, json.
78
+ For a pdf, '# ' / '## ' lines are headings and '- ' lines are bullets. Use it for reports, summaries, logs, tables."""
79
+ if format not in FORMATS:
80
+ raise ValueError(f"format must be one of: {', '.join(FORMATS)}")
81
+ c = cfg()
82
+ chat = security.resolve_chat(c, chat_id)
83
+ stem = re.sub(r"[^\w.-]+", "_", os.path.splitext(os.path.basename(name))[0]).strip("._") or "file"
84
+ filename = f"{stem}.{format}"
85
+ body = pdf.render(content, title=name if format == "pdf" else None) if format == "pdf" else content.encode()
86
+ if len(body) > security.MAX_BYTES:
87
+ raise security.DeniedError("file is larger than 50 MB")
88
+ data = {"chat_id": chat, **({"caption": caption} if caption else {})}
89
+ r = await telegram.call(c.token, "sendDocument", data, {"document": (filename, body)})
90
+ _log(chat, f"generated={filename} bytes={len(body)}")
91
+ return f"sent {filename}, message_id={r['message_id']}"
92
+
93
+
94
+ @mcp.tool()
95
+ async def get_updates(limit: int = 10) -> list[dict]:
96
+ """List recent messages sent to the bot (shows chat ids; helps first-time setup)."""
97
+ r = await telegram.call(cfg().token, "getUpdates", {"limit": limit})
98
+ return [
99
+ {"chat_id": m["chat"]["id"], "from": m.get("from", {}).get("first_name"), "text": (m.get("text") or "")[:200]}
100
+ for u in r
101
+ if (m := u.get("message"))
102
+ ]
103
+
104
+
105
+ class Confirm(BaseModel):
106
+ confirm: bool = False
107
+
108
+
109
+ async def _approve(ctx: Context, what: str) -> None:
110
+ """Widening access needs a human yes. Fails closed if the client cannot ask."""
111
+ try:
112
+ r = await ctx.elicit(f"Allow mcp-server-telegram-bot to {what}?", Confirm)
113
+ except Exception:
114
+ raise security.DeniedError("client cannot ask for confirmation; set it in the environment instead") from None
115
+ if r.action != "accept" or not r.data.confirm:
116
+ raise security.DeniedError("not approved")
117
+
118
+
119
+ def _edit(kind: str, value: str, add: bool) -> str:
120
+ path = security.state_path()
121
+ extra = security.load_extra(path)
122
+ items = extra[kind]
123
+ if add and value not in items:
124
+ items.append(value)
125
+ elif not add:
126
+ if value not in items:
127
+ raise security.DeniedError("not a runtime-added entry (env entries are changed in the environment)")
128
+ items.remove(value)
129
+ security.save_extra(path, extra)
130
+ return value
131
+
132
+
133
+ @mcp.tool()
134
+ def list_access() -> dict:
135
+ """Show allowed chats (id and name) and directories. Use names as chat_id when sending."""
136
+ extra = security.load_extra(security.state_path())
137
+ c = cfg()
138
+ by_id = {v: k for k, v in c.names.items()}
139
+ return {
140
+ "chats": [{"id": i, "name": by_id.get(i)} for i in c.chat_ids],
141
+ "dirs": list(c.dirs),
142
+ "added_at_runtime": extra,
143
+ }
144
+
145
+
146
+ @mcp.tool()
147
+ async def add_chat(chat_id: str, ctx: Context, name: str | None = None) -> str:
148
+ """Allow another chat id, optionally with a name (e.g. "ali") usable as chat_id later. Asks the user to confirm."""
149
+ cid = security.validate_chat(chat_id)
150
+ entry = f"{security.validate_name(name)}={cid}" if name else cid
151
+ await _approve(ctx, f"send messages to chat {entry}")
152
+ _edit("chats", entry, True)
153
+ return f"added chat {entry}"
154
+
155
+
156
+ @mcp.tool()
157
+ async def add_dir(path: str, ctx: Context) -> str:
158
+ """Allow files from another directory (asks the user to confirm)."""
159
+ real = security.validate_dir(cfg(), path)
160
+ await _approve(ctx, f"send files from {real}")
161
+ return f"added dir {_edit('dirs', real, True)}"
162
+
163
+
164
+ @mcp.tool()
165
+ def remove_chat(chat: str) -> str:
166
+ """Remove a chat (id or name) that was added at runtime."""
167
+ for e in security.load_extra(security.state_path())["chats"]:
168
+ name, cid = security.parse_chat(e)
169
+ if chat.strip() in (cid, name):
170
+ _edit("chats", e, False)
171
+ return f"removed chat {e}"
172
+ raise security.DeniedError("not a runtime-added entry (env entries are changed in the environment)")
173
+
174
+
175
+ @mcp.tool()
176
+ def remove_dir(path: str) -> str:
177
+ """Remove a directory that was added at runtime."""
178
+ return f"removed dir {_edit('dirs', security.validate_dir(cfg(), path), False)}"
179
+
180
+
181
+ def main() -> None:
182
+ try:
183
+ base()
184
+ except security.ConfigError as e:
185
+ sys.exit(f"mcp-server-telegram-bot: {e}")
186
+ mcp.run()
187
+
188
+
189
+ if __name__ == "__main__":
190
+ main()
@@ -0,0 +1,27 @@
1
+ import logging
2
+
3
+ import httpx
4
+
5
+ # httpx logs full request URLs at INFO, and the URL contains the bot token
6
+ logging.getLogger("httpx").setLevel(logging.WARNING)
7
+
8
+
9
+ class TelegramError(Exception):
10
+ pass
11
+
12
+
13
+ async def call(token: str, method: str, data=None, files=None, client: httpx.AsyncClient | None = None) -> dict:
14
+ """Call a Bot API method. Errors never include the URL, so the token cannot leak."""
15
+ own = client is None
16
+ client = client or httpx.AsyncClient(timeout=60)
17
+ try:
18
+ r = await client.post(f"https://api.telegram.org/bot{token}/{method}", data=data, files=files)
19
+ body = r.json()
20
+ except (httpx.HTTPError, ValueError) as e:
21
+ raise TelegramError(f"request failed: {type(e).__name__}") from None
22
+ finally:
23
+ if own:
24
+ await client.aclose()
25
+ if not body.get("ok"):
26
+ raise TelegramError(f"Telegram error {body.get('error_code')}: {body.get('description')}")
27
+ return body["result"]
@@ -0,0 +1,286 @@
1
+ import asyncio
2
+ import os
3
+
4
+ import httpx
5
+ import pytest
6
+
7
+ from telegram_mcp import security, telegram
8
+
9
+ TOKEN = "123456:secret-token"
10
+
11
+
12
+ def cfg(tmp_path, chats=("1", "2")):
13
+ return security.load_config(
14
+ {"TELEGRAM_BOT_TOKEN": TOKEN, "ALLOWED_CHAT_IDS": ",".join(chats), "ALLOWED_DIRS": str(tmp_path)}
15
+ )
16
+
17
+
18
+ def test_missing_env():
19
+ with pytest.raises(security.ConfigError):
20
+ security.load_config({"ALLOWED_CHAT_IDS": "1"})
21
+ with pytest.raises(security.ConfigError):
22
+ security.load_config({"TELEGRAM_BOT_TOKEN": "x"})
23
+
24
+
25
+ def test_chat(tmp_path):
26
+ c = cfg(tmp_path)
27
+ assert security.resolve_chat(c, None) == "1"
28
+ assert security.resolve_chat(c, 2) == "2"
29
+ with pytest.raises(security.DeniedError):
30
+ security.resolve_chat(c, "999")
31
+
32
+
33
+ def test_path_ok_and_traversal(tmp_path):
34
+ c = cfg(tmp_path)
35
+ f = tmp_path / "a.txt"
36
+ f.write_text("hi")
37
+ real = security.check_path(c, str(f))
38
+ assert real == str(f.resolve()) and security.is_inside(c, real)
39
+ sub = tmp_path / "sub"
40
+ sub.mkdir()
41
+ out = security.check_path(c, str(sub / ".." / "a.txt"))
42
+ assert security.is_inside(c, out)
43
+ outside = tmp_path.parent / "elsewhere.txt"
44
+ outside.write_text("x")
45
+ assert not security.is_inside(c, security.check_path(c, str(sub / ".." / ".." / "elsewhere.txt")))
46
+
47
+
48
+ def test_symlink_escape(tmp_path):
49
+ allowed = tmp_path / "ok"
50
+ allowed.mkdir()
51
+ secret = tmp_path / "secret.txt"
52
+ secret.write_text("x")
53
+ link = allowed / "link.txt"
54
+ os.symlink(secret, link)
55
+ c = cfg(allowed)
56
+ assert not security.is_inside(c, security.check_path(c, str(link))) # resolves outside, so it must ask
57
+
58
+
59
+ def test_symlink_to_protected_is_refused(tmp_path):
60
+ key = tmp_path / ".ssh"
61
+ key.mkdir()
62
+ (key / "id").write_text("x")
63
+ link = tmp_path / "innocent.txt"
64
+ os.symlink(key / "id", link)
65
+ with pytest.raises(security.DeniedError):
66
+ security.check_path(cfg(tmp_path), str(link))
67
+
68
+
69
+ def test_protected_and_size(tmp_path, monkeypatch):
70
+ c = cfg(tmp_path)
71
+ (tmp_path / ".env").write_text("x")
72
+ (tmp_path / ".ssh").mkdir()
73
+ (tmp_path / ".ssh" / "id").write_text("x")
74
+ for p in (tmp_path / ".env", tmp_path / ".ssh" / "id"):
75
+ with pytest.raises(security.DeniedError):
76
+ security.check_path(c, str(p))
77
+ big = tmp_path / "big.bin"
78
+ big.write_text("x")
79
+ monkeypatch.setattr(security, "MAX_BYTES", 0)
80
+ with pytest.raises(security.DeniedError):
81
+ security.check_path(c, str(big))
82
+
83
+
84
+ def test_session_dir_auto_allowed(tmp_path):
85
+ work = tmp_path / "work"
86
+ work.mkdir()
87
+ f = work / "r.txt"
88
+ f.write_text("x")
89
+ env = {"TELEGRAM_BOT_TOKEN": "x", "ALLOWED_CHAT_IDS": "1"}
90
+ c = security.load_config(env, cwd=str(work))
91
+ assert security.is_inside(c, security.check_path(c, str(f)))
92
+ other = tmp_path / "other.txt"
93
+ other.write_text("x")
94
+ assert not security.is_inside(c, security.check_path(c, str(other)))
95
+
96
+
97
+ def test_home_or_root_session_is_not_auto_allowed(monkeypatch, tmp_path):
98
+ monkeypatch.setenv("HOME", str(tmp_path))
99
+ env = {"TELEGRAM_BOT_TOKEN": "x", "ALLOWED_CHAT_IDS": "1"}
100
+ assert security.load_config(env, cwd=str(tmp_path)).session_dir == ""
101
+ assert security.load_config(env, cwd="/").session_dir == ""
102
+
103
+
104
+ def test_blocked_paths(tmp_path, monkeypatch):
105
+ monkeypatch.setenv("HOME", str(tmp_path))
106
+ c = security.load_config(
107
+ {"TELEGRAM_BOT_TOKEN": "x", "ALLOWED_CHAT_IDS": "1", "DENY_PATHS": str(tmp_path / "mine")}, cwd=str(tmp_path / "w")
108
+ )
109
+ for rel in (".claude/x.json", ".config/gcloud/k", ".git/config", "a/id_rsa", "a/b.pem", "a/.env.local", "mine/f", ".aws/credentials"):
110
+ p = tmp_path / rel
111
+ p.parent.mkdir(parents=True, exist_ok=True)
112
+ p.write_text("x")
113
+ with pytest.raises(security.DeniedError):
114
+ security.check_path(c, str(p))
115
+ with pytest.raises(security.DeniedError):
116
+ security.check_path(c, "/etc/hostname")
117
+ ok = tmp_path / "w" / "notes.md"
118
+ ok.parent.mkdir()
119
+ ok.write_text("x")
120
+ assert security.check_path(c, str(ok))
121
+
122
+
123
+ def test_bad_access_mode():
124
+ with pytest.raises(security.ConfigError):
125
+ security.load_config({"TELEGRAM_BOT_TOKEN": "x", "ALLOWED_CHAT_IDS": "1", "ACCESS_MODE": "open"})
126
+
127
+
128
+ def test_error_hides_token():
129
+ def boom(request):
130
+ raise httpx.ConnectError("fail " + str(request.url))
131
+
132
+ async def run():
133
+ async with httpx.AsyncClient(transport=httpx.MockTransport(boom)) as cl:
134
+ await telegram.call(TOKEN, "sendMessage", client=cl)
135
+
136
+ with pytest.raises(telegram.TelegramError) as e:
137
+ asyncio.run(run())
138
+ assert "secret-token" not in str(e.value)
139
+
140
+
141
+ def test_httpx_logger_silenced():
142
+ import logging
143
+
144
+ assert logging.getLogger("httpx").getEffectiveLevel() >= logging.WARNING
145
+
146
+
147
+ def test_extra_merge_and_validation(tmp_path, monkeypatch):
148
+ monkeypatch.setenv("TELEGRAM_MCP_STATE", str(tmp_path / "s" / "state.json"))
149
+ from telegram_mcp import server
150
+
151
+ server._base = cfg(tmp_path)
152
+ d = tmp_path / "more"
153
+ d.mkdir()
154
+ assert server._edit("chats", "7", True) == "7"
155
+ assert server._edit("dirs", str(d), True) == str(d)
156
+ c = server.cfg()
157
+ assert "7" in c.chat_ids and str(d) in c.dirs
158
+ server._edit("chats", "7", False)
159
+ assert "7" not in server.cfg().chat_ids
160
+ with pytest.raises(security.DeniedError):
161
+ server._edit("chats", "1", False) # env entry cannot be removed
162
+ for bad in ("/", os.path.expanduser("~"), str(tmp_path / "nope")):
163
+ with pytest.raises(security.DeniedError):
164
+ security.validate_dir(server.cfg(), bad)
165
+ with pytest.raises(security.DeniedError):
166
+ security.validate_chat("abc")
167
+
168
+
169
+ def test_approve_fails_closed():
170
+ from types import SimpleNamespace as NS
171
+
172
+ from telegram_mcp import server
173
+
174
+ class Ctx:
175
+ def __init__(self, r=None, exc=None):
176
+ self.r, self.exc = r, exc
177
+
178
+ async def elicit(self, *a):
179
+ if self.exc:
180
+ raise self.exc
181
+ return self.r
182
+
183
+ ok = NS(action="accept", data=NS(confirm=True))
184
+ asyncio.run(server._approve(Ctx(ok), "x"))
185
+ for ctx in (Ctx(NS(action="decline", data=None)), Ctx(NS(action="accept", data=NS(confirm=False))), Ctx(exc=RuntimeError())):
186
+ with pytest.raises(security.DeniedError):
187
+ asyncio.run(server._approve(ctx, "x"))
188
+
189
+
190
+ def test_chat_names(tmp_path, monkeypatch):
191
+ monkeypatch.setenv("TELEGRAM_MCP_STATE", str(tmp_path / "state.json"))
192
+ from telegram_mcp import server
193
+
194
+ server._base = security.load_config(
195
+ {"TELEGRAM_BOT_TOKEN": TOKEN, "ALLOWED_CHAT_IDS": "me=1,2", "ALLOWED_DIRS": ""}
196
+ )
197
+ server._edit("chats", "ali=77", True)
198
+ c = server.cfg()
199
+ assert security.resolve_chat(c, "Ali") == "77"
200
+ assert security.resolve_chat(c, "me") == "1"
201
+ assert security.resolve_chat(c, None) == "1"
202
+ assert server.remove_chat("ali") == "removed chat ali=77"
203
+ with pytest.raises(security.DeniedError):
204
+ security.resolve_chat(server.cfg(), "ali")
205
+ with pytest.raises(security.DeniedError):
206
+ server.remove_chat("me")
207
+ with pytest.raises(security.DeniedError):
208
+ security.validate_name("a b=c")
209
+
210
+
211
+ def test_pdf_render_unicode_and_limits():
212
+ from telegram_mcp import pdf
213
+
214
+ out = pdf.render("# Sarlavha\nOʻzbekcha: gʻoya, ўзбек, қ\n- band\n" + "x" * 500, title="Hisobot")
215
+ assert out.startswith(b"%PDF")
216
+ with pytest.raises(ValueError):
217
+ pdf.render("x" * (pdf.MAX_CHARS + 1))
218
+
219
+
220
+ def test_send_generated(tmp_path, monkeypatch):
221
+ from telegram_mcp import server
222
+
223
+ monkeypatch.setenv("TELEGRAM_MCP_STATE", str(tmp_path / "s.json"))
224
+ monkeypatch.setenv("TELEGRAM_MCP_LOG", str(tmp_path / "sent.log"))
225
+ server._base = cfg(tmp_path)
226
+ seen = {}
227
+
228
+ async def fake(token, method, data=None, files=None, client=None):
229
+ seen.update(method=method, data=data, files=files)
230
+ return {"message_id": 5}
231
+
232
+ monkeypatch.setattr(server.telegram, "call", fake)
233
+ r = asyncio.run(server.send_generated("../../My Report!", "# Hi", "md", chat_id="2"))
234
+ assert r == "sent My_Report.md, message_id=5"
235
+ assert seen["data"]["chat_id"] == "2" and seen["files"]["document"][0] == "My_Report.md"
236
+ asyncio.run(server.send_generated("r", "# Hi", "pdf"))
237
+ assert seen["files"]["document"][1].startswith(b"%PDF")
238
+ with pytest.raises(ValueError):
239
+ asyncio.run(server.send_generated("r", "x", "exe"))
240
+ with pytest.raises(security.DeniedError):
241
+ asyncio.run(server.send_generated("r", "x", "md", chat_id="999"))
242
+ assert "generated=My_Report.md" in (tmp_path / "sent.log").read_text()
243
+
244
+
245
+ def test_send_file_tiers(tmp_path, monkeypatch):
246
+ from types import SimpleNamespace as NS
247
+
248
+ from telegram_mcp import server
249
+
250
+ monkeypatch.setenv("TELEGRAM_MCP_STATE", str(tmp_path / "s.json"))
251
+ monkeypatch.setenv("TELEGRAM_MCP_LOG", str(tmp_path / "sent.log"))
252
+ work = tmp_path / "work"
253
+ work.mkdir()
254
+ (work / "in.txt").write_text("x")
255
+ (tmp_path / "out.txt").write_text("x")
256
+ sent = []
257
+
258
+ async def fake(token, method, data=None, files=None, client=None):
259
+ sent.append(method)
260
+ return {"message_id": 1}
261
+
262
+ monkeypatch.setattr(server.telegram, "call", fake)
263
+
264
+ class Ctx:
265
+ def __init__(self, yes):
266
+ self.yes, self.asked = yes, 0
267
+
268
+ async def elicit(self, *a):
269
+ self.asked += 1
270
+ return NS(action="accept", data=NS(confirm=self.yes))
271
+
272
+ def run(mode, path, ctx):
273
+ env = {"TELEGRAM_BOT_TOKEN": TOKEN, "ALLOWED_CHAT_IDS": "1", "ACCESS_MODE": mode}
274
+ server._base = security.load_config(env, cwd=str(work))
275
+ return asyncio.run(server.send_document(ctx, str(path)))
276
+
277
+ c = Ctx(True)
278
+ run("ask", work / "in.txt", c)
279
+ assert c.asked == 0 # inside session dir: no prompt
280
+ run("ask", tmp_path / "out.txt", c)
281
+ assert c.asked == 1 # outside: asked, approved
282
+ with pytest.raises(security.DeniedError):
283
+ run("ask", tmp_path / "out.txt", Ctx(False))
284
+ with pytest.raises(security.DeniedError):
285
+ run("strict", tmp_path / "out.txt", Ctx(True))
286
+ assert len(sent) == 2