diff --git a/.codex/skills/telegram-plugin-maintenance/SKILL.md b/.codex/skills/telegram-plugin-maintenance/SKILL.md
new file mode 100644
index 0000000..133b099
--- /dev/null
+++ b/.codex/skills/telegram-plugin-maintenance/SKILL.md
@@ -0,0 +1,56 @@
+---
+name: telegram-plugin-maintenance
+description: Maintain speech115/telegram-plugin: GitHub radar, safe PR flow, MCP/control-plane checks, plugin docs sync.
+---
+
+# Telegram Plugin Maintenance
+
+Use this skill for `/Users/sereja/Projects/tools/telegram` when the task touches
+GitHub state, commits/PRs, MCP tool behavior, control-plane commands, plugin
+packaging, or release readiness.
+
+## First Pass
+
+1. Inspect local state with `git status --short --branch`.
+2. For GitHub state, use `github-radar`:
+ - `repobar pulls speech115/telegram-plugin --limit 20 --json`
+ - `repobar issues speech115/telegram-plugin --limit 20 --json`
+ - `repobar ci speech115/telegram-plugin --limit 20 --json`
+3. Read `AGENTS.md`, then the narrower `mcp/AGENTS.md` or
+ `control-plane/AGENTS.md` for files you will touch.
+
+## Safe Change Flow
+
+- Create a `codex/...` branch before repo changes.
+- Commit with `safe-commit`, listing exact paths.
+- Use `safe-pr` only when the user asked to publish a branch/PR.
+- Keep merge, issue close, PR comments, tags, and releases as separate explicit
+ user-approved actions.
+
+## Checks
+
+Run `scripts/safe-gate` before commit. For narrower edits, the focused checks
+are:
+
+```bash
+mcp/.venv/bin/python -m pytest -q mcp/tests
+control-plane/.venv/bin/python -m pytest -q control-plane/tests
+mcp/bin/sync-agent-docs --plugin-dir plugin --check --no-restart --json
+```
+
+Run `scripts/ci-release-gate.sh` for release or packaging changes.
+
+## Telegram-Specific Boundaries
+
+- Default MCP expansion is read-only.
+- Do not add or expose read-state mutators unless the user explicitly asks.
+- Do not copy sessions, rewrite LaunchAgents, sync plugin caches, or start
+ mirror/backfill jobs as incidental cleanup.
+- After MCP tool metadata or surface changes, sync agent docs:
+
+```bash
+mcp/bin/sync-agent-docs --plugin-dir plugin --no-restart --json
+```
+
+Treat plugin drift checks as packaging evidence only; they do not prove live MCP
+runtime health.
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..368d6d6
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,42 @@
+# Telegram Plugin Repo Rules
+
+## Scope
+
+This repo contains three coupled surfaces:
+
+- `mcp/`: Telegram MCP server and live tool behavior.
+- `control-plane/`: local operator commands, release checks, and agent docs.
+- `plugin/`: packaged Codex plugin and bundled Telegram skill.
+
+Prefer the more specific `mcp/AGENTS.md` and `control-plane/AGENTS.md` when
+working inside those directories.
+
+## GitHub Workflow
+
+- Use the `github-radar` skill before PR, merge, release, or issue cleanup work.
+- Use the `git-safe-workflow` skill for commits and PRs.
+- Default path is branch -> local checks -> exact-file commit -> PR.
+- Do not push directly to `main` unless the user explicitly asks for direct-main
+ behavior.
+- Do not merge PRs, close issues, post public GitHub comments, tag, or release
+ without a separate explicit user request.
+
+## Local Gate
+
+Run `scripts/safe-gate` before committing non-trivial changes. It checks:
+
+- whitespace diff hygiene;
+- MCP tests;
+- control-plane tests in portable mode;
+- agent docs sync drift.
+
+For full release verification, run `scripts/ci-release-gate.sh`.
+
+## Product Safety
+
+- Keep Telegram read-surface expansion read-only by default.
+- Do not add read-state mutators, direct sends, cache sync, session copying,
+ LaunchAgent rewrites, or mirror jobs without an explicit request.
+- After MCP tool surface changes, run `mcp/bin/sync-agent-docs --plugin-dir plugin --no-restart --json`.
+- A clean plugin drift report is not enough; verify the actual runtime/tool
+ surface when the change affects MCP behavior.
diff --git a/mcp/docs/agent/tools.md b/mcp/docs/agent/tools.md
index 376a383..40ac0af 100644
--- a/mcp/docs/agent/tools.md
+++ b/mcp/docs/agent/tools.md
@@ -48,7 +48,6 @@ The restricted plugin profile exposes task-shaped tools only. Prefer these names
- `download_media`
- `download_media_batch`
- `download_dialog_media`
-- `telegram_export_members`
## Not on default surface
@@ -56,6 +55,10 @@ Low-level aliases such as `read_today_dialog`, `send_dialog_message`, and admin
mutations require an explicit full/admin profile. Agents on the default surface
must not call them.
+`telegram_export_members` is an explicit owner/local privacy export. It remains
+available in the full owner surface, but it is not routine default-facade context
+gathering.
+
## Modes for `telegram_read`
- `fast` — no voice transcription, no sender names (default for skim)
diff --git a/mcp/src/telegram_mcp/approval_server.py b/mcp/src/telegram_mcp/approval_server.py
index 5b0d425..1f1ceb1 100644
--- a/mcp/src/telegram_mcp/approval_server.py
+++ b/mcp/src/telegram_mcp/approval_server.py
@@ -66,6 +66,14 @@ def _mutate(self, *, token: str, nonce: str, action: str) -> str:
raise ToolContractError("invalid_confirmation_token", "confirmation token is unknown")
if nonce != record.one_time_nonce:
raise ToolContractError("invalid_confirmation_token", "approval nonce is invalid")
+ if record.approval_state == "approved":
+ return "
Уже одобрено
Можно отправлять через telegram_confirmed_send.
"
+ if record.approval_state == "used":
+ return "Уже использовано
Этот токен уже был отправлен.
"
+ if record.approval_state == "rejected":
+ return "Уже отклонено
Создайте новое превью, если нужно отправить сообщение.
"
+ if record.approval_state == "expired":
+ raise ToolContractError("expired_confirmation_token", "confirmation token has expired")
if action == "approve":
store.approve(token)
return "Одобрено
Можно отправлять через telegram_confirmed_send.
"
diff --git a/mcp/src/telegram_mcp/fast_read_today.py b/mcp/src/telegram_mcp/fast_read_today.py
index 1e49f02..d91665b 100644
--- a/mcp/src/telegram_mcp/fast_read_today.py
+++ b/mcp/src/telegram_mcp/fast_read_today.py
@@ -29,6 +29,13 @@ class FastReadError(RuntimeError):
pass
+class FastReadToolError(FastReadError):
+ def __init__(self, endpoint: str, payload: object | None) -> None:
+ self.endpoint = endpoint
+ self.payload = payload
+ super().__init__(f"MCP tool error at {endpoint}: {payload!r}")
+
+
ACCOUNT_ENDPOINTS = {
"main": (8799, "~/.telegram-mcp/launchd.env"),
"crwddy": (8799, "~/.telegram-mcp/launchd.env"),
@@ -142,9 +149,24 @@ def payload_is_tool_error(payload: object | None) -> bool:
def exception_is_tool_error(exc: Exception) -> bool:
+ if isinstance(exc, FastReadToolError):
+ return True
return payload_is_tool_error(str(exc))
+def tool_error_code(payload: object | None) -> str | None:
+ if isinstance(payload, dict):
+ code = payload.get("code") or payload.get("error_code")
+ return str(code) if code is not None else None
+ if isinstance(payload, str):
+ stripped = payload.strip()
+ if ":" in stripped:
+ prefix = stripped.split(":", 1)[0].strip()
+ if prefix:
+ return prefix
+ return None
+
+
async def read_once(
*,
attempt: EndpointAttempt,
@@ -196,7 +218,7 @@ async def read_once(
if bool(getattr(result, "isError", False)) or payload_is_tool_error(payload):
structured = getattr(result, "structuredContent", None)
error_payload = structured if structured is not None else payload
- raise FastReadError(f"MCP tool error at {attempt.endpoint}: {error_payload!r}")
+ raise FastReadToolError(attempt.endpoint, error_payload)
elapsed_seconds = round(time.perf_counter() - started, 3)
from .agent_preflight import observe_fast_read
@@ -326,6 +348,10 @@ def main(argv: list[str] | None = None) -> int:
"error": "telegram_tool_error",
"message": "Live Telegram read failed inside the MCP tool.",
}
+ if isinstance(exc, FastReadToolError):
+ error["endpoint"] = exc.endpoint
+ error["tool_error_code"] = tool_error_code(exc.payload)
+ error["tool_error_payload"] = exc.payload
else:
error = {
"ok": False,
diff --git a/mcp/src/telegram_mcp/send_confirmation.py b/mcp/src/telegram_mcp/send_confirmation.py
index f6827da..83f10d5 100644
--- a/mcp/src/telegram_mcp/send_confirmation.py
+++ b/mcp/src/telegram_mcp/send_confirmation.py
@@ -127,6 +127,10 @@ def reject(self, token: str) -> SendConfirmationRecord:
record = self._expire_if_needed(preview_id, record)
if record.approval_state == "expired":
raise ToolContractError("expired_confirmation_token", "confirmation token has expired")
+ if record.approval_state == "used":
+ raise ToolContractError("invalid_confirmation_token", "confirmation token was already used")
+ if record.approval_state == "approved":
+ return record
record.approval_state = "rejected"
return record
diff --git a/mcp/tests/test_fast_read_today.py b/mcp/tests/test_fast_read_today.py
index ee2869b..7cc2bcc 100644
--- a/mcp/tests/test_fast_read_today.py
+++ b/mcp/tests/test_fast_read_today.py
@@ -4,10 +4,12 @@
from telegram_mcp.fast_read_today import (
EndpointAttempt,
FastReadError,
+ FastReadToolError,
exception_is_tool_error,
endpoint_attempts,
payload_is_tool_error,
read_with_failover,
+ tool_error_code,
)
@@ -70,6 +72,17 @@ def test_exception_is_tool_error_detects_nested_tool_failure(self):
self.assertTrue(exception_is_tool_error(exc))
+ def test_typed_fast_read_tool_error_keeps_payload_code(self):
+ payload = {
+ "code": "permission_denied",
+ "message": "private channel",
+ "next": "ask user for access",
+ }
+ exc = FastReadToolError("http://127.0.0.1:8799/mcp", payload)
+
+ self.assertTrue(exception_is_tool_error(exc))
+ self.assertEqual(tool_error_code(exc.payload), "permission_denied")
+
def test_read_with_failover_does_not_cross_account_by_default(self):
attempts = [
EndpointAttempt("http://127.0.0.1:8799/mcp", "/tmp/a.env", 8799),
diff --git a/mcp/tests/test_send_confirmation.py b/mcp/tests/test_send_confirmation.py
index 42273ff..d226141 100644
--- a/mcp/tests/test_send_confirmation.py
+++ b/mcp/tests/test_send_confirmation.py
@@ -118,6 +118,18 @@ def test_approve_by_token_then_consume_removes_token_lookup(self):
self.assertIsNone(store.get(token))
self.assertIsNone(store.get(preview_id))
+ def test_reject_after_approval_does_not_cancel_approval(self):
+ store = SendConfirmationStore(ttl_seconds=60)
+ payload = {"chat": "@x", "text_hash": "abc"}
+ _preview_id, token, _ = store.mint(payload, preview_text="hi")
+
+ store.approve(token)
+ store.reject(token)
+
+ record = store.get(token)
+ self.assertIsNotNone(record)
+ self.assertEqual(record.approval_state, "approved") # type: ignore[union-attr]
+
class ApprovalServerTests(unittest.TestCase):
def tearDown(self):
@@ -165,6 +177,15 @@ def test_get_does_not_approve_and_post_requires_nonce(self):
self.assertEqual(response.status, 200)
self.assertEqual(store.get(token).approval_state, "approved") # type: ignore[union-attr]
+ body = urlencode({"token": token, "nonce": record.one_time_nonce, "action": "reject"})
+ conn = HTTPConnection("127.0.0.1", port)
+ conn.request("POST", "/telegram/approve", body=body, headers={"Content-Type": "application/x-www-form-urlencoded"})
+ response = conn.getresponse()
+ response.read()
+ conn.close()
+ self.assertEqual(response.status, 200)
+ self.assertEqual(store.get(token).approval_state, "approved") # type: ignore[union-attr]
+
if __name__ == "__main__":
unittest.main()
diff --git a/scripts/safe-gate b/scripts/safe-gate
new file mode 100755
index 0000000..bba3dbc
--- /dev/null
+++ b/scripts/safe-gate
@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+
+python_for() {
+ local package_dir="$1"
+ if [ -x "${ROOT}/${package_dir}/.venv/bin/python" ]; then
+ printf '%s\n' "${ROOT}/${package_dir}/.venv/bin/python"
+ else
+ command -v python3
+ fi
+}
+
+git -C "${ROOT}" diff --check
+
+MCP_PYTHON="$(python_for mcp)"
+CONTROL_PYTHON="$(python_for control-plane)"
+
+(
+ cd "${ROOT}/mcp"
+ "${MCP_PYTHON}" -m unittest discover -s tests
+)
+
+(
+ cd "${ROOT}/control-plane"
+ export TELEGRAM_CI_PORTABLE=1
+ export TELEGRAM_MONOREPO_ROOT="${ROOT}"
+ export TELEGRAM_CONTROL_PLANE_ROOT="${ROOT}/control-plane"
+ export TELEGRAM_MCP_REPO="${ROOT}/mcp"
+ export TELEGRAM_PLUGIN_SOURCE="${ROOT}/plugin"
+ export TELEGRAM_PLUGIN_PACKAGE="${ROOT}/plugin"
+ export TELEGRAM_PROJECTS_ROOT="${ROOT}"
+ "${CONTROL_PYTHON}" -m pytest -q tests
+)
+
+"${ROOT}/mcp/bin/sync-agent-docs" --plugin-dir "${ROOT}/plugin" --check --no-restart --json