mcgram 0.2.0__tar.gz → 0.2.2__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.
- {mcgram-0.2.0 → mcgram-0.2.2}/CHANGELOG.md +26 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/PKG-INFO +9 -9
- {mcgram-0.2.0 → mcgram-0.2.2}/README.md +8 -8
- {mcgram-0.2.0 → mcgram-0.2.2}/pyproject.toml +1 -1
- {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/__init__.py +1 -1
- {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/ask_registry.py +24 -1
- {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/cli_init.py +43 -4
- {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/config.py +18 -5
- {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/data/config.example.yaml +2 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_ask_registry.py +46 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_channels.py +45 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_cli.py +3 -1
- {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_cli_init.py +62 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_cli_init_ntfy.py +3 -1
- {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_tools_ask.py +5 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/uv.lock +1 -1
- {mcgram-0.2.0 → mcgram-0.2.2}/.env.example +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/.github/workflows/ci.yml +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/.gitignore +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/LICENSE +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/config.example.yaml +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/docs/architecture.md +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/docs/images/flow.png +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/docs/images/hero.png +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/docs/images/logo.png +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/docs/manual-smoke-test.md +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/docs/security-threat-model.md +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/plans/20260521-brainstorm-mcgram-telegram-mcp/brainstorm-report.md +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/plans/20260521-implement-mcgram-telegram-mcp/phase-01-scaffolding-config-audit.md +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/plans/20260521-implement-mcgram-telegram-mcp/phase-02-telegram-client-send-tools.md +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/plans/20260521-implement-mcgram-telegram-mcp/phase-03-ask-flow-reminders.md +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/plans/20260521-implement-mcgram-telegram-mcp/phase-04-cli-companion-skill.md +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/plans/20260521-implement-mcgram-telegram-mcp/phase-05-tests-docs-ci.md +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/plans/20260521-implement-mcgram-telegram-mcp/plan.md +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/plans/260522-1355-ntfy-transport-integration/phase-01-ntfy-client.md +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/plans/260522-1355-ntfy-transport-integration/phase-02-config-schema.md +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/plans/260522-1355-ntfy-transport-integration/phase-03-tools-branch.md +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/plans/260522-1355-ntfy-transport-integration/phase-04-ask-and-reminders.md +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/plans/260522-1355-ntfy-transport-integration/phase-05-cli.md +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/plans/260522-1355-ntfy-transport-integration/phase-06-docs-skill.md +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/plans/260522-1355-ntfy-transport-integration/plan.md +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/scripts/live-smoke.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/audit.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/cli.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/cli_audit.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/cli_channel.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/cli_doctor.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/data/__init__.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/data/skill/SKILL.md +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/dispatch.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/errors.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/lock.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/ntfy_client.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/polling.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/rate_limiter.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/reminders.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/runtime.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/server.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/skill_installer.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/tg_client.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/tools/__init__.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/tools/ask.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/tools/cancel_reminder.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/tools/list_reminders.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/tools/send_file.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/tools/send_message.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/tools/send_video.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/tools/set_reminder.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/update_dispatcher.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/tests/__init__.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/tests/conftest.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_audit.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_cli_audit.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_cli_channel.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_cli_channel_ntfy.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_cli_doctor.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_cli_doctor_ntfy.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_config.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_config_ntfy.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_lock.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_ntfy_client.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_polling.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_polling_conflict.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_rate_limiter.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_reminders.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_server_main.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_server_smoke.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_skill_installer.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_tg_client.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_tools_ntfy.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_tools_reminder.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_tools_send_file.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_tools_send_message.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_tools_send_video.py +0 -0
- {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_update_dispatcher.py +0 -0
|
@@ -5,6 +5,32 @@ All notable changes to mcgram will be documented in this file.
|
|
|
5
5
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
|
|
6
6
|
this project adheres to [Semantic Versioning](https://semver.org/).
|
|
7
7
|
|
|
8
|
+
## [0.2.2] — 2026-07-26
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `mcgram init` now probes `api.telegram.org` reachability and bakes the result
|
|
12
|
+
into the scaffolded config: `bot.disable_polling` is pre-set (`true` when the
|
|
13
|
+
machine looks Telegram-blocked, `false` when reachable). Skippable via
|
|
14
|
+
`MCGRAM_INIT_NO_TG_PROBE=1` (air-gapped installs / CI).
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- Default-channel auto-seed (`Settings._seed_default_channel`) now prefers ntfy
|
|
18
|
+
when a bot is configured but `disable_polling=true` (blocked machine) and an
|
|
19
|
+
ntfy topic exists — so notifications land on a reachable transport instead of
|
|
20
|
+
a Telegram channel that can't be polled. Explicit `channels.default` still wins.
|
|
21
|
+
- `ask` now rewrites the question message to record the operator's choice
|
|
22
|
+
(`✅ Đã chọn: …` for buttons, `✅ Trả lời: …` for freetext) instead of only
|
|
23
|
+
stripping the inline keyboard — the selection stays visible in chat history.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
- `test_version` no longer hard-codes the version string (asserts `__version__`).
|
|
27
|
+
|
|
28
|
+
## [0.2.1] — 2026-07-26
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
- README images and doc links now use absolute GitHub URLs so they render on
|
|
32
|
+
the PyPI project page (relative paths only resolve inside the GitHub repo).
|
|
33
|
+
|
|
8
34
|
## [0.2.0] — 2026-05-22
|
|
9
35
|
|
|
10
36
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mcgram
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Notification bridge for Claude Code (Telegram + ntfy.sh) — send notifications, ask short questions, set reminders via Telegram bot or ntfy.sh topic.
|
|
5
5
|
Project-URL: Homepage, https://github.com/tvtdev94/mcgram
|
|
6
6
|
Project-URL: Repository, https://github.com/tvtdev94/mcgram
|
|
@@ -40,7 +40,7 @@ Description-Content-Type: text/markdown
|
|
|
40
40
|
|
|
41
41
|
<div align="center">
|
|
42
42
|
|
|
43
|
-
<img src="docs/images/logo.png" alt="mcgram logo" width="120" />
|
|
43
|
+
<img src="https://raw.githubusercontent.com/tvtdev94/mcgram/main/docs/images/logo.png" alt="mcgram logo" width="120" />
|
|
44
44
|
|
|
45
45
|
# mcgram
|
|
46
46
|
|
|
@@ -49,11 +49,11 @@ Description-Content-Type: text/markdown
|
|
|
49
49
|
[](https://github.com/tvtdev94/mcgram/actions/workflows/ci.yml)
|
|
50
50
|
[](https://pypi.org/project/mcgram/)
|
|
51
51
|
[](https://www.python.org/downloads/)
|
|
52
|
-
[](LICENSE)
|
|
52
|
+
[](https://github.com/tvtdev94/mcgram/blob/main/LICENSE)
|
|
53
53
|
[](#)
|
|
54
54
|
[](#)
|
|
55
55
|
|
|
56
|
-
<img src="docs/images/hero.png" alt="mcgram hero" width="720" />
|
|
56
|
+
<img src="https://raw.githubusercontent.com/tvtdev94/mcgram/main/docs/images/hero.png" alt="mcgram hero" width="720" />
|
|
57
57
|
|
|
58
58
|
</div>
|
|
59
59
|
|
|
@@ -77,7 +77,7 @@ Single process, no daemon, no VPS, no webhook setup. Lives inside the MCP server
|
|
|
77
77
|
## How it fits together
|
|
78
78
|
|
|
79
79
|
<div align="center">
|
|
80
|
-
<img src="docs/images/flow.png" alt="Architecture flow" width="720" />
|
|
80
|
+
<img src="https://raw.githubusercontent.com/tvtdev94/mcgram/main/docs/images/flow.png" alt="Architecture flow" width="720" />
|
|
81
81
|
</div>
|
|
82
82
|
|
|
83
83
|
Claude Code spawns `mcgram` over stdio. mcgram opens a long-poll to the Telegram Bot API. When you tap a button or type a reply, the update lands on your phone-out / mcgram-in side, the operator allowlist filters non-you traffic, and the answer flows back to Claude as the tool result.
|
|
@@ -147,7 +147,7 @@ Declare both `ntfy:` and `bot:` in config — each named channel picks its own t
|
|
|
147
147
|
| **`cancel_reminder`** | — | — | Cancel a pending reminder |
|
|
148
148
|
| **`list_reminders`** | — | — | List currently pending reminders |
|
|
149
149
|
|
|
150
|
-
ntfy.sh has no 2-way input, so `ask` returns `transport_unsupported` on ntfy channels. The companion [Claude Code skill](src/mcgram/data/skill/SKILL.md) (installed by `mcgram init`) teaches Claude when to call which tool — both **English** and **Vietnamese** trigger phrases are recognized.
|
|
150
|
+
ntfy.sh has no 2-way input, so `ask` returns `transport_unsupported` on ntfy channels. The companion [Claude Code skill](https://github.com/tvtdev94/mcgram/blob/main/src/mcgram/data/skill/SKILL.md) (installed by `mcgram init`) teaches Claude when to call which tool — both **English** and **Vietnamese** trigger phrases are recognized.
|
|
151
151
|
|
|
152
152
|
## Channels
|
|
153
153
|
|
|
@@ -236,7 +236,7 @@ allow_outside_cwd: false # send_file restricted to CWD by default
|
|
|
236
236
|
| ⏱ **`ask` DoS** | Hard timeout cap (600s) so a forgetful user can't freeze Claude forever |
|
|
237
237
|
| 🔁 **Polling conflict** | Telegram 409 caught with clean error — no crash loop |
|
|
238
238
|
|
|
239
|
-
Full STRIDE analysis: [docs/security-threat-model.md](docs/security-threat-model.md).
|
|
239
|
+
Full STRIDE analysis: [docs/security-threat-model.md](https://github.com/tvtdev94/mcgram/blob/main/docs/security-threat-model.md).
|
|
240
240
|
|
|
241
241
|
## Audit
|
|
242
242
|
|
|
@@ -269,7 +269,7 @@ Sample lines:
|
|
|
269
269
|
|
|
270
270
|
## Architecture
|
|
271
271
|
|
|
272
|
-
Full module map, lifecycle diagram, and data flow: [docs/architecture.md](docs/architecture.md).
|
|
272
|
+
Full module map, lifecycle diagram, and data flow: [docs/architecture.md](https://github.com/tvtdev94/mcgram/blob/main/docs/architecture.md).
|
|
273
273
|
|
|
274
274
|
```
|
|
275
275
|
src/mcgram/
|
|
@@ -308,4 +308,4 @@ uv run ruff check src/ tests/
|
|
|
308
308
|
|
|
309
309
|
## License
|
|
310
310
|
|
|
311
|
-
MIT — see [LICENSE](LICENSE).
|
|
311
|
+
MIT — see [LICENSE](https://github.com/tvtdev94/mcgram/blob/main/LICENSE).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
<img src="docs/images/logo.png" alt="mcgram logo" width="120" />
|
|
3
|
+
<img src="https://raw.githubusercontent.com/tvtdev94/mcgram/main/docs/images/logo.png" alt="mcgram logo" width="120" />
|
|
4
4
|
|
|
5
5
|
# mcgram
|
|
6
6
|
|
|
@@ -9,11 +9,11 @@
|
|
|
9
9
|
[](https://github.com/tvtdev94/mcgram/actions/workflows/ci.yml)
|
|
10
10
|
[](https://pypi.org/project/mcgram/)
|
|
11
11
|
[](https://www.python.org/downloads/)
|
|
12
|
-
[](LICENSE)
|
|
12
|
+
[](https://github.com/tvtdev94/mcgram/blob/main/LICENSE)
|
|
13
13
|
[](#)
|
|
14
14
|
[](#)
|
|
15
15
|
|
|
16
|
-
<img src="docs/images/hero.png" alt="mcgram hero" width="720" />
|
|
16
|
+
<img src="https://raw.githubusercontent.com/tvtdev94/mcgram/main/docs/images/hero.png" alt="mcgram hero" width="720" />
|
|
17
17
|
|
|
18
18
|
</div>
|
|
19
19
|
|
|
@@ -37,7 +37,7 @@ Single process, no daemon, no VPS, no webhook setup. Lives inside the MCP server
|
|
|
37
37
|
## How it fits together
|
|
38
38
|
|
|
39
39
|
<div align="center">
|
|
40
|
-
<img src="docs/images/flow.png" alt="Architecture flow" width="720" />
|
|
40
|
+
<img src="https://raw.githubusercontent.com/tvtdev94/mcgram/main/docs/images/flow.png" alt="Architecture flow" width="720" />
|
|
41
41
|
</div>
|
|
42
42
|
|
|
43
43
|
Claude Code spawns `mcgram` over stdio. mcgram opens a long-poll to the Telegram Bot API. When you tap a button or type a reply, the update lands on your phone-out / mcgram-in side, the operator allowlist filters non-you traffic, and the answer flows back to Claude as the tool result.
|
|
@@ -107,7 +107,7 @@ Declare both `ntfy:` and `bot:` in config — each named channel picks its own t
|
|
|
107
107
|
| **`cancel_reminder`** | — | — | Cancel a pending reminder |
|
|
108
108
|
| **`list_reminders`** | — | — | List currently pending reminders |
|
|
109
109
|
|
|
110
|
-
ntfy.sh has no 2-way input, so `ask` returns `transport_unsupported` on ntfy channels. The companion [Claude Code skill](src/mcgram/data/skill/SKILL.md) (installed by `mcgram init`) teaches Claude when to call which tool — both **English** and **Vietnamese** trigger phrases are recognized.
|
|
110
|
+
ntfy.sh has no 2-way input, so `ask` returns `transport_unsupported` on ntfy channels. The companion [Claude Code skill](https://github.com/tvtdev94/mcgram/blob/main/src/mcgram/data/skill/SKILL.md) (installed by `mcgram init`) teaches Claude when to call which tool — both **English** and **Vietnamese** trigger phrases are recognized.
|
|
111
111
|
|
|
112
112
|
## Channels
|
|
113
113
|
|
|
@@ -196,7 +196,7 @@ allow_outside_cwd: false # send_file restricted to CWD by default
|
|
|
196
196
|
| ⏱ **`ask` DoS** | Hard timeout cap (600s) so a forgetful user can't freeze Claude forever |
|
|
197
197
|
| 🔁 **Polling conflict** | Telegram 409 caught with clean error — no crash loop |
|
|
198
198
|
|
|
199
|
-
Full STRIDE analysis: [docs/security-threat-model.md](docs/security-threat-model.md).
|
|
199
|
+
Full STRIDE analysis: [docs/security-threat-model.md](https://github.com/tvtdev94/mcgram/blob/main/docs/security-threat-model.md).
|
|
200
200
|
|
|
201
201
|
## Audit
|
|
202
202
|
|
|
@@ -229,7 +229,7 @@ Sample lines:
|
|
|
229
229
|
|
|
230
230
|
## Architecture
|
|
231
231
|
|
|
232
|
-
Full module map, lifecycle diagram, and data flow: [docs/architecture.md](docs/architecture.md).
|
|
232
|
+
Full module map, lifecycle diagram, and data flow: [docs/architecture.md](https://github.com/tvtdev94/mcgram/blob/main/docs/architecture.md).
|
|
233
233
|
|
|
234
234
|
```
|
|
235
235
|
src/mcgram/
|
|
@@ -268,4 +268,4 @@ uv run ruff check src/ tests/
|
|
|
268
268
|
|
|
269
269
|
## License
|
|
270
270
|
|
|
271
|
-
MIT — see [LICENSE](LICENSE).
|
|
271
|
+
MIT — see [LICENSE](https://github.com/tvtdev94/mcgram/blob/main/LICENSE).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "mcgram"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.2"
|
|
4
4
|
description = "Notification bridge for Claude Code (Telegram + ntfy.sh) — send notifications, ask short questions, set reminders via Telegram bot or ntfy.sh topic."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -68,7 +68,7 @@ class AskRegistry:
|
|
|
68
68
|
)
|
|
69
69
|
try:
|
|
70
70
|
value, source = await asyncio.wait_for(fut, timeout=timeout_s)
|
|
71
|
-
await
|
|
71
|
+
await _mark_answered(client, chat_id, msg["message_id"], question, value, source)
|
|
72
72
|
return {"value": value, "source": source, "question_id": q_id}
|
|
73
73
|
except TimeoutError:
|
|
74
74
|
await _mark_timed_out(client, chat_id, msg["message_id"], question)
|
|
@@ -124,6 +124,29 @@ class AskRegistry:
|
|
|
124
124
|
self._answer_hook = hook
|
|
125
125
|
|
|
126
126
|
|
|
127
|
+
async def _mark_answered(
|
|
128
|
+
client: TelegramClient,
|
|
129
|
+
chat_id: int,
|
|
130
|
+
message_id: int,
|
|
131
|
+
question: str,
|
|
132
|
+
value: str,
|
|
133
|
+
source: str,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Record the operator's answer in the chat by rewriting the question message.
|
|
136
|
+
|
|
137
|
+
Without this the inline keyboard just vanishes on reply, leaving no trace in
|
|
138
|
+
chat history of what was picked. Append the choice, then drop the buttons.
|
|
139
|
+
"""
|
|
140
|
+
label = "Đã chọn" if source == "button" else "Trả lời"
|
|
141
|
+
try:
|
|
142
|
+
await client.edit_message_text(
|
|
143
|
+
chat_id, message_id, f"{question}\n\n✅ {label}: {value}"
|
|
144
|
+
)
|
|
145
|
+
except Exception:
|
|
146
|
+
log.debug("edit_message_text on answer failed (non-fatal)", exc_info=True)
|
|
147
|
+
await _strip_keyboard(client, chat_id, message_id)
|
|
148
|
+
|
|
149
|
+
|
|
127
150
|
async def _strip_keyboard(client: TelegramClient, chat_id: int, message_id: int) -> None:
|
|
128
151
|
try:
|
|
129
152
|
await client.edit_message_reply_markup(chat_id, message_id, reply_markup=None)
|
|
@@ -120,9 +120,34 @@ def _register_with_claude_code(cfg: Path, *, force: bool) -> None:
|
|
|
120
120
|
print(f" stderr: {result.stderr.strip()[:200]}")
|
|
121
121
|
|
|
122
122
|
|
|
123
|
-
def _render_config_template(topic: str) -> str:
|
|
124
|
-
"""Substitute
|
|
125
|
-
|
|
123
|
+
def _render_config_template(topic: str, *, tg_disable_polling: bool) -> str:
|
|
124
|
+
"""Substitute placeholders into the bundled config template: the generated
|
|
125
|
+
ntfy topic and the detected Telegram reachability (`disable_polling`)."""
|
|
126
|
+
return (
|
|
127
|
+
_bundled_config_yaml()
|
|
128
|
+
.replace("{{NTFY_TOPIC}}", topic)
|
|
129
|
+
.replace("{{TG_DISABLE_POLLING}}", "true" if tg_disable_polling else "false")
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _probe_telegram_reachable(
|
|
134
|
+
api_root: str = "https://api.telegram.org", timeout: float = 5.0
|
|
135
|
+
) -> bool:
|
|
136
|
+
"""Best-effort: can this machine reach the Telegram Bot API?
|
|
137
|
+
|
|
138
|
+
Any HTTP response (even 404) means the host is reachable -> True. A connect
|
|
139
|
+
or timeout error means it is blocked/unreachable -> False. Never raises.
|
|
140
|
+
|
|
141
|
+
Returns True (skips the probe) when MCGRAM_INIT_NO_TG_PROBE is set — for
|
|
142
|
+
air-gapped installs and tests, where 'reachable' keeps disable_polling=false.
|
|
143
|
+
"""
|
|
144
|
+
if os.environ.get("MCGRAM_INIT_NO_TG_PROBE", "").strip().lower() in {"1", "true", "yes"}:
|
|
145
|
+
return True
|
|
146
|
+
try:
|
|
147
|
+
httpx.get(api_root, timeout=timeout)
|
|
148
|
+
return True
|
|
149
|
+
except httpx.HTTPError:
|
|
150
|
+
return False
|
|
126
151
|
|
|
127
152
|
|
|
128
153
|
def _seed_ntfy_subscription(server: str, topic: str) -> None:
|
|
@@ -184,9 +209,14 @@ def init_config(*, force: bool = False) -> int:
|
|
|
184
209
|
skipped: list[str] = []
|
|
185
210
|
|
|
186
211
|
fresh_scaffold = False
|
|
212
|
+
tg_reachable = True
|
|
187
213
|
if not cfg.exists() or force:
|
|
188
214
|
topic = _generate_topic()
|
|
189
|
-
|
|
215
|
+
tg_reachable = _probe_telegram_reachable()
|
|
216
|
+
cfg.write_text(
|
|
217
|
+
_render_config_template(topic, tg_disable_polling=not tg_reachable),
|
|
218
|
+
encoding="utf-8",
|
|
219
|
+
)
|
|
190
220
|
created.append(str(cfg))
|
|
191
221
|
fresh_scaffold = True
|
|
192
222
|
else:
|
|
@@ -204,6 +234,15 @@ def init_config(*, force: bool = False) -> int:
|
|
|
204
234
|
for p in skipped:
|
|
205
235
|
print(f"skipped {p} (already exists; use --force to overwrite)")
|
|
206
236
|
|
|
237
|
+
if fresh_scaffold:
|
|
238
|
+
if tg_reachable:
|
|
239
|
+
print("detect telegram reachable — enable `bot:` later for 2-way `ask`")
|
|
240
|
+
else:
|
|
241
|
+
print(
|
|
242
|
+
"detect api.telegram.org unreachable — this machine looks "
|
|
243
|
+
"Telegram-blocked; ntfy stays default (bot.disable_polling pre-set true)"
|
|
244
|
+
)
|
|
245
|
+
|
|
207
246
|
install_skill(quiet=False, force=force)
|
|
208
247
|
_register_with_claude_code(cfg, force=force)
|
|
209
248
|
|
|
@@ -154,25 +154,38 @@ class Settings(BaseModel):
|
|
|
154
154
|
|
|
155
155
|
@model_validator(mode="after")
|
|
156
156
|
def _seed_default_channel(self) -> Settings:
|
|
157
|
-
"""Auto-seed `default` channel based on which transport
|
|
157
|
+
"""Auto-seed `default` channel based on which transport can actually serve.
|
|
158
158
|
|
|
159
|
-
|
|
160
|
-
|
|
159
|
+
User's explicit `channels.default` always wins. Otherwise Telegram is the
|
|
160
|
+
default only when it can fully operate (polling on → `ask` works). A machine
|
|
161
|
+
that blocks Telegram sets `bot.disable_polling=true`; there, if ntfy is
|
|
162
|
+
configured, the default routes to ntfy instead — so notifications land
|
|
163
|
+
somewhere reachable rather than a transport that can't be polled.
|
|
161
164
|
"""
|
|
162
165
|
if DEFAULT_CHANNEL in self.channels:
|
|
163
166
|
return self
|
|
164
|
-
|
|
167
|
+
ntfy_usable = self.ntfy is not None and bool(self.ntfy.default_topic)
|
|
168
|
+
if self.bot is not None and not self.bot.disable_polling:
|
|
165
169
|
self.channels[DEFAULT_CHANNEL] = ChannelConfig(
|
|
166
170
|
transport="telegram",
|
|
167
171
|
chat_id=self.bot.operator_chat_id,
|
|
168
172
|
description="Auto-created from bot.operator_chat_id",
|
|
169
173
|
)
|
|
170
|
-
elif
|
|
174
|
+
elif ntfy_usable:
|
|
175
|
+
assert self.ntfy is not None
|
|
171
176
|
self.channels[DEFAULT_CHANNEL] = ChannelConfig(
|
|
172
177
|
transport="ntfy",
|
|
173
178
|
ntfy_topic=self.ntfy.default_topic,
|
|
174
179
|
description="Auto-created from ntfy.default_topic",
|
|
175
180
|
)
|
|
181
|
+
elif self.bot is not None:
|
|
182
|
+
# bot present but polling disabled and no ntfy fallback — still seed a
|
|
183
|
+
# default so send-only Telegram deployments have a target.
|
|
184
|
+
self.channels[DEFAULT_CHANNEL] = ChannelConfig(
|
|
185
|
+
transport="telegram",
|
|
186
|
+
chat_id=self.bot.operator_chat_id,
|
|
187
|
+
description="Auto-created from bot.operator_chat_id (send-only)",
|
|
188
|
+
)
|
|
176
189
|
return self
|
|
177
190
|
|
|
178
191
|
def resolve_destination(self, name: str | None = None) -> Destination:
|
|
@@ -16,6 +16,8 @@ ntfy:
|
|
|
16
16
|
# bot:
|
|
17
17
|
# token_env: MCGRAM_BOT_TOKEN
|
|
18
18
|
# operator_chat_id: 0 # your personal chat ID (integer)
|
|
19
|
+
# disable_polling: {{TG_DISABLE_POLLING}} # auto-detected at init: true = api.telegram.org
|
|
20
|
+
# # looked unreachable (send-only, no `ask`); false = reachable
|
|
19
21
|
|
|
20
22
|
defaults:
|
|
21
23
|
parse_mode: plain # plain | markdown_v2 (default plain)
|
|
@@ -190,5 +190,51 @@ async def test_callback_index_out_of_range() -> None:
|
|
|
190
190
|
assert result["value"] == "only"
|
|
191
191
|
|
|
192
192
|
|
|
193
|
+
async def test_button_answer_echoed_into_message() -> None:
|
|
194
|
+
"""After a button tap the question message is rewritten to record the choice."""
|
|
195
|
+
client = FakeClient()
|
|
196
|
+
reg = AskRegistry()
|
|
197
|
+
reg.set_answer_hook(client.answer_callback_query)
|
|
198
|
+
|
|
199
|
+
async def driver() -> None:
|
|
200
|
+
for _ in range(50):
|
|
201
|
+
if reg.open_count:
|
|
202
|
+
break
|
|
203
|
+
await asyncio.sleep(0.01)
|
|
204
|
+
q_id = next(iter(reg._pending))
|
|
205
|
+
await reg.handle_update({"callback_query": {"id": "cq1", "data": f"{q_id}:1"}})
|
|
206
|
+
|
|
207
|
+
drv = asyncio.create_task(driver())
|
|
208
|
+
result = await reg.open(
|
|
209
|
+
client, chat_id=1, question="Deploy?", options=["Yes", "No"], timeout_s=5
|
|
210
|
+
)
|
|
211
|
+
await drv
|
|
212
|
+
assert result["value"] == "No"
|
|
213
|
+
text_edits = [e for e in client.edited if e["kind"] == "text"]
|
|
214
|
+
assert any("Deploy?" in e["text"] and "Đã chọn: No" in e["text"] for e in text_edits)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
async def test_freetext_answer_echoed_into_message() -> None:
|
|
218
|
+
client = FakeClient()
|
|
219
|
+
reg = AskRegistry()
|
|
220
|
+
|
|
221
|
+
async def driver() -> None:
|
|
222
|
+
for _ in range(50):
|
|
223
|
+
if reg.open_count:
|
|
224
|
+
break
|
|
225
|
+
await asyncio.sleep(0.01)
|
|
226
|
+
await asyncio.sleep(0.01)
|
|
227
|
+
await reg.handle_update({
|
|
228
|
+
"message": {"chat": {"id": 1}, "text": "ship it", "date": time.time() + 1}
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
drv = asyncio.create_task(driver())
|
|
232
|
+
result = await reg.open(client, chat_id=1, question="Why?", options=None, timeout_s=5)
|
|
233
|
+
await drv
|
|
234
|
+
assert result["value"] == "ship it"
|
|
235
|
+
text_edits = [e for e in client.edited if e["kind"] == "text"]
|
|
236
|
+
assert any("Trả lời: ship it" in e["text"] for e in text_edits)
|
|
237
|
+
|
|
238
|
+
|
|
193
239
|
# Suppress "Unused" import on bare envs
|
|
194
240
|
_ = pytest
|
|
@@ -61,3 +61,48 @@ def test_unknown_channel_raises(tmp_path: Path) -> None:
|
|
|
61
61
|
s = _write(tmp_path / "c.yaml", "bot:\n operator_chat_id: 1\n")
|
|
62
62
|
with pytest.raises(ConfigError, match="unknown channel"):
|
|
63
63
|
s.resolve_channel("nope")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_default_routes_to_ntfy_when_telegram_blocked(tmp_path: Path) -> None:
|
|
67
|
+
"""bot present but polling disabled (blocked machine) + ntfy → default = ntfy."""
|
|
68
|
+
s = _write(
|
|
69
|
+
tmp_path / "c.yaml",
|
|
70
|
+
"""
|
|
71
|
+
bot:
|
|
72
|
+
operator_chat_id: 1
|
|
73
|
+
disable_polling: true
|
|
74
|
+
ntfy:
|
|
75
|
+
default_topic: mcgram-abc
|
|
76
|
+
""",
|
|
77
|
+
)
|
|
78
|
+
dest = s.resolve_destination()
|
|
79
|
+
assert dest.transport == "ntfy"
|
|
80
|
+
assert dest.ntfy_topic == "mcgram-abc"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_default_routes_to_telegram_when_reachable(tmp_path: Path) -> None:
|
|
84
|
+
"""bot with polling on wins the default even when ntfy is also configured."""
|
|
85
|
+
s = _write(
|
|
86
|
+
tmp_path / "c.yaml",
|
|
87
|
+
"""
|
|
88
|
+
bot:
|
|
89
|
+
operator_chat_id: 7
|
|
90
|
+
disable_polling: false
|
|
91
|
+
ntfy:
|
|
92
|
+
default_topic: mcgram-abc
|
|
93
|
+
""",
|
|
94
|
+
)
|
|
95
|
+
dest = s.resolve_destination()
|
|
96
|
+
assert dest.transport == "telegram"
|
|
97
|
+
assert dest.chat_id == 7
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_default_telegram_sendonly_when_blocked_without_ntfy(tmp_path: Path) -> None:
|
|
101
|
+
"""No ntfy fallback: a blocked bot still seeds a (send-only) telegram default."""
|
|
102
|
+
s = _write(
|
|
103
|
+
tmp_path / "c.yaml",
|
|
104
|
+
"bot:\n operator_chat_id: 5\n disable_polling: true\n",
|
|
105
|
+
)
|
|
106
|
+
dest = s.resolve_destination()
|
|
107
|
+
assert dest.transport == "telegram"
|
|
108
|
+
assert dest.chat_id == 5
|
|
@@ -11,11 +11,13 @@ from mcgram.cli import main
|
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
def test_version(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
|
14
|
+
from mcgram import __version__
|
|
15
|
+
|
|
14
16
|
monkeypatch.setattr(sys, "argv", ["mcgram", "--version"])
|
|
15
17
|
main()
|
|
16
18
|
out = capsys.readouterr().out
|
|
17
19
|
assert "mcgram" in out
|
|
18
|
-
assert
|
|
20
|
+
assert __version__ in out
|
|
19
21
|
|
|
20
22
|
|
|
21
23
|
def test_help(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
|
@@ -16,6 +16,13 @@ def _no_claude_cli(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
16
16
|
monkeypatch.setattr(cli_init, "_claude_cli_available", lambda: False)
|
|
17
17
|
|
|
18
18
|
|
|
19
|
+
@pytest.fixture(autouse=True)
|
|
20
|
+
def _no_tg_probe(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
21
|
+
"""Keep init offline: skip the Telegram reachability probe unless a test
|
|
22
|
+
overrides `_probe_telegram_reachable` itself."""
|
|
23
|
+
monkeypatch.setenv("MCGRAM_INIT_NO_TG_PROBE", "1")
|
|
24
|
+
|
|
25
|
+
|
|
19
26
|
def test_init_creates_files(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
|
20
27
|
capsys: pytest.CaptureFixture[str]) -> None:
|
|
21
28
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
@@ -93,3 +100,58 @@ def test_register_skipped_when_already_present(
|
|
|
93
100
|
init_config()
|
|
94
101
|
out = capsys.readouterr().out
|
|
95
102
|
assert "already in" in out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_init_sets_disable_polling_false_when_telegram_reachable(
|
|
106
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
|
107
|
+
) -> None:
|
|
108
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
109
|
+
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
110
|
+
monkeypatch.setattr(cli_init, "_probe_telegram_reachable", lambda *a, **k: True)
|
|
111
|
+
init_config()
|
|
112
|
+
text = (Path.home() / ".mcgram" / "config.yaml").read_text(encoding="utf-8")
|
|
113
|
+
assert "disable_polling: false" in text
|
|
114
|
+
assert "{{TG_DISABLE_POLLING}}" not in text # placeholder fully substituted
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_init_sets_disable_polling_true_when_telegram_blocked(
|
|
118
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
|
119
|
+
capsys: pytest.CaptureFixture[str],
|
|
120
|
+
) -> None:
|
|
121
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
122
|
+
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
123
|
+
monkeypatch.setattr(cli_init, "_probe_telegram_reachable", lambda *a, **k: False)
|
|
124
|
+
init_config()
|
|
125
|
+
text = (Path.home() / ".mcgram" / "config.yaml").read_text(encoding="utf-8")
|
|
126
|
+
assert "disable_polling: true" in text
|
|
127
|
+
out = capsys.readouterr().out
|
|
128
|
+
assert "unreachable" in out.lower()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_probe_reachable_true_on_any_response(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
132
|
+
monkeypatch.delenv("MCGRAM_INIT_NO_TG_PROBE", raising=False)
|
|
133
|
+
import httpx
|
|
134
|
+
monkeypatch.setattr(httpx, "get", lambda *a, **k: object())
|
|
135
|
+
assert cli_init._probe_telegram_reachable() is True
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_probe_reachable_false_on_http_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
139
|
+
monkeypatch.delenv("MCGRAM_INIT_NO_TG_PROBE", raising=False)
|
|
140
|
+
import httpx
|
|
141
|
+
|
|
142
|
+
def _boom(*_a: object, **_k: object) -> object:
|
|
143
|
+
raise httpx.ConnectError("blocked")
|
|
144
|
+
|
|
145
|
+
monkeypatch.setattr(httpx, "get", _boom)
|
|
146
|
+
assert cli_init._probe_telegram_reachable() is False
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_probe_skipped_via_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
150
|
+
monkeypatch.setenv("MCGRAM_INIT_NO_TG_PROBE", "1")
|
|
151
|
+
import httpx
|
|
152
|
+
|
|
153
|
+
def _boom(*_a: object, **_k: object) -> object:
|
|
154
|
+
raise AssertionError("probe must not touch the network when skipped")
|
|
155
|
+
|
|
156
|
+
monkeypatch.setattr(httpx, "get", _boom)
|
|
157
|
+
assert cli_init._probe_telegram_reachable() is True
|
|
@@ -19,8 +19,10 @@ def _no_claude_cli(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
19
19
|
|
|
20
20
|
@pytest.fixture(autouse=True)
|
|
21
21
|
def _no_real_network(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
22
|
-
"""Don't hit
|
|
22
|
+
"""Don't hit the network from the test suite: skip the welcome seed and the
|
|
23
|
+
Telegram reachability probe (probe-skipped counts as reachable)."""
|
|
23
24
|
monkeypatch.setenv("MCGRAM_INIT_NO_WELCOME", "1")
|
|
25
|
+
monkeypatch.setenv("MCGRAM_INIT_NO_TG_PROBE", "1")
|
|
24
26
|
|
|
25
27
|
|
|
26
28
|
def test_generate_topic_format() -> None:
|
|
@@ -64,6 +64,11 @@ async def test_happy_button(app_state: AppState, httpx_mock) -> None:
|
|
|
64
64
|
url="https://api.telegram.org/botfake-token/answerCallbackQuery",
|
|
65
65
|
json={"ok": True, "result": True},
|
|
66
66
|
)
|
|
67
|
+
# ask now rewrites the question message to record the choice, then strips buttons.
|
|
68
|
+
httpx_mock.add_response(
|
|
69
|
+
url="https://api.telegram.org/botfake-token/editMessageText",
|
|
70
|
+
json={"ok": True, "result": True},
|
|
71
|
+
)
|
|
67
72
|
httpx_mock.add_response(
|
|
68
73
|
url="https://api.telegram.org/botfake-token/editMessageReplyMarkup",
|
|
69
74
|
json={"ok": True, "result": True},
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mcgram-0.2.0 → mcgram-0.2.2}/plans/20260521-brainstorm-mcgram-telegram-mcp/brainstorm-report.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mcgram-0.2.0 → mcgram-0.2.2}/plans/20260521-implement-mcgram-telegram-mcp/phase-05-tests-docs-ci.md
RENAMED
|
File without changes
|
|
File without changes
|
{mcgram-0.2.0 → mcgram-0.2.2}/plans/260522-1355-ntfy-transport-integration/phase-01-ntfy-client.md
RENAMED
|
File without changes
|
{mcgram-0.2.0 → mcgram-0.2.2}/plans/260522-1355-ntfy-transport-integration/phase-02-config-schema.md
RENAMED
|
File without changes
|
{mcgram-0.2.0 → mcgram-0.2.2}/plans/260522-1355-ntfy-transport-integration/phase-03-tools-branch.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mcgram-0.2.0 → mcgram-0.2.2}/plans/260522-1355-ntfy-transport-integration/phase-06-docs-skill.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|