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.
Files changed (95) hide show
  1. {mcgram-0.2.0 → mcgram-0.2.2}/CHANGELOG.md +26 -0
  2. {mcgram-0.2.0 → mcgram-0.2.2}/PKG-INFO +9 -9
  3. {mcgram-0.2.0 → mcgram-0.2.2}/README.md +8 -8
  4. {mcgram-0.2.0 → mcgram-0.2.2}/pyproject.toml +1 -1
  5. {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/__init__.py +1 -1
  6. {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/ask_registry.py +24 -1
  7. {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/cli_init.py +43 -4
  8. {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/config.py +18 -5
  9. {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/data/config.example.yaml +2 -0
  10. {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_ask_registry.py +46 -0
  11. {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_channels.py +45 -0
  12. {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_cli.py +3 -1
  13. {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_cli_init.py +62 -0
  14. {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_cli_init_ntfy.py +3 -1
  15. {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_tools_ask.py +5 -0
  16. {mcgram-0.2.0 → mcgram-0.2.2}/uv.lock +1 -1
  17. {mcgram-0.2.0 → mcgram-0.2.2}/.env.example +0 -0
  18. {mcgram-0.2.0 → mcgram-0.2.2}/.github/workflows/ci.yml +0 -0
  19. {mcgram-0.2.0 → mcgram-0.2.2}/.gitignore +0 -0
  20. {mcgram-0.2.0 → mcgram-0.2.2}/LICENSE +0 -0
  21. {mcgram-0.2.0 → mcgram-0.2.2}/config.example.yaml +0 -0
  22. {mcgram-0.2.0 → mcgram-0.2.2}/docs/architecture.md +0 -0
  23. {mcgram-0.2.0 → mcgram-0.2.2}/docs/images/flow.png +0 -0
  24. {mcgram-0.2.0 → mcgram-0.2.2}/docs/images/hero.png +0 -0
  25. {mcgram-0.2.0 → mcgram-0.2.2}/docs/images/logo.png +0 -0
  26. {mcgram-0.2.0 → mcgram-0.2.2}/docs/manual-smoke-test.md +0 -0
  27. {mcgram-0.2.0 → mcgram-0.2.2}/docs/security-threat-model.md +0 -0
  28. {mcgram-0.2.0 → mcgram-0.2.2}/plans/20260521-brainstorm-mcgram-telegram-mcp/brainstorm-report.md +0 -0
  29. {mcgram-0.2.0 → mcgram-0.2.2}/plans/20260521-implement-mcgram-telegram-mcp/phase-01-scaffolding-config-audit.md +0 -0
  30. {mcgram-0.2.0 → mcgram-0.2.2}/plans/20260521-implement-mcgram-telegram-mcp/phase-02-telegram-client-send-tools.md +0 -0
  31. {mcgram-0.2.0 → mcgram-0.2.2}/plans/20260521-implement-mcgram-telegram-mcp/phase-03-ask-flow-reminders.md +0 -0
  32. {mcgram-0.2.0 → mcgram-0.2.2}/plans/20260521-implement-mcgram-telegram-mcp/phase-04-cli-companion-skill.md +0 -0
  33. {mcgram-0.2.0 → mcgram-0.2.2}/plans/20260521-implement-mcgram-telegram-mcp/phase-05-tests-docs-ci.md +0 -0
  34. {mcgram-0.2.0 → mcgram-0.2.2}/plans/20260521-implement-mcgram-telegram-mcp/plan.md +0 -0
  35. {mcgram-0.2.0 → mcgram-0.2.2}/plans/260522-1355-ntfy-transport-integration/phase-01-ntfy-client.md +0 -0
  36. {mcgram-0.2.0 → mcgram-0.2.2}/plans/260522-1355-ntfy-transport-integration/phase-02-config-schema.md +0 -0
  37. {mcgram-0.2.0 → mcgram-0.2.2}/plans/260522-1355-ntfy-transport-integration/phase-03-tools-branch.md +0 -0
  38. {mcgram-0.2.0 → mcgram-0.2.2}/plans/260522-1355-ntfy-transport-integration/phase-04-ask-and-reminders.md +0 -0
  39. {mcgram-0.2.0 → mcgram-0.2.2}/plans/260522-1355-ntfy-transport-integration/phase-05-cli.md +0 -0
  40. {mcgram-0.2.0 → mcgram-0.2.2}/plans/260522-1355-ntfy-transport-integration/phase-06-docs-skill.md +0 -0
  41. {mcgram-0.2.0 → mcgram-0.2.2}/plans/260522-1355-ntfy-transport-integration/plan.md +0 -0
  42. {mcgram-0.2.0 → mcgram-0.2.2}/scripts/live-smoke.py +0 -0
  43. {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/audit.py +0 -0
  44. {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/cli.py +0 -0
  45. {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/cli_audit.py +0 -0
  46. {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/cli_channel.py +0 -0
  47. {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/cli_doctor.py +0 -0
  48. {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/data/__init__.py +0 -0
  49. {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/data/skill/SKILL.md +0 -0
  50. {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/dispatch.py +0 -0
  51. {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/errors.py +0 -0
  52. {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/lock.py +0 -0
  53. {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/ntfy_client.py +0 -0
  54. {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/polling.py +0 -0
  55. {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/rate_limiter.py +0 -0
  56. {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/reminders.py +0 -0
  57. {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/runtime.py +0 -0
  58. {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/server.py +0 -0
  59. {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/skill_installer.py +0 -0
  60. {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/tg_client.py +0 -0
  61. {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/tools/__init__.py +0 -0
  62. {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/tools/ask.py +0 -0
  63. {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/tools/cancel_reminder.py +0 -0
  64. {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/tools/list_reminders.py +0 -0
  65. {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/tools/send_file.py +0 -0
  66. {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/tools/send_message.py +0 -0
  67. {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/tools/send_video.py +0 -0
  68. {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/tools/set_reminder.py +0 -0
  69. {mcgram-0.2.0 → mcgram-0.2.2}/src/mcgram/update_dispatcher.py +0 -0
  70. {mcgram-0.2.0 → mcgram-0.2.2}/tests/__init__.py +0 -0
  71. {mcgram-0.2.0 → mcgram-0.2.2}/tests/conftest.py +0 -0
  72. {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_audit.py +0 -0
  73. {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_cli_audit.py +0 -0
  74. {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_cli_channel.py +0 -0
  75. {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_cli_channel_ntfy.py +0 -0
  76. {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_cli_doctor.py +0 -0
  77. {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_cli_doctor_ntfy.py +0 -0
  78. {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_config.py +0 -0
  79. {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_config_ntfy.py +0 -0
  80. {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_lock.py +0 -0
  81. {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_ntfy_client.py +0 -0
  82. {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_polling.py +0 -0
  83. {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_polling_conflict.py +0 -0
  84. {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_rate_limiter.py +0 -0
  85. {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_reminders.py +0 -0
  86. {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_server_main.py +0 -0
  87. {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_server_smoke.py +0 -0
  88. {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_skill_installer.py +0 -0
  89. {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_tg_client.py +0 -0
  90. {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_tools_ntfy.py +0 -0
  91. {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_tools_reminder.py +0 -0
  92. {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_tools_send_file.py +0 -0
  93. {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_tools_send_message.py +0 -0
  94. {mcgram-0.2.0 → mcgram-0.2.2}/tests/test_tools_send_video.py +0 -0
  95. {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.0
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
  [![CI](https://github.com/tvtdev94/mcgram/actions/workflows/ci.yml/badge.svg)](https://github.com/tvtdev94/mcgram/actions/workflows/ci.yml)
50
50
  [![PyPI](https://img.shields.io/pypi/v/mcgram.svg)](https://pypi.org/project/mcgram/)
51
51
  [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
52
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
52
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/tvtdev94/mcgram/blob/main/LICENSE)
53
53
  [![Tests](https://img.shields.io/badge/tests-153%20passing-brightgreen.svg)](#)
54
54
  [![Coverage](https://img.shields.io/badge/coverage-84%25-brightgreen.svg)](#)
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
  [![CI](https://github.com/tvtdev94/mcgram/actions/workflows/ci.yml/badge.svg)](https://github.com/tvtdev94/mcgram/actions/workflows/ci.yml)
10
10
  [![PyPI](https://img.shields.io/pypi/v/mcgram.svg)](https://pypi.org/project/mcgram/)
11
11
  [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
12
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
12
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/tvtdev94/mcgram/blob/main/LICENSE)
13
13
  [![Tests](https://img.shields.io/badge/tests-153%20passing-brightgreen.svg)](#)
14
14
  [![Coverage](https://img.shields.io/badge/coverage-84%25-brightgreen.svg)](#)
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.0"
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"
@@ -1,3 +1,3 @@
1
1
  """mcgram - notification bridge for Claude Code (Telegram + ntfy.sh)."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.2.2"
@@ -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 _strip_keyboard(client, chat_id, msg["message_id"])
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 placeholder topic into the bundled config template."""
125
- return _bundled_config_yaml().replace("{{NTFY_TOPIC}}", topic)
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
- cfg.write_text(_render_config_template(topic), encoding="utf-8")
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 is configured.
157
+ """Auto-seed `default` channel based on which transport can actually serve.
158
158
 
159
- Priority when both transports are present and user hasn't declared
160
- `channels.default` explicitly: telegram wins (preserves pre-ntfy behavior).
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
- if self.bot is not None:
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 self.ntfy is not None and self.ntfy.default_topic:
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 "0.2.0" in out
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 ntfy.sh from the test suite; default to skipping the welcome seed."""
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},
@@ -506,7 +506,7 @@ wheels = [
506
506
 
507
507
  [[package]]
508
508
  name = "mcgram"
509
- version = "0.2.0"
509
+ version = "0.2.2"
510
510
  source = { editable = "." }
511
511
  dependencies = [
512
512
  { name = "httpx" },
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