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.
- mcp_server_telegram_bot-0.2.0/.gitignore +6 -0
- mcp_server_telegram_bot-0.2.0/LICENSE +21 -0
- mcp_server_telegram_bot-0.2.0/PKG-INFO +76 -0
- mcp_server_telegram_bot-0.2.0/README.md +60 -0
- mcp_server_telegram_bot-0.2.0/pyproject.toml +36 -0
- mcp_server_telegram_bot-0.2.0/src/telegram_mcp/__init__.py +0 -0
- mcp_server_telegram_bot-0.2.0/src/telegram_mcp/fonts/DejaVuSans-Bold.ttf +0 -0
- mcp_server_telegram_bot-0.2.0/src/telegram_mcp/fonts/DejaVuSans.ttf +0 -0
- mcp_server_telegram_bot-0.2.0/src/telegram_mcp/fonts/LICENSE-DejaVu +187 -0
- mcp_server_telegram_bot-0.2.0/src/telegram_mcp/pdf.py +36 -0
- mcp_server_telegram_bot-0.2.0/src/telegram_mcp/security.py +174 -0
- mcp_server_telegram_bot-0.2.0/src/telegram_mcp/server.py +190 -0
- mcp_server_telegram_bot-0.2.0/src/telegram_mcp/telegram.py +27 -0
- mcp_server_telegram_bot-0.2.0/tests/test_security.py +286 -0
|
@@ -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"]
|
|
File without changes
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|