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